first commit

This commit is contained in:
dev
2026-02-22 12:12:02 +08:00
commit b236048a24
1467 changed files with 160403 additions and 0 deletions
@@ -0,0 +1,22 @@
package org.dromara;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
/**
* 启动程序
*
* @author pengles
*/
@SpringBootApplication
public class PoJieApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(PoJieApplication.class);
application.setApplicationStartup(new BufferingApplicationStartup(2048));
application.run(args);
System.out.println("(♥◠‿◠)ノ゙ PoJie启动成功 ლ(´ڡ`ლ)゙");
}
}
@@ -0,0 +1,18 @@
package org.dromara;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
/**
* web容器中进行部署
*
* @author pengles
*/
public class PoJieServletInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(PoJieApplication.class);
}
}
@@ -0,0 +1,321 @@
package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils;
import org.dromara.app.domain.vo.AppConfigVo;
import org.dromara.app.service.AppLoginService;
import org.dromara.app.service.IAppAuthStrategy;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginBody;
import org.dromara.common.core.domain.model.RegisterBody;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.ratelimiter.annotation.RateLimiter;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.social.config.properties.SocialLoginConfigProperties;
import org.dromara.common.social.config.properties.SocialProperties;
import org.dromara.common.social.utils.SocialUtils;
import org.dromara.member.domain.bo.AppUserBo;
import org.dromara.member.service.AppSocialAuthService;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.service.SysClientService;
import org.dromara.system.service.SysConfigService;
import org.dromara.web.domain.vo.LoginVo;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* App端认证控制器
*
* @author pengles
*/
@Slf4j
@SaIgnore
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/auth")
public class AppAuthController {
private final SocialProperties socialProperties;
private final AppLoginService appLoginService;
private final org.dromara.app.service.AppLoginRiskService appLoginRiskService;
private final SysClientService clientService;
private final SysConfigService configService;
private final AppSocialAuthService appSocialAuthService;
/**
* App端登录方法
*
* @param body 登录信息
* @return 结果
*/
// TODO: App端暂时不加密,先完善用户端的加密方法在加密
// @ApiEncrypt
@PostMapping("/login")
public R<LoginVo> login(@RequestBody String body) {
LoginBody loginBody = JsonUtils.parseObject(body, LoginBody.class);
ValidatorUtils.validate(loginBody);
// 授权类型和客户端id
String clientId = loginBody.getClientId();
String grantType = loginBody.getGrantType();
SysClientVo client = clientService.queryByClientId(clientId);
// 查询不到 client 或 client 内不包含 grantType
if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), grantType)) {
log.info("客户端id: {} 认证类型:{} 异常!.", clientId, grantType);
return R.fail(MessageUtils.message("auth.grant.type.error"));
} else if (!SystemConstants.NORMAL.equals(client.getStatus())) {
return R.fail(MessageUtils.message("auth.grant.type.blocked"));
}
// App端登录
LoginVo loginVo = IAppAuthStrategy.authenticate(body, client, grantType);
Long userId = LoginHelper.getUserId();
// 记录登录信息
appLoginRiskService.checkRisk(userId, LoginHelper.getLoginUser().getIpaddr());
appLoginService.recordLoginInfo(userId, LoginHelper.getLoginUser().getIpaddr());
return R.ok(loginVo);
}
/**
* App端退出登录
*
* @return 结果
*/
@PostMapping("/logout")
public R<Void> logout() {
appLoginService.logout();
return R.ok("退出成功");
}
/**
* App端用户注册
*
* @param user 用户信息
* @return 结果
*/
// TODO: App端暂时不加密,先完善用户端的加密方法在加密
// @ApiEncrypt
@RateLimiter(key = "register", time = 60, count = 1)
@PostMapping("/register")
public R<Void> register(@Validated @RequestBody RegisterBody user) {
if (!configService.selectRegisterEnabled()) {
return R.fail("当前系统没有开启注册功能!");
}
AppUserBo bo = new AppUserBo();
bo.setUserName(user.getUsername());
bo.setPassword(user.getPassword());
bo.setNickName(user.getUsername());
// 校验是否已存在
if (!appLoginService.checkUserNameUnique(bo.getUserName())) {
return R.fail("注册失败,账号已存在");
}
// 执行注册
return appLoginService.register(bo) ? R.ok() : R.fail("注册失败");
}
/**
* 获取App端配置信息
*
* @return 配置信息
*/
@GetMapping("/config")
public R<AppConfigVo> getConfig() {
AppConfigVo config = new AppConfigVo();
config.setRegisterEnabled(configService.selectRegisterEnabled());
// 可以添加更多App端特有的配置
return R.ok(config);
}
/**
* 获取登录二维码与票据
*/
@GetMapping("/qrcode")
public R<java.util.Map<String,String>> getQrCode() {
String ticket = cn.hutool.core.util.IdUtil.fastSimpleUUID();
RedisUtils.setCacheObject("APP_LOGIN_QR:" + ticket, "PENDING", java.time.Duration.ofMinutes(5));
String url = "/wx/mp/qrcode/url?ticket=" + ticket;
java.util.Map<String,String> res = new java.util.HashMap<>();
res.put("ticket", ticket);
res.put("url", url);
return R.ok(res);
}
/**
* 轮询二维码登录状态
*/
@GetMapping("/qrcode/login")
public R<String> pollQrLogin(@RequestParam String ticket) {
String key = "APP_LOGIN_QR:" + ticket;
String status = RedisUtils.getCacheObject(key);
if (status == null) {
return R.ok("EXPIRED");
}
return R.ok(status);
}
/**
* 绑定第三方账号
*
* @param body 第三方账号信息
* @return 结果
*/
// TODO: App端暂时不加密,先完善前段的加密方法在加密
// @ApiEncrypt
@PostMapping("/bind/social")
public R<Void> bindSocial(@RequestBody String body) {
SocialLoginBody socialLoginBody = JsonUtils.parseObject(body, SocialLoginBody.class);
ValidatorUtils.validate(socialLoginBody);
Long userId = LoginHelper.getUserId();
if (userId == null) {
return R.fail("请先登录");
}
try {
// 获取第三方登录信息
AuthResponse<AuthUser> response = SocialUtils.loginAuth(
socialLoginBody.getSource(), socialLoginBody.getSocialCode(),
socialLoginBody.getSocialState(), socialProperties);
AuthUser authUserData = response.getData();
// 判断授权响应是否成功
if (!response.ok()) {
return R.fail(response.getMsg());
}
// 执行绑定逻辑
appSocialAuthService.socialRegister(authUserData);
log.info("用户 {} 绑定第三方账号成功,平台: {}", userId, socialLoginBody.getSource());
return R.ok("绑定成功");
} catch (Exception e) {
log.error("绑定第三方账号失败", e);
return R.fail("绑定失败: " + e.getMessage());
}
}
/**
* 解绑第三方账号
*
* @param platform 第三方平台
* @return 结果
*/
@PostMapping("/unbind/social")
public R<Void> unbindSocial(@RequestParam String platform) {
Long userId = LoginHelper.getUserId();
if (userId == null) {
return R.fail("请先登录");
}
if (StringUtils.isBlank(platform)) {
return R.fail("平台参数不能为空");
}
try {
// 查找并删除绑定关系
boolean success = appSocialAuthService.deleteByUserIdAndPlatform(userId, platform);
if (success) {
log.info("用户 {} 解绑第三方账号成功,平台: {}", userId, platform);
return R.ok("解绑成功");
} else {
log.warn("用户 {} 尝试解绑不存在的第三方账号,平台: {}", userId, platform);
return R.fail("未找到绑定关系");
}
} catch (Exception e) {
log.error("用户 {} 解绑第三方账号失败,平台: {}", userId, platform, e);
return R.fail("解绑失败: " + e.getMessage());
}
}
/**
* 获取App端第三方登录跳转URL
*
* @param source 登录来源
* @param domain 域名
* @return 结果
*/
@GetMapping("/binding/{source}")
public R<String> authBinding(@PathVariable("source") String source, @RequestParam String domain) {
SocialLoginConfigProperties obj = socialProperties.getType().get(source);
if (ObjectUtil.isNull(obj)) {
return R.fail(source + "平台账号暂不支持");
}
AuthRequest authRequest = SocialUtils.getAuthRequest(source, socialProperties);
Map<String, String> map = new HashMap<>();
map.put("domain", domain);
map.put("state", AuthStateUtils.createState());
String authorizeUrl = authRequest.authorize(Base64.encode(JsonUtils.toJsonString(map), StandardCharsets.UTF_8));
return R.ok("操作成功", authorizeUrl);
}
/**
* App端社交账号绑定回调(需要token)
*
* @param loginBody 请求体
* @return 结果
*/
@PostMapping("/social/binding")
public R<Void> socialBinding(@RequestBody SocialLoginBody loginBody) {
// 校验token
Long userId = LoginHelper.getUserId();
if (userId == null) {
return R.fail("请先登录");
}
try {
// 获取第三方登录信息
AuthResponse<AuthUser> response = SocialUtils.loginAuth(
loginBody.getSource(), loginBody.getSocialCode(),
loginBody.getSocialState(), socialProperties);
AuthUser authUserData = response.getData();
// 判断授权响应是否成功
if (!response.ok()) {
return R.fail(response.getMsg());
}
appSocialAuthService.socialRegister(authUserData);
return R.ok("绑定成功");
} catch (Exception e) {
log.error("社交账号绑定失败", e);
return R.fail("绑定失败: " + e.getMessage());
}
}
/**
* 获取用户绑定的第三方账号列表
*
* @return 绑定列表
*/
@GetMapping("/social/list")
public R<?> getSocialList() {
Long userId = LoginHelper.getUserId();
if (userId == null) {
return R.fail("请先登录");
}
try {
var socialList = appSocialAuthService.queryByUserId(userId);
return R.ok(socialList);
} catch (Exception e) {
log.error("获取第三方账号绑定列表失败", e);
return R.fail("获取失败: " + e.getMessage());
}
}
}
@@ -0,0 +1,59 @@
package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.app.session.AppSession;
import org.dromara.app.session.AppSessionManager;
import org.dromara.common.core.domain.R;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/session")
public class AppSessionController {
private final AppSessionManager sessionManager;
@GetMapping("/active")
@SaCheckLogin
public R<List<AppSession>> active() {
Long userId = LoginHelper.getUserId();
return R.ok(sessionManager.list(userId));
}
@PostMapping("/terminate")
@SaCheckLogin
public R<Boolean> terminate(@RequestParam String sessionId) {
Long userId = LoginHelper.getUserId();
return R.ok(sessionManager.terminate(userId, sessionId));
}
@PostMapping("/heartbeat")
@SaCheckLogin
public R<Boolean> heartbeat(@RequestParam String sessionId) {
Long userId = LoginHelper.getUserId();
return R.ok(sessionManager.heartbeat(userId, sessionId, Duration.ofDays(30)));
}
@GetMapping("/sse")
@SaCheckLogin
public SseEmitter sse() {
Long userId = LoginHelper.getUserId();
SseEmitter emitter = new SseEmitter(Duration.ofHours(1).toMillis());
String ch = "APP:SESS_EVT:" + userId;
org.dromara.common.redis.utils.RedisUtils.subscribe(ch, String.class, msg -> {
try {
emitter.send(SseEmitter.event().name("session").data(msg));
} catch (IOException e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
@@ -0,0 +1,118 @@
package org.dromara.app.domain;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.dromara.common.core.domain.model.LoginUser;
import java.io.Serial;
import java.util.Set;
/**
* App登录用户身份权限
*
* @author pengles
*/
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
public class AppLoginUser extends LoginUser {
@Serial
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private Long userId;
/**
* 部门ID
*/
private Long deptId;
/**
* 部门名
*/
private String deptName;
/**
* 用户唯一标识
*/
private String token;
/**
* 用户类型
*/
private String userType;
/**
* 登录时间
*/
private Long loginTime;
/**
* 过期时间
*/
private Long expireTime;
/**
* 登录IP地址
*/
private String ipaddr;
/**
* 登录地点
*/
private String loginLocation;
/**
* 浏览器类型
*/
private String browser;
/**
* 操作系统
*/
private String os;
/**
* 菜单权限
*/
private Set<String> menuPermission;
/**
* 角色权限
*/
private Set<String> rolePermission;
/**
* 用户名
*/
private String username;
/**
* 用户昵称
*/
private String nickname;
/**
* 信息是否完善
*/
private Boolean infoComplete;
/**
* 获取登录id
*/
@Override
public String getLoginId() {
if (userType == null) {
throw new IllegalArgumentException("用户类型不能为空");
}
if (userId == null) {
throw new IllegalArgumentException("用户ID不能为空");
}
return userType + ":" + userId;
}
}
@@ -0,0 +1,13 @@
package org.dromara.app.domain.vo;
public class AppConfigVo {
private boolean registerEnabled;
public boolean isRegisterEnabled() {
return registerEnabled;
}
public void setRegisterEnabled(boolean registerEnabled) {
this.registerEnabled = registerEnabled;
}
}
@@ -0,0 +1,157 @@
package org.dromara.app.listener;
import cn.dev33.satoken.listener.SaTokenListener;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.service.AppLoginService;
import org.dromara.common.core.constant.CacheConstants;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.domain.dto.UserOnlineDTO;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ip.AddressUtils;
import org.dromara.common.log.event.LogininforEvent;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* App用户行为 侦听器的实现
*
* @author pengles
*/
@RequiredArgsConstructor
@Component
@Slf4j
public class AppUserActionListener implements SaTokenListener {
private final AppLoginService appLoginService;
/**
* 每次登录时触发
*/
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter) {
UserAgent userAgent = UserAgentUtil.parse(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = ServletUtils.getClientIP();
UserOnlineDTO dto = new UserOnlineDTO();
dto.setIpaddr(ip);
dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
dto.setBrowser(userAgent.getBrowser().getName());
dto.setOs(userAgent.getOs().getName());
dto.setLoginTime(System.currentTimeMillis());
dto.setTokenId(tokenValue);
String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY);
dto.setUserName(username);
dto.setClientKey((String) loginParameter.getExtra(LoginHelper.CLIENT_KEY));
dto.setDeviceType(loginParameter.getDeviceType());
dto.setDeptName((String) loginParameter.getExtra(LoginHelper.DEPT_NAME_KEY));
if (loginParameter.getTimeout() == -1) {
RedisUtils.setCacheObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue, dto);
} else {
RedisUtils.setCacheObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue, dto, Duration.ofSeconds(loginParameter.getTimeout()));
}
// 记录登录日志
LogininforEvent logininforEvent = new LogininforEvent();
logininforEvent.setUsername(username);
logininforEvent.setStatus(Constants.LOGIN_SUCCESS);
logininforEvent.setMessage(MessageUtils.message("user.login.success"));
logininforEvent.setRequest(ServletUtils.getRequest());
SpringUtils.context().publishEvent(logininforEvent);
// 更新App用户登录信息
appLoginService.recordLoginInfo((Long) loginParameter.getExtra(LoginHelper.USER_KEY), ip);
log.info("app user doLogin, userId:{}, token:{}", loginId, tokenValue);
}
/**
* 每次注销时触发
*/
@Override
public void doLogout(String loginType, Object loginId, String tokenValue) {
RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue);
log.info("app user doLogout, userId:{}, token:{}", loginId, tokenValue);
}
/**
* 每次被踢下线时触发
*/
@Override
public void doKickout(String loginType, Object loginId, String tokenValue) {
RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue);
log.info("app user doKickout, userId:{}, token:{}", loginId, tokenValue);
}
/**
* 每次被顶下线时触发
*/
@Override
public void doReplaced(String loginType, Object loginId, String tokenValue) {
RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue);
log.info("app user doReplaced, userId:{}, token:{}", loginId, tokenValue);
}
/**
* 每次被封禁时触发
*/
@Override
public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) {
log.info("App用户被禁用: loginType={}, loginId={}, service={}, level={}, disableTime={}",
loginType, loginId, service, level, disableTime);
// 删除在线用户信息
String tokenValue = StpUtil.getTokenValueByLoginId(loginId);
if (StringUtils.isNotBlank(tokenValue)) {
RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue);
}
}
/**
* 每次被解封时触发
*/
@Override
public void doUntieDisable(String loginType, Object loginId, String service) {
}
/**
* 每次打开二级认证时触发
*/
@Override
public void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) {
}
/**
* 每次关闭二级认证时触发
*/
@Override
public void doCloseSafe(String loginType, String tokenValue, String service) {
}
/**
* 每次创建Session时触发
*/
@Override
public void doCreateSession(String id) {
}
/**
* 每次注销Session时触发
*/
@Override
public void doLogoutSession(String id) {
}
/**
* 每次Token续期时触发
*/
@Override
public void doRenewTimeout(String loginType, Object loginId, String tokenValue, long timeout) {
}
}
@@ -0,0 +1,53 @@
package org.dromara.app.service;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.member.domain.vo.AppUserVo;
import org.dromara.common.core.utils.ip.AddressUtils;
import org.dromara.common.mail.utils.MailUtils;
import org.dromara.member.service.AppUserService;
import org.springframework.stereotype.Service;
/**
* App登录异地风险检测服务
* 比对用户上次登录IP归属地与本次登录IP归属地,若发生变化则进行日志与邮件告警。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AppLoginRiskService {
private final AppUserService appUserService;
/**
* 执行异地登录风险检测
* @param userId 当前登录用户ID
* @param currentIp 本次登录IP
*/
public void checkRisk(Long userId, String currentIp) {
AppUserVo user = appUserService.queryById(userId);
if (user == null) {
return;
}
String lastIp = StrUtil.blankToDefault(user.getLoginIp(), "");
String lastRegion = AddressUtils.getRealAddressByIP(lastIp);
String currentRegion = AddressUtils.getRealAddressByIP(currentIp);
boolean validLast = !AddressUtils.UNKNOWN_IP.equals(lastRegion) && !AddressUtils.UNKNOWN_ADDRESS.equals(lastRegion);
boolean validCurrent = !AddressUtils.UNKNOWN_IP.equals(currentRegion) && !AddressUtils.UNKNOWN_ADDRESS.equals(currentRegion);
boolean isLocal = AddressUtils.LOCAL_ADDRESS.equals(lastRegion) || AddressUtils.LOCAL_ADDRESS.equals(currentRegion);
if (validLast && validCurrent && !isLocal && !StrUtil.equals(lastRegion, currentRegion)) {
log.warn("App用户 {} 异地登录风险, 上次:{}({}), 本次:{}({})", userId, lastRegion, lastIp, currentRegion, currentIp);
String email = user.getEmail();
if (StrUtil.isNotBlank(email)) {
String subject = "异地登录告警";
String content = "您的账号检测到异地登录风险\n上次登录: " + lastRegion + " (" + lastIp + ")\n本次登录: " + currentRegion + " (" + currentIp + ")";
try {
MailUtils.sendText(email, subject, content);
} catch (Exception e) {
log.error("发送异地登录告警邮件失败: {}", e.getMessage());
}
}
}
}
}
@@ -0,0 +1,151 @@
package org.dromara.app.service;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.ObjectUtil;
import org.dromara.common.core.utils.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.member.domain.AppUser;
import org.dromara.member.domain.bo.AppUserBo;
import org.dromara.common.core.constant.CacheConstants;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.log.event.LogininforEvent;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.member.service.AppUserService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.function.Supplier;
/**
* App登录校验方法
*
* @author pengles
*/
@RequiredArgsConstructor
@Slf4j
@Service
public class AppLoginService {
private final AppUserService appUserService;
private final ApplicationEventPublisher eventPublisher;
@Value("${user.password.maxRetryCount}")
private Integer maxRetryCount;
@Value("${user.password.lockTime}")
private Integer lockTime;
/**
* 退出登录
*/
public void logout() {
try {
AppLoginUser loginUser = LoginHelper.getLoginUser();
if (ObjectUtil.isNotNull(loginUser)) {
StpUtil.logout();
recordLogininfor(loginUser.getUsername(), Constants.LOGOUT, MessageUtils.message("user.logout.success"));
}
} catch (NotLoginException ignored) {
}
}
/**
* 注册
*/
public Boolean register(AppUserBo bo) {
return appUserService.register(bo);
}
/**
* 校验用户名唯一
*/
public boolean checkUserNameUnique(String userName) {
return appUserService.checkUserNameUnique(userName);
}
/**
* 记录登录信息
*
* @param username 用户名
* @param status 状态
* @param message 消息内容
*/
public void recordLogininfor(String username, String status, String message) {
LogininforEvent logininforEvent = new LogininforEvent();
logininforEvent.setUsername(username);
logininforEvent.setStatus(status);
logininforEvent.setMessage(message);
logininforEvent.setRequest(ServletUtils.getRequest());
eventPublisher.publishEvent(logininforEvent);
}
/**
* 构建登录用户
*/
public AppLoginUser buildLoginUser(AppUser appUser) {
AppLoginUser loginUser = new AppLoginUser();
loginUser.setUserId(appUser.getUserId());
loginUser.setUsername(appUser.getUserName());
loginUser.setUserType("app");
loginUser.setInfoComplete(StringUtils.isNotBlank(appUser.getPhone()));
// 可以根据需要添加更多字段
return loginUser;
}
/**
* 记录登录信息
*
* @param userId 用户ID
* @param ip IP地址
*/
public void recordLoginInfo(Long userId, String ip) {
AppUser appUser = new AppUser();
appUser.setUserId(userId);
appUser.setLoginIp(ip);
appUser.setLoginDate(LocalDateTime.now());
appUserService.updateById(appUser);
}
/**
* 登录校验
*/
public void checkLogin(LoginType loginType, String username, Supplier<Boolean> supplier) {
String errorKey = CacheConstants.PWD_ERR_CNT_KEY + username;
String loginFail = Constants.LOGIN_FAIL;
// 获取用户登录错误次数,默认为0 (可自定义限制策略 例如: key + username + ip)
int errorNumber = ObjectUtil.defaultIfNull(RedisUtils.getCacheObject(errorKey), 0);
// 锁定时间内登录 则踢出
if (errorNumber >= maxRetryCount) {
recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime));
throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime);
}
if (supplier.get()) {
// 是否第一次
errorNumber++;
recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitCount(), errorNumber));
// 达到规定错误次数 则锁定登录
if (errorNumber >= maxRetryCount) {
RedisUtils.setCacheObject(errorKey, errorNumber, Duration.ofMinutes(lockTime));
throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime);
} else {
RedisUtils.setCacheObject(errorKey, errorNumber);
throw new UserException(loginType.getRetryLimitCount(), errorNumber);
}
}
// 登录成功 清空错误次数
RedisUtils.deleteObject(errorKey);
}
}
@@ -0,0 +1,44 @@
package org.dromara.app.service;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
/**
* App认证策略
*
* @author pengles
*/
public interface IAppAuthStrategy {
String BASE_NAME = "AppAuthStrategy";
/**
* 登录
*
* @param body 登录对象
* @param client 授权管理视图对象
* @param grantType 授权类型
* @return 登录验证信息
*/
static LoginVo authenticate(String body, SysClientVo client, String grantType) {
// 授权类型和客户端id
String beanName = grantType + BASE_NAME;
if (!SpringUtils.containsBean(beanName)) {
throw new ServiceException("授权类型不正确!");
}
IAppAuthStrategy instance = SpringUtils.getBean(beanName);
return instance.login(body, client);
}
/**
* 登录
*
* @param body 登录对象
* @param client 授权管理视图对象
* @return 登录验证信息
*/
LoginVo login(String body, SysClientVo client);
}
@@ -0,0 +1,107 @@
package org.dromara.app.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.member.domain.vo.AppUserVo;
import org.dromara.app.service.AppLoginService;
import org.dromara.member.service.AppUserService;
import org.dromara.app.service.IAppAuthStrategy;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.model.EmailLoginBody;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
import org.springframework.stereotype.Service;
/**
* App邮箱认证策略
*
* @author pengles
*/
@Slf4j
@Service("app_email" + IAppAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class AppEmailAuthStrategy implements IAppAuthStrategy {
private final AppLoginService appLoginService;
private final AppUserService appUserService;
@Override
public LoginVo login(String body, SysClientVo client) {
EmailLoginBody loginBody = JsonUtils.parseObject(body, EmailLoginBody.class);
ValidatorUtils.validate(loginBody);
String email = loginBody.getEmail();
String emailCode = loginBody.getEmailCode();
AppUserVo user = loadUserByEmail(email);
appLoginService.checkLogin(LoginType.EMAIL, user.getUserName(), () -> !validateEmailCode(email, emailCode));
// 构建App登录用户
AppLoginUser loginUser = new AppLoginUser();
loginUser.setUserId(user.getUserId());
loginUser.setUsername(user.getUserName());
loginUser.setUserType("app");
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
return loginVo;
}
/**
* 校验邮箱验证码
*/
private boolean validateEmailCode(String email, String emailCode) {
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + email);
if (StringUtils.isBlank(code)) {
appLoginService.recordLogininfor(email, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
return code.equals(emailCode);
}
/**
* 通过邮箱加载用户信息
*/
private AppUserVo loadUserByEmail(String email) {
AppUserVo appUser = appUserService.selectUserByEmail(email);
if (ObjectUtil.isNull(appUser)) {
log.info("登录用户:{} 不存在.", email);
throw new UserException("user.not.exists", email);
} else if ("1".equals(appUser.getStatus())) {
log.info("登录用户:{} 已被停用.", email);
throw new UserException("user.blocked", email);
}
// 将 AppUser 转换为 AppUserVo
AppUserVo user = new AppUserVo();
BeanUtil.copyProperties(appUser, user);
return user;
}
}
@@ -0,0 +1,166 @@
package org.dromara.app.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.BCrypt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.member.domain.AppUser;
import org.dromara.member.domain.vo.AppUserVo;
import org.dromara.app.service.AppLoginService;
import org.dromara.member.service.AppUserService;
import org.dromara.app.service.IAppAuthStrategy;
import org.dromara.app.session.AppSession;
import org.dromara.app.session.AppSessionManager;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.model.PasswordLoginBody;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.user.CaptchaException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
/**
* App密码认证策略
*
* @author pengles
*/
@Slf4j
@Service("app_password" + IAppAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class AppPasswordAuthStrategy implements IAppAuthStrategy {
private final CaptchaProperties captchaProperties;
private final AppLoginService appLoginService;
private final AppUserService appUserService;
private final AppSessionManager appSessionManager;
@Override
public LoginVo login(String body, SysClientVo client) {
PasswordLoginBody loginBody = JsonUtils.parseObject(body, PasswordLoginBody.class);
ValidatorUtils.validate(loginBody);
String username = loginBody.getUsername();
String password = loginBody.getPassword();
String code = loginBody.getCode();
String uuid = loginBody.getUuid();
boolean captchaEnabled = captchaProperties.getEnable();
// 验证码开关
// if (captchaEnabled) {
// validateCaptcha(username, code, uuid);
// }
AppUser appUser = loadUserByUsername(username);
appLoginService.checkLogin(LoginType.PASSWORD, username, () -> !BCrypt.checkpw(password, appUser.getPassword()));
// 创建App登录用户
AppLoginUser loginUser = buildAppLoginUser(appUser);
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
// 注册会话
try {
String ua = ServletUtils.getRequest().getHeader("User-Agent");
String deviceId = ServletUtils.getRequest().getHeader("X-Device-Id");
String ip = LoginHelper.getLoginUser().getIpaddr();
AppSession s = new AppSession();
s.setSessionId(StpUtil.getTokenValue());
s.setUserId(loginUser.getUserId());
s.setDeviceType(client.getDeviceType());
s.setDeviceId(deviceId != null ? deviceId : UUID.randomUUID().toString());
s.setUserAgent(ua);
s.setIp(ip);
s.setLocation("");
long now = Instant.now().toEpochMilli();
s.setLoginAt(now);
Long ttlSec = StpUtil.getTokenTimeout();
s.setExpireAt(now + (ttlSec != null ? ttlSec * 1000 : 24 * 3600 * 1000));
s.setActive(true);
s.setToken(StpUtil.getTokenValue());
appSessionManager.register(s, Duration.ofSeconds(ttlSec != null ? ttlSec : 86400));
} catch (Exception ignored) {
}
return loginVo;
}
/**
* 校验验证码
*
* @param username 用户名
* @param code 验证码
* @param uuid 唯一标识
*/
private void validateCaptcha(String username, String code, String uuid) {
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + StringUtils.blankToDefault(uuid, "");
String captcha = RedisUtils.getCacheObject(verifyKey);
RedisUtils.deleteObject(verifyKey);
if (captcha == null) {
appLoginService.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha)) {
appLoginService.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error"));
throw new CaptchaException();
}
}
/**
* 根据用户名加载App用户
*/
private AppUser loadUserByUsername(String username) {
AppUserVo user = appUserService.selectUserByUserName(username);
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", username);
throw new UserException("user.not.exists", username);
} else if ("1".equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", username);
throw new UserException("user.blocked", username);
}
return JsonUtils.parseObject(JsonUtils.toJsonString(user), AppUser.class);
}
/**
* 构建App登录用户
*/
private AppLoginUser buildAppLoginUser(AppUser appUser) {
AppLoginUser loginUser = new AppLoginUser();
loginUser.setUserId(appUser.getUserId());
loginUser.setUsername(appUser.getUserName());
loginUser.setUserType("app");
// 设置其他必要字段
return loginUser;
}
}
@@ -0,0 +1,114 @@
package org.dromara.app.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.member.domain.vo.AppUserVo;
import org.dromara.app.service.AppLoginService;
import org.dromara.member.service.AppUserService;
import org.dromara.app.service.IAppAuthStrategy;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.model.SmsLoginBody;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
import org.springframework.stereotype.Service;
/**
* App短信认证策略
*
* @author pengles
*/
@Slf4j
@Service("app_sms" + IAppAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class AppSmsAuthStrategy implements IAppAuthStrategy {
private final AppLoginService appLoginService;
private final AppUserService appUserService;
@Override
public LoginVo login(String body, SysClientVo client) {
SmsLoginBody loginBody = JsonUtils.parseObject(body, SmsLoginBody.class);
ValidatorUtils.validate(loginBody);
String phonenumber = loginBody.getPhonenumber();
String smsCode = loginBody.getSmsCode();
AppUserVo user = loadUserByPhonenumber(phonenumber);
appLoginService.checkLogin(LoginType.SMS, user.getUserName(), () -> !validateSmsCode(phonenumber, smsCode));
// 构建App登录用户
AppLoginUser loginUser = new AppLoginUser();
loginUser.setUserId(user.getUserId());
loginUser.setUsername(user.getUserName());
loginUser.setUserType("app");
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
return loginVo;
}
/**
* 校验短信验证码
*/
private boolean validateSmsCode(String phonenumber, String smsCode) {
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + phonenumber);
if (StringUtils.isBlank(code)) {
appLoginService.recordLogininfor(phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
return code.equals(smsCode);
}
/**
* 通过手机号加载用户信息
*/
private AppUserVo loadUserByPhonenumber(String phonenumber) {
AppUserVo appUser = appUserService.selectUserByPhone(phonenumber);
if (ObjectUtil.isNull(appUser)) {
log.info("登录用户:{} 不存在.", phonenumber);
throw new UserException("user.not.exists", phonenumber);
} else if ("1".equals(appUser.getStatus())) {
log.info("登录用户:{} 已被停用.", phonenumber);
throw new UserException("user.blocked", phonenumber);
}
// 将 AppUser 转换为 AppUserVo
AppUserVo user = new AppUserVo();
BeanUtil.copyProperties(appUser, user);
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", phonenumber);
throw new UserException("user.not.exists", phonenumber);
} else if ("1".equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", phonenumber);
throw new UserException("user.blocked", phonenumber);
}
return user;
}
}
@@ -0,0 +1,116 @@
package org.dromara.app.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.member.domain.AppSocialAuth;
import org.dromara.member.domain.vo.AppUserVo;
import org.dromara.app.service.AppLoginService;
import org.dromara.member.service.AppSocialAuthService;
import org.dromara.member.service.AppUserService;
import org.dromara.app.service.IAppAuthStrategy;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.social.config.properties.SocialProperties;
import org.dromara.common.social.utils.SocialUtils;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
import org.springframework.stereotype.Service;
/**
* App第三方登录认证策略
*
* @author pengles
*/
@Slf4j
@Service("app_social" + IAppAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class AppSocialAuthStrategy implements IAppAuthStrategy {
private final SocialProperties socialProperties;
private final AppUserService appUserService;
private final AppLoginService appLoginService;
private final AppSocialAuthService appSocialAuthService;
@Override
public LoginVo login(String body, SysClientVo client) {
SocialLoginBody loginBody = JsonUtils.parseObject(body, SocialLoginBody.class);
ValidatorUtils.validate(loginBody);
String source = loginBody.getSource();
String socialCode = loginBody.getSocialCode();
String socialState = loginBody.getSocialState();
// 获取第三方登录信息
AuthRequest authRequest = SocialUtils.getAuthRequest(source, socialProperties);
AuthCallback callback = AuthCallback.builder()
.code(socialCode)
.state(socialState)
.build();
AuthResponse<AuthUser> response = authRequest.login(callback);
if (!response.ok()) {
throw new ServiceException(response.getMsg());
}
AuthUser authUserData = response.getData();
AppUserVo user = loadUserBySocial(authUserData.getSource(), authUserData.getUuid());
assert user != null;
appLoginService.checkLogin(LoginType.PASSWORD, user.getUserName(), () -> ObjectUtil.isNull(user));
// 构建App登录用户
AppLoginUser loginUser = new AppLoginUser();
loginUser.setUserId(user.getUserId());
loginUser.setUsername(user.getUserName());
loginUser.setUserType("app");
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
return loginVo;
}
/**
* 通过第三方信息查找用户
*/
private AppUserVo loadUserBySocial(String source, String openId) {
// 根据第三方平台信息查找绑定的App用户
log.info("App端第三方登录查找用户,source: {}, openId: {}", source, openId);
// 首先通过openId查找
AppSocialAuth socialAuth = appSocialAuthService.queryByPlatformAndOpenId(source, openId);
if (socialAuth != null && socialAuth.getUserId() != null) {
// 找到绑定的用户,返回用户信息
AppUserVo user = appUserService.queryById(socialAuth.getUserId());
if (user != null) {
log.info("通过openId找到绑定用户,userId: {}, userName: {}", user.getUserId(), user.getUserName());
return user;
}
}
log.warn("未找到与第三方账号绑定的App用户,source: {}, openId: {}", source, openId);
return null;
}
}
@@ -0,0 +1,118 @@
package org.dromara.app.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthToken;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.member.domain.vo.AppUserVo;
import org.dromara.member.service.AppSocialAuthService;
import org.dromara.app.service.IAppAuthStrategy;
import org.dromara.common.core.domain.model.XcxLoginBody;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.social.config.properties.SocialProperties;
import org.dromara.common.social.utils.SocialUtils;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
import org.springframework.stereotype.Service;
/**
* App端微信小程序认证策略
* 支持微信小程序登录,通过微信小程序授权码获取用户信息并完成登录
*
* @author pengles
*/
@Slf4j
@Service("app_wechat_miniprogram" + IAppAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class AppWechatMiniprogramAuthStrategy implements IAppAuthStrategy {
private final SocialProperties socialProperties;
private final AppSocialAuthService appSocialAuthService;
@Override
public LoginVo login(String body, SysClientVo client) {
XcxLoginBody loginBody = JsonUtils.parseObject(body, XcxLoginBody.class);
ValidatorUtils.validate(loginBody);
// xcxCode 为小程序调用 wx.login 授权后获取
String xcxCode = loginBody.getCode();
// 多个小程序识别使用
String appid = loginBody.getAppid();
// 校验 appid + appsecret + xcxCode 调用登录凭证校验接口 获取 session_key 与 openid
// 使用SocialUtils获取微信小程序的AuthRequest
AuthRequest authRequest = SocialUtils.getAuthRequest("wechat_miniprogram", socialProperties);
if (authRequest == null) {
throw new UserException("未配置微信小程序登录");
}
AuthCallback authCallback = new AuthCallback();
authCallback.setCode(xcxCode);
AuthResponse<AuthUser> response = authRequest.login(authCallback);
String openid = null;
String unionId = null;
if (response.ok()) {
AuthToken token = response.getData().getToken();
openid = token.getOpenId();
// 微信小程序只有关联到微信开放平台下之后才能获取到 unionId,因此unionId不一定能返回。
unionId = token.getUnionId();
} else {
throw new UserException("第三方登录失败,原因:" + response.getMsg());
}
AppUserVo user = loadUserByWxId("wechat_miniprogram", openid, unionId);
if (ObjectUtil.isNull(user)) {
throw new UserException("登录失败,请先绑定用户账号");
}
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
AppLoginUser loginUser = new AppLoginUser();
loginUser.setUserId(user.getUserId());
loginUser.setUsername(user.getUserName());
loginUser.setNickname(user.getNickName());
loginUser.setUserType("app");
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
loginVo.setOpenid(openid);
return loginVo;
}
/**
* 通过微信标识加载用户信息,优先 unionId,回退 openId
*/
private AppUserVo loadUserByWxId(String platform, String openid, String unionId) {
if (ObjectUtil.isNotEmpty(unionId)) {
AppUserVo byUnion = appSocialAuthService.selectUserByPlatformAndUnionId(platform, unionId);
if (ObjectUtil.isNotNull(byUnion)) {
return byUnion;
}
}
return appSocialAuthService.selectUserByPlatformAndOpenId(platform, openid);
}
}
@@ -0,0 +1,130 @@
package org.dromara.app.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.member.domain.vo.AppUserVo;
import org.dromara.member.service.AppSocialAuthService;
import org.dromara.app.service.IAppAuthStrategy;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.social.config.properties.SocialProperties;
import org.dromara.common.social.utils.SocialUtils;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
import org.springframework.stereotype.Service;
/**
* App端微信公众号认证策略
* 处理微信公众号环境下的OAuth2登录逻辑
* 依赖 justauth.type.wechat_mp 配置项
*
* @author pengles
*/
@Slf4j
@Service("app_wechat_mp" + IAppAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class AppWechatMpAuthStrategy implements IAppAuthStrategy {
private final SocialProperties socialProperties;
private final AppSocialAuthService appSocialAuthService;
/**
* 微信公众号登录核心逻辑
*
* @param body 登录请求体,包含 socialCode 和 socialState
* @param client 客户端配置
* @return 登录结果 LoginVo
*/
@Override
public LoginVo login(String body, SysClientVo client) {
SocialLoginBody loginBody = JsonUtils.parseObject(body, SocialLoginBody.class);
ValidatorUtils.validate(loginBody);
String code = loginBody.getSocialCode();
String state = loginBody.getSocialState();
// 使用SocialUtils获取微信公众号的AuthRequest
// 对应 application.yml 中 justauth.type.wechat_mp 的配置
AuthRequest authRequest = SocialUtils.getAuthRequest("wechat_mp", socialProperties);
if (authRequest == null) {
throw new UserException("未配置微信公众号登录");
}
// 构造回调参数
AuthCallback authCallback = new AuthCallback();
authCallback.setCode(code);
authCallback.setState(state);
// 执行第三方登录请求,换取 Access Token 和 User Info
AuthResponse<AuthUser> response = authRequest.login(authCallback);
String openid;
String unionId = null;
if (response.ok()) {
AuthUser authUser = response.getData();
openid = authUser.getUuid();
if (authUser.getToken() != null) {
unionId = authUser.getToken().getUnionId();
}
} else {
throw new UserException("第三方登录失败,原因:" + response.getMsg());
}
// 根据 OpenId 或 UnionId 查找系统用户
AppUserVo user = loadUserByWxId("wechat_mp", openid, unionId);
if (ObjectUtil.isNull(user)) {
throw new UserException("登录失败,请先绑定用户账号");
}
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
AppLoginUser loginUser = new AppLoginUser();
loginUser.setUserId(user.getUserId());
loginUser.setUsername(user.getUserName());
loginUser.setNickname(user.getNickName());
loginUser.setUserType("app");
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
loginVo.setOpenid(openid);
return loginVo;
}
/**
* 通过微信标识加载用户信息,优先使用 unionId(跨应用统一标识),缺失时回退使用 openId
* @param platform 平台标识(wechat_mp / wechat_open / wechat_miniprogram
* @param openid 微信 openId
* @param unionId 微信 unionId(可能为空)
*/
private AppUserVo loadUserByWxId(String platform, String openid, String unionId) {
if (ObjectUtil.isNotEmpty(unionId)) {
AppUserVo byUnion = appSocialAuthService.selectUserByPlatformAndUnionId(platform, unionId);
if (ObjectUtil.isNotNull(byUnion)) {
return byUnion;
}
}
return appSocialAuthService.selectUserByPlatformAndOpenId(platform, openid);
}
}
@@ -0,0 +1,128 @@
package org.dromara.app.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.member.domain.vo.AppUserVo;
import org.dromara.member.service.AppSocialAuthService;
import org.dromara.app.service.IAppAuthStrategy;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.social.config.properties.SocialProperties;
import org.dromara.common.social.utils.SocialUtils;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
import org.springframework.stereotype.Service;
/**
* App端微信开放平台认证策略
* 处理微信开放平台(APP端/网站应用)环境下的OAuth2登录逻辑
* 依赖 justauth.type.wechat_open 配置项
* 通常用于APP原生微信登录
*
* @author pengles
*/
@Slf4j
@Service("app_wechat_open" + IAppAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class AppWechatOpenAuthStrategy implements IAppAuthStrategy {
private final SocialProperties socialProperties;
private final AppSocialAuthService appSocialAuthService;
/**
* 微信开放平台登录核心逻辑
*
* @param body 登录请求体,包含 socialCode 和 socialState
* @param client 客户端配置
* @return 登录结果 LoginVo
*/
@Override
public LoginVo login(String body, SysClientVo client) {
SocialLoginBody loginBody = JsonUtils.parseObject(body, SocialLoginBody.class);
ValidatorUtils.validate(loginBody);
String code = loginBody.getSocialCode();
String state = loginBody.getSocialState();
// 使用SocialUtils获取微信开放平台的AuthRequest
// 对应 application.yml 中 justauth.type.wechat_open 的配置
AuthRequest authRequest = SocialUtils.getAuthRequest("wechat_open", socialProperties);
if (authRequest == null) {
throw new UserException("未配置微信开放平台登录");
}
// 构造回调参数
AuthCallback authCallback = new AuthCallback();
authCallback.setCode(code);
authCallback.setState(state);
// 执行第三方登录请求,换取 Access Token 和 User Info
AuthResponse<AuthUser> response = authRequest.login(authCallback);
String openid;
String unionId = null;
if (response.ok()) {
AuthUser authUser = response.getData();
openid = authUser.getUuid();
if (authUser.getToken() != null) {
unionId = authUser.getToken().getUnionId();
}
} else {
throw new UserException("第三方登录失败,原因:" + response.getMsg());
}
// 根据 OpenId 或 UnionId 查找系统用户
AppUserVo user = loadUserByWxId("wechat_open", openid, unionId);
if (ObjectUtil.isNull(user)) {
throw new UserException("登录失败,请先绑定用户账号");
}
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
AppLoginUser loginUser = new AppLoginUser();
loginUser.setUserId(user.getUserId());
loginUser.setUsername(user.getUserName());
loginUser.setNickname(user.getNickName());
loginUser.setUserType("app");
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
loginVo.setOpenid(openid);
return loginVo;
}
/**
* 通过微信标识加载用户信息,优先 unionId,回退 openId
*/
private AppUserVo loadUserByWxId(String platform, String openid, String unionId) {
if (ObjectUtil.isNotEmpty(unionId)) {
AppUserVo byUnion = appSocialAuthService.selectUserByPlatformAndUnionId(platform, unionId);
if (ObjectUtil.isNotNull(byUnion)) {
return byUnion;
}
}
return appSocialAuthService.selectUserByPlatformAndOpenId(platform, openid);
}
}
@@ -0,0 +1,19 @@
package org.dromara.app.session;
import lombok.Data;
@Data
public class AppSession {
private String sessionId;
private Long userId;
private String deviceType;
private String deviceId;
private String userAgent;
private String ip;
private String location;
private long loginAt;
private long expireAt;
private boolean active;
private String token;
}
@@ -0,0 +1,80 @@
package org.dromara.app.session;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@Slf4j
@Component
@RequiredArgsConstructor
public class AppSessionManager {
private static final String KEY_INDEX_FMT = "APP:SESS_INDEX:%s";
private static final String KEY_SESS_FMT = "APP:SESS:%s:%s";
private static final String CH_EVT_FMT = "APP:SESS_EVT:%s";
public void register(AppSession s, Duration ttl) {
String indexKey = indexKey(s.getUserId());
String sessKey = sessKey(s.getUserId(), s.getSessionId());
RedisUtils.addCacheSet(indexKey, sessKey);
RedisUtils.setCacheObject(sessKey, JsonUtils.toJsonString(s), ttl);
publishEvent(s.getUserId(), "REGISTER", s.getSessionId());
}
public List<AppSession> list(Long userId) {
String indexKey = indexKey(userId);
Set<String> keys = RedisUtils.getCacheSet(indexKey);
List<AppSession> out = new ArrayList<>();
if (keys != null) {
for (String k : keys) {
String val = RedisUtils.getCacheObject(k);
if (val != null) {
out.add(JsonUtils.parseObject(val, AppSession.class));
}
}
}
return out;
}
public boolean terminate(Long userId, String sessionId) {
String indexKey = indexKey(userId);
String sessKey = sessKey(userId, sessionId);
RedisUtils.deleteObject(sessKey);
try {
org.redisson.api.RSet<String> set = org.dromara.common.redis.utils.RedisUtils.getClient().getSet(indexKey);
set.remove(sessKey);
} catch (Exception ignored) {
}
publishEvent(userId, "TERMINATE", sessionId);
return true;
}
public boolean heartbeat(Long userId, String sessionId, Duration ttlExtend) {
String sessKey = sessKey(userId, sessionId);
String val = RedisUtils.getCacheObject(sessKey);
if (val == null) return false;
AppSession s = JsonUtils.parseObject(val, AppSession.class);
RedisUtils.setCacheObject(sessKey, JsonUtils.toJsonString(s), ttlExtend);
publishEvent(userId, "HEARTBEAT", sessionId);
return true;
}
public void publishEvent(Long userId, String type, String sessionId) {
String ch = String.format(CH_EVT_FMT, userId);
String payload = type + ":" + sessionId;
RedisUtils.publish(ch, payload);
}
private String indexKey(Long userId) {
return String.format(KEY_INDEX_FMT, userId);
}
private String sessKey(Long userId, String sessionId) {
return String.format(KEY_SESS_FMT, userId, sessionId);
}
}
@@ -0,0 +1,73 @@
package org.dromara.web.config;
import cn.dev33.satoken.exception.NotLoginException;
import org.dromara.app.domain.AppLoginUser;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.util.AntPathMatcher;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
/**
* App用户信息补全过滤器
*
* @author Pair-Programmer
*/
public class AppInfoCompletionFilter implements Filter {
@Value("#{'${app.auth.whitelist:/app/user/profile,/app/user/bind,/app/auth/logout}'.split(',')}")
private List<String> whitelist;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String uri = httpRequest.getRequestURI();
// 仅处理App端请求
if (StringUtils.startsWith(uri, "/app")) {
try {
// 尝试获取登录用户,若未登录会抛出异常或返回null
LoginUser loginUser = LoginHelper.getLoginUser();
// 仅针对App登录用户进行检查
if (loginUser instanceof AppLoginUser appUser) {
// 如果信息不完善 (infoComplete 为 false 或 null)
if (Boolean.FALSE.equals(appUser.getInfoComplete())) {
// 检查白名单
boolean isAllowed = false;
if (whitelist != null) {
for (String pattern : whitelist) {
if (pathMatcher.match(pattern.trim(), uri)) {
isAllowed = true;
break;
}
}
}
if (!isAllowed) {
ServletUtils.renderString((HttpServletResponse) response,
JsonUtils.toJsonString(R.fail(403, "用户信息未补全,请先完善信息")));
return;
}
}
}
} catch (Exception ignored) {
// 未登录或其他异常(如Token无效),放行交由后续认证过滤器或拦截器处理
}
}
chain.doFilter(request, response);
}
}
@@ -0,0 +1,52 @@
package org.dromara.web.config;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.ObjectUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.dromara.common.core.domain.R;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 风险控制拦截器
*
* @author Pair-Programmer
*/
public class RiskInterceptor implements HandlerInterceptor {
public static final String RISK_LOCKED_KEY = "RISK_LOCKED";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 仅处理登录用户
if (!StpUtil.isLogin()) {
return true;
}
// 检查 Session 是否有风险锁
Object isLocked = StpUtil.getSession().get(RISK_LOCKED_KEY);
if (ObjectUtil.isNotNull(isLocked) && (Boolean) isLocked) {
String uri = request.getRequestURI();
// 白名单:改密接口、登出接口、获取个人信息(用于页面展示)
// 注意:具体路径需根据实际 Controller 调整
if (StringUtils.equalsAnyIgnoreCase(uri,
"/system/user/profile/updatePwd",
"/auth/logout",
"/system/user/profile"
)) {
return true;
}
// 拦截并返回 403
ServletUtils.renderString(response,
JsonUtils.toJsonString(R.fail(403, "检测到异常登录,请强制修改密码后继续操作")));
return false;
}
return true;
}
}
@@ -0,0 +1,41 @@
package org.dromara.web.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 扩展配置
*
* @author Pair-Programmer
*/
@Configuration
public class WebMvcPlusConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册风险控制拦截器
registry.addInterceptor(new RiskInterceptor())
.addPathPatterns("/system/**", "/monitor/**", "/tool/**");
}
@Bean
public AppInfoCompletionFilter appInfoCompletionFilter() {
return new AppInfoCompletionFilter();
}
@Bean
public FilterRegistrationBean<AppInfoCompletionFilter> appInfoCompletionFilterRegistration(AppInfoCompletionFilter filter) {
FilterRegistrationBean<AppInfoCompletionFilter> registration = new FilterRegistrationBean<>(filter);
registration.addUrlPatterns("/app/*");
registration.setName("appInfoCompletionFilter");
// 顺序需在 Sa-Token 认证过滤器之后,但在业务逻辑之前
// Sa-Token 拦截器在 Interceptor 层,Filter 在 Servlet 层。
// 但 LoginHelper 需要 TokenToken 在 Header 里,随时可取。
// 关键是 Filter 需要在请求处理前执行。
registration.setOrder(100);
return registration;
}
}
@@ -0,0 +1,41 @@
package org.dromara.web.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 风险控制配置
*
* @author Pair-Programmer
*/
@Data
@Component
@ConfigurationProperties(prefix = "sys.risk")
public class RiskControlProperties {
/**
* 是否开启风险检测
*/
private Boolean enabled = true;
/**
* 风险阈值 (0-100)
*/
private Integer threshold = 60;
/**
* 异地登录权重
*/
private Integer geoWeight = 50;
/**
* 新设备权重
*/
private Integer deviceWeight = 30;
/**
* 非工作时间权重 (00:00-06:00)
*/
private Integer timeWeight = 10;
}
@@ -0,0 +1,208 @@
package org.dromara.web.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginBody;
import org.dromara.common.core.domain.model.RegisterBody;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.encrypt.annotation.ApiEncrypt;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.social.config.properties.SocialLoginConfigProperties;
import org.dromara.common.social.config.properties.SocialProperties;
import org.dromara.common.social.utils.SocialUtils;
import org.dromara.common.sse.dto.SseMessageDto;
import org.dromara.common.sse.utils.SseMessageUtils;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.service.SysClientService;
import org.dromara.system.service.SysConfigService;
import org.dromara.system.service.SysSocialService;
import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService;
import org.dromara.web.service.SysRegisterService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 认证
*
* @author pengles
*/
@Slf4j
@SaIgnore
@RequiredArgsConstructor
@RestController
@RequestMapping("/auth")
public class AuthController {
private final SocialProperties socialProperties;
private final SysLoginService loginService;
private final SysRegisterService registerService;
private final SysConfigService configService;
private final SysSocialService socialUserService;
private final SysClientService clientService;
private final ScheduledExecutorService scheduledExecutorService;
/**
* 登录方法
*
* @param body 登录信息
* @return 结果
*/
@ApiEncrypt
@PostMapping("/login")
public R<LoginVo> login(@RequestBody String body) {
LoginBody loginBody = JsonUtils.parseObject(body, LoginBody.class);
ValidatorUtils.validate(loginBody);
// 授权类型和客户端id
String clientId = loginBody.getClientId();
String grantType = loginBody.getGrantType();
SysClientVo client = clientService.queryByClientId(clientId);
// 查询不到 client 或 client 内不包含 grantType
if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), grantType)) {
log.info("客户端id: {} 认证类型:{} 异常!.", clientId, grantType);
return R.fail(MessageUtils.message("auth.grant.type.error"));
} else if (!SystemConstants.NORMAL.equals(client.getStatus())) {
return R.fail(MessageUtils.message("auth.grant.type.blocked"));
}
// 登录
LoginVo loginVo = IAuthStrategy.login(body, client, grantType);
Long userId = LoginHelper.getUserId();
scheduledExecutorService.schedule(() -> {
SseMessageDto dto = new SseMessageDto();
dto.setMessage("欢迎登录RuoYi-Vue-Plus后台管理系统");
dto.setUserIds(List.of(userId));
SseMessageUtils.publishMessage(dto);
}, 5, TimeUnit.SECONDS);
return R.ok(loginVo);
}
/**
* 获取跳转URL
*
* @param source 登录来源
* @return 结果
*/
@GetMapping("/binding/{source}")
public R<String> authBinding(@PathVariable("source") String source, @RequestParam String domain) {
SocialLoginConfigProperties obj = socialProperties.getType().get(source);
if (ObjectUtil.isNull(obj)) {
return R.fail(source + "平台账号暂不支持");
}
AuthRequest authRequest = SocialUtils.getAuthRequest(source, socialProperties);
Map<String, String> map = new HashMap<>();
map.put("domain", domain);
map.put("state", AuthStateUtils.createState());
String authorizeUrl = authRequest.authorize(Base64.encode(JsonUtils.toJsonString(map), StandardCharsets.UTF_8));
return R.ok("操作成功", authorizeUrl);
}
/**
* 前端回调绑定授权(需要token)
*
* @param loginBody 请求体
* @return 结果
*/
@PostMapping("/social/callback")
public R<Void> socialCallback(@RequestBody SocialLoginBody loginBody) {
// 校验token
StpUtil.checkLogin();
// 获取第三方登录信息
AuthResponse<AuthUser> response = SocialUtils.loginAuth(
loginBody.getSource(), loginBody.getSocialCode(),
loginBody.getSocialState(), socialProperties);
AuthUser authUserData = response.getData();
// 判断授权响应是否成功
if (!response.ok()) {
return R.fail(response.getMsg());
}
loginService.socialRegister(authUserData);
return R.ok();
}
/**
* 取消授权(需要token)
*
* @param socialId socialId
*/
@DeleteMapping(value = "/unlock/{socialId}")
public R<Void> unlockSocial(@PathVariable Long socialId) {
// 校验token
StpUtil.checkLogin();
Boolean rows = socialUserService.deleteWithValidById(socialId);
return rows ? R.ok() : R.fail("取消授权失败");
}
/**
* 退出登录
*/
@PostMapping("/logout")
public R<Void> logout() {
loginService.logout();
return R.ok("退出成功");
}
/**
* 用户注册
*/
@ApiEncrypt
@PostMapping("/register")
public R<Void> register(@Validated @RequestBody RegisterBody user) {
if (!configService.selectRegisterEnabled()) {
return R.fail("当前系统没有开启注册功能!");
}
registerService.register(user);
return R.ok();
}
/**
* 登录页面租户下拉框(兼容多租户前端)
*/
@GetMapping("/tenant/list")
public R<TenantListVo> tenantList() {
return R.ok(TenantListVo.DISABLED_TENANT);
}
/**
* 租户下拉列表
*
* @param tenantEnabled 租户开关
*/
public record TenantListVo(boolean tenantEnabled) {
/**
* 禁用租户,租户开关响应false(即关闭),让前端关闭租户功能
*/
public static final TenantListVo DISABLED_TENANT = TenantListVo.of(false);
public static TenantListVo of(boolean tenantEnabled) {
return new TenantListVo(tenantEnabled);
}
}
}
@@ -0,0 +1,154 @@
package org.dromara.web.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.captcha.AbstractCaptcha;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.reflect.ReflectUtils;
import org.dromara.common.mail.config.properties.MailProperties;
import org.dromara.common.mail.utils.MailUtils;
import org.dromara.common.ratelimiter.annotation.RateLimiter;
import org.dromara.common.ratelimiter.enums.LimitType;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.common.web.enums.CaptchaType;
import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.dromara.web.domain.vo.CaptchaVo;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.LinkedHashMap;
/**
* 验证码操作处理
*
* @author pengles
*/
@SaIgnore
@Slf4j
@Validated
@RequiredArgsConstructor
@RestController
public class CaptchaController {
private final CaptchaProperties captchaProperties;
private final MailProperties mailProperties;
/**
* 短信验证码
*
* @param phonenumber 用户手机号
*/
@RateLimiter(key = "#phonenumber", time = 60, count = 1)
@GetMapping("/resource/sms/code")
public R<Void> smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber;
String code = RandomUtil.randomNumbers(4);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
// 验证码模板id 自行处理 (查数据库或写死均可)
String templateId = "";
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
SmsResponse smsResponse = smsBlend.sendMessage(phonenumber, templateId, map);
if (!smsResponse.isSuccess()) {
log.error("验证码短信发送异常 => {}", smsResponse);
return R.fail(smsResponse.getData().toString());
}
return R.ok();
}
/**
* 邮箱验证码
*
* @param email 邮箱
*/
@GetMapping("/resource/email/code")
public R<Void> emailCode(@NotBlank(message = "{user.email.not.blank}") String email) {
if (!mailProperties.getEnabled()) {
return R.fail("当前系统没有开启邮箱功能!");
}
SpringUtils.getAopProxy(this).emailCodeImpl(email);
return R.ok();
}
/**
* 邮箱验证码
* 独立方法避免验证码关闭之后仍然走限流
*/
@RateLimiter(key = "#email", time = 60, count = 1)
public void emailCodeImpl(String email) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + email;
String code = RandomUtil.randomNumbers(4);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
try {
MailUtils.sendText(email, "登录验证码", "您本次验证码为:" + code + ",有效性为" + Constants.CAPTCHA_EXPIRATION + "分钟,请尽快填写。");
} catch (Exception e) {
log.error("验证码短信发送异常 => {}", e.getMessage());
throw new ServiceException(e.getMessage());
}
}
/**
* 生成验证码
*/
@GetMapping("/auth/code")
public R<CaptchaVo> getCode() {
boolean captchaEnabled = captchaProperties.getEnable();
if (!captchaEnabled) {
CaptchaVo captchaVo = new CaptchaVo();
captchaVo.setCaptchaEnabled(false);
return R.ok(captchaVo);
}
return R.ok(SpringUtils.getAopProxy(this).getCodeImpl());
}
/**
* 生成验证码
* 独立方法避免验证码关闭之后仍然走限流
*/
@RateLimiter(time = 60, count = 10, limitType = LimitType.IP)
public CaptchaVo getCodeImpl() {
// 保存验证码信息
String uuid = IdUtil.simpleUUID();
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + uuid;
// 生成验证码
CaptchaType captchaType = captchaProperties.getType();
boolean isMath = CaptchaType.MATH == captchaType;
Integer length = isMath ? captchaProperties.getNumberLength() : captchaProperties.getCharLength();
CodeGenerator codeGenerator = ReflectUtils.newInstance(captchaType.getClazz(), length);
AbstractCaptcha captcha = SpringUtils.getBean(captchaProperties.getCategory().getClazz());
captcha.setGenerator(codeGenerator);
captcha.createCode();
// 如果是数学验证码,使用SpEL表达式处理验证码结果
String code = captcha.getCode();
if (isMath) {
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(StringUtils.remove(code, "="));
code = exp.getValue(String.class);
}
RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
CaptchaVo captchaVo = new CaptchaVo();
captchaVo.setUuid(uuid);
captchaVo.setImg(captcha.getImageBase64());
return captchaVo;
}
}
@@ -0,0 +1,28 @@
package org.dromara.web.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 首页
*
* @author pengles
*/
@SaIgnore
@RequiredArgsConstructor
@RestController
public class IndexController {
/**
* 访问首页,提示语
*/
@GetMapping("/")
public String index() {
return StringUtils.format("欢迎使用{}后台管理框架,请通过前端地址访问。", SpringUtils.getApplicationName());
}
}
@@ -0,0 +1,28 @@
package org.dromara.web.domain.vo;
import lombok.Data;
/**
* 验证码信息
*
* @author pengles
*/
@Data
public class CaptchaVo {
/**
* 是否开启验证码
*/
private Boolean captchaEnabled = true;
/**
* 验证码唯一标识
*/
private String uuid;
/**
* 验证码图片
*/
private String img;
}
@@ -0,0 +1,54 @@
package org.dromara.web.domain.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 登录验证信息
*
* @author pengles
*/
@Data
public class LoginVo {
/**
* 授权令牌
*/
@JsonProperty("access_token")
private String accessToken;
/**
* 刷新令牌
*/
@JsonProperty("refresh_token")
private String refreshToken;
/**
* 授权令牌 access_token 的有效期
*/
@JsonProperty("expire_in")
private Long expireIn;
/**
* 刷新令牌 refresh_token 的有效期
*/
@JsonProperty("refresh_expire_in")
private Long refreshExpireIn;
/**
* 应用id
*/
@JsonProperty("client_id")
private String clientId;
/**
* 令牌权限
*/
private String scope;
/**
* 用户 openid
*/
private String openid;
}
@@ -0,0 +1,173 @@
package org.dromara.web.listener;
import cn.dev33.satoken.listener.SaTokenListener;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.CacheConstants;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.domain.dto.UserOnlineDTO;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ip.AddressUtils;
import org.dromara.common.log.event.LogininforEvent;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.web.config.properties.RiskControlProperties;
import org.dromara.web.service.RiskControlService;
import org.dromara.web.service.SysLoginService;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 用户行为 侦听器的实现
*
* @author pengles
*/
@RequiredArgsConstructor
@Component
@Slf4j
public class UserActionListener implements SaTokenListener {
private final SysLoginService loginService;
private final RiskControlService riskControlService;
private final RiskControlProperties riskProperties;
/**
* 每次登录时触发
*/
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter) {
UserAgent userAgent = UserAgentUtil.parse(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = ServletUtils.getClientIP();
UserOnlineDTO dto = new UserOnlineDTO();
dto.setIpaddr(ip);
dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
dto.setBrowser(userAgent.getBrowser().getName());
dto.setOs(userAgent.getOs().getName());
dto.setLoginTime(System.currentTimeMillis());
dto.setTokenId(tokenValue);
String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY);
dto.setUserName(username);
dto.setClientKey((String) loginParameter.getExtra(LoginHelper.CLIENT_KEY));
dto.setDeviceType(loginParameter.getDeviceType());
dto.setDeptName((String) loginParameter.getExtra(LoginHelper.DEPT_NAME_KEY));
if (loginParameter.getTimeout() == -1) {
RedisUtils.setCacheObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue, dto);
} else {
RedisUtils.setCacheObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue, dto, Duration.ofSeconds(loginParameter.getTimeout()));
}
// 风险检测 (仅针对 sys 用户)
// 假设 "login" 是 sys 用户的 loginType (SaToken 默认配置)
// if ("login".equals(loginType)) {
// int score = riskControlService.assessRisk(username, ip, ServletUtils.getRequest().getHeader("User-Agent"));
// if (score > riskProperties.getThreshold()) {
// log.warn("用户 {} 登录风险过高 (score={}),已锁定会话", username, score);
// StpUtil.getSession().set(RiskInterceptor.RISK_LOCKED_KEY, true);
// // 可以在此触发邮件/短信告警
// }
// }
// 记录登录日志
LogininforEvent logininforEvent = new LogininforEvent();
logininforEvent.setUsername(username);
logininforEvent.setStatus(Constants.LOGIN_SUCCESS);
logininforEvent.setMessage(MessageUtils.message("user.login.success"));
logininforEvent.setRequest(ServletUtils.getRequest());
SpringUtils.context().publishEvent(logininforEvent);
// 更新登录信息
loginService.recordLoginInfo((Long) loginParameter.getExtra(LoginHelper.USER_KEY), ip);
log.info("user doLogin, userId:{}, token:{}", loginId, tokenValue);
}
/**
* 每次注销时触发
*/
@Override
public void doLogout(String loginType, Object loginId, String tokenValue) {
RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue);
log.info("user doLogout, userId:{}, token:{}", loginId, tokenValue);
}
/**
* 每次被踢下线时触发
*/
@Override
public void doKickout(String loginType, Object loginId, String tokenValue) {
RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue);
log.info("user doKickout, userId:{}, token:{}", loginId, tokenValue);
}
/**
* 每次被顶下线时触发
*/
@Override
public void doReplaced(String loginType, Object loginId, String tokenValue) {
RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue);
log.info("user doReplaced, userId:{}, token:{}", loginId, tokenValue);
}
/**
* 每次被封禁时触发
*/
@Override
public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) {
log.info("用户被禁用: loginType={}, loginId={}, service={}, level={}, disableTime={}",
loginType, loginId, service, level, disableTime);
// 删除在线用户信息
String tokenValue = StpUtil.getTokenValueByLoginId(loginId);
if (StringUtils.isNotBlank(tokenValue)) {
RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue);
}
}
/**
* 每次被解封时触发
*/
@Override
public void doUntieDisable(String loginType, Object loginId, String service) {
}
/**
* 每次打开二级认证时触发
*/
@Override
public void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) {
}
/**
* 每次创建Session时触发
*/
@Override
public void doCloseSafe(String loginType, String tokenValue, String service) {
}
/**
* 每次创建Session时触发
*/
@Override
public void doCreateSession(String id) {
}
/**
* 每次注销Session时触发
*/
@Override
public void doLogoutSession(String id) {
}
/**
* 每次Token续期时触发
*/
@Override
public void doRenewTimeout(String loginType, Object loginId, String tokenValue, long timeout) {
}
}
@@ -0,0 +1,45 @@
package org.dromara.web.service;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.web.domain.vo.LoginVo;
/**
* 授权策略
*
* @author pengles
*/
public interface IAuthStrategy {
String BASE_NAME = "AuthStrategy";
/**
* 登录
*
* @param body 登录对象
* @param client 授权管理视图对象
* @param grantType 授权类型
* @return 登录验证信息
*/
static LoginVo login(String body, SysClientVo client, String grantType) {
// 授权类型和客户端id
String beanName = grantType + BASE_NAME;
if (!SpringUtils.containsBean(beanName)) {
throw new ServiceException("授权类型不正确!");
}
IAuthStrategy instance = SpringUtils.getBean(beanName);
return instance.login(body, client);
}
/**
* 登录
*
* @param body 登录对象
* @param client 授权管理视图对象
* @return 登录验证信息
*/
LoginVo login(String body, SysClientVo client);
}
@@ -0,0 +1,137 @@
package org.dromara.web.service;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ip.AddressUtils;
import org.dromara.system.domain.bo.SysLogininforBo;
import org.dromara.system.domain.vo.SysLogininforVo;
import org.dromara.system.service.SysLogininforService;
import org.dromara.web.config.properties.RiskControlProperties;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 风险控制服务
*
* @author Pair-Programmer
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RiskControlService {
private final SysLogininforService logininforService;
private final RiskControlProperties properties;
/**
* 评估登录风险
*
* @param username 用户名
* @param currentIp 当前IP
* @param uaStr User-Agent字符串
* @return 风险分数 (0-100)
*/
public int assessRisk(String username, String currentIp, String uaStr) {
if (!properties.getEnabled()) {
return 0;
}
int score = 0;
String currentAddress = AddressUtils.getRealAddressByIP(currentIp);
UserAgent currentUserAgent = UserAgentUtil.parse(uaStr);
// 1. 获取最近成功的登录记录 (取最近10条)
SysLogininforBo query = new SysLogininforBo();
query.setUserName(username);
query.setStatus(Constants.SUCCESS);
// 这里假设 selectLogininforList 是按时间倒序的,且我们可以通过PageHelper或流截取
// 由于是 Service 调用,我们先获取列表然后手动截取,或者依赖 Mapper 的分页(这里简化处理)
List<SysLogininforVo> historyList = logininforService.selectLogininforList(query);
// 如果是首次登录或无历史记录,视为低风险
if (CollUtil.isEmpty(historyList)) {
return 0;
}
// 截取最近 10 条作为基线
List<SysLogininforVo> baseline = historyList.stream().limit(10).collect(Collectors.toList());
// 2. 检查异地登录 (省份是否变化)
boolean isGeoAnomaly = true;
Set<String> validLocations = baseline.stream()
.map(SysLogininforVo::getLoginLocation)
.filter(StrUtil::isNotBlank)
.map(this::extractProvince)
.collect(Collectors.toSet());
String currentProvince = extractProvince(currentAddress);
if (validLocations.contains(currentProvince)) {
isGeoAnomaly = false;
} else {
// 特殊处理:如果历史记录都是“未知”或“内网IP”,且当前是公网IP,可能误报,暂且认为是异地
log.info("风险检测-异地登录: 用户={}, 历史={}, 当前={}", username, validLocations, currentProvince);
}
if (isGeoAnomaly) {
score += properties.getGeoWeight();
}
// 3. 检查新设备 (Browser + OS)
boolean isNewDevice = true;
Set<String> knownDevices = baseline.stream()
.map(log -> log.getBrowser() + " on " + log.getOs())
.collect(Collectors.toSet());
String currentDevice = currentUserAgent.getBrowser().getName() + " on " + currentUserAgent.getOs().getName();
if (knownDevices.contains(currentDevice)) {
isNewDevice = false;
} else {
log.info("风险检测-新设备: 用户={}, 历史={}, 当前={}", username, knownDevices, currentDevice);
}
if (isNewDevice) {
score += properties.getDeviceWeight();
}
// 4. 检查非工作时间 (00:00 - 06:00)
int hour = DateUtil.hour(new Date(), true);
if (hour >= 0 && hour < 6) {
score += properties.getTimeWeight();
}
log.info("风险检测完成: 用户={}, 总分={}, 详情(Geo={}, Device={}, Time={})",
username, score, isGeoAnomaly, isNewDevice, (hour >= 0 && hour < 6));
return score;
}
/**
* 提取省份 (简单截取)
*/
private String extractProvince(String location) {
if (StrUtil.isBlank(location)) {
return "Unknown";
}
// 假设 location 格式为 "XX省 XX市" 或 "XX省"
// 简单处理:取前2-3个字符,或者根据空格分割
if (location.contains("")) {
return location.substring(0, location.indexOf("") + 1);
}
// 直辖市
if (location.startsWith("北京") || location.startsWith("上海") || location.startsWith("天津") || location.startsWith("重庆")) {
return location.substring(0, 2);
}
return location; // 无法识别则返回原值
}
}
@@ -0,0 +1,205 @@
package org.dromara.web.service;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Opt;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.lock.annotation.Lock4j;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthUser;
import org.dromara.common.core.constant.CacheConstants;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.domain.dto.PostDTO;
import org.dromara.common.core.domain.dto.RoleDTO;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.log.event.LogininforEvent;
import org.dromara.common.mybatis.helper.DataPermissionHelper;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.bo.SysSocialBo;
import org.dromara.system.domain.vo.*;
import org.dromara.system.service.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.function.Supplier;
/**
* 登录校验方法
*
* @author pengles
*/
@RequiredArgsConstructor
@Slf4j
@Service
public class SysLoginService {
@Value("${user.password.maxRetryCount}")
private Integer maxRetryCount;
@Value("${user.password.lockTime}")
private Integer lockTime;
private final SysPermissionService permissionService;
private final SysSocialService sysSocialService;
private final SysRoleService roleService;
private final SysDeptService deptService;
private final SysPostService postService;
private final SysUserService userService;
/**
* 绑定第三方用户
*
* @param authUserData 授权响应实体
*/
@Lock4j
public void socialRegister(AuthUser authUserData) {
String authId = authUserData.getSource() + authUserData.getUuid();
// 第三方用户信息
SysSocialBo bo = BeanUtil.toBean(authUserData, SysSocialBo.class);
BeanUtil.copyProperties(authUserData.getToken(), bo);
Long userId = LoginHelper.getUserId();
bo.setUserId(userId);
bo.setAuthId(authId);
bo.setOpenId(authUserData.getUuid());
bo.setUserName(authUserData.getUsername());
bo.setNickName(authUserData.getNickname());
List<SysSocialVo> checkList = sysSocialService.selectByAuthId(authId);
if (CollUtil.isNotEmpty(checkList)) {
throw new ServiceException("此三方账号已经被绑定!");
}
// 查询是否已经绑定用户
SysSocialBo params = new SysSocialBo();
params.setUserId(userId);
params.setSource(bo.getSource());
List<SysSocialVo> list = sysSocialService.queryList(params);
if (CollUtil.isEmpty(list)) {
// 没有绑定用户, 新增用户信息
sysSocialService.insertByBo(bo);
} else {
// 更新用户信息
bo.setId(list.get(0).getId());
sysSocialService.updateByBo(bo);
// 如果要绑定的平台账号已经被绑定过了 是否抛异常自行决断
// throw new ServiceException("此平台账号已经被绑定!");
}
}
/**
* 退出登录
*/
public void logout() {
try {
LoginUser loginUser = LoginHelper.getLoginUser();
if (ObjectUtil.isNull(loginUser)) {
return;
}
recordLogininfor(loginUser.getUsername(), Constants.LOGOUT, MessageUtils.message("user.logout.success"));
} catch (NotLoginException ignored) {
} finally {
try {
StpUtil.logout();
} catch (NotLoginException ignored) {
}
}
}
/**
* 记录登录信息
*
* @param username 用户名
* @param status 状态
* @param message 消息内容
*/
public void recordLogininfor(String username, String status, String message) {
LogininforEvent logininforEvent = new LogininforEvent();
logininforEvent.setUsername(username);
logininforEvent.setStatus(status);
logininforEvent.setMessage(message);
logininforEvent.setRequest(ServletUtils.getRequest());
SpringUtils.context().publishEvent(logininforEvent);
}
/**
* 构建登录用户
*/
public LoginUser buildLoginUser(SysUserVo user) {
LoginUser loginUser = new LoginUser();
Long userId = user.getUserId();
loginUser.setUserId(userId);
loginUser.setDeptId(user.getDeptId());
loginUser.setUsername(user.getUserName());
loginUser.setNickname(user.getNickName());
loginUser.setUserType(user.getUserType());
loginUser.setMenuPermission(permissionService.getMenuPermission(userId));
loginUser.setRolePermission(permissionService.getRolePermission(userId));
if (ObjectUtil.isNotNull(user.getDeptId())) {
Opt<SysDeptVo> deptOpt = Opt.of(user.getDeptId()).map(deptService::selectDeptById);
loginUser.setDeptName(deptOpt.map(SysDeptVo::getDeptName).orElse(StringUtils.EMPTY));
loginUser.setDeptCategory(deptOpt.map(SysDeptVo::getDeptCategory).orElse(StringUtils.EMPTY));
}
List<SysRoleVo> roles = roleService.selectRolesByUserId(userId);
List<SysPostVo> posts = postService.selectPostsByUserId(userId);
loginUser.setRoles(BeanUtil.copyToList(roles, RoleDTO.class));
loginUser.setPosts(BeanUtil.copyToList(posts, PostDTO.class));
return loginUser;
}
/**
* 记录登录信息
*
* @param userId 用户ID
*/
public void recordLoginInfo(Long userId, String ip) {
DataPermissionHelper.ignore(() -> userService.updateLoginInfo(userId, ip));
}
/**
* 登录校验
*/
public void checkLogin(LoginType loginType, String username, Supplier<Boolean> supplier) {
String errorKey = CacheConstants.PWD_ERR_CNT_KEY + username;
String loginFail = Constants.LOGIN_FAIL;
// 获取用户登录错误次数,默认为0 (可自定义限制策略 例如: key + username + ip)
int errorNumber = ObjectUtil.defaultIfNull(RedisUtils.getCacheObject(errorKey), 0);
// 锁定时间内登录 则踢出
if (errorNumber >= maxRetryCount) {
recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime));
throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime);
}
if (supplier.get()) {
// 错误次数递增
errorNumber++;
RedisUtils.setCacheObject(errorKey, errorNumber, Duration.ofMinutes(lockTime));
// 达到规定错误次数 则锁定登录
if (errorNumber >= maxRetryCount) {
recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime));
throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime);
} else {
// 未达到规定错误次数
recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitCount(), errorNumber));
throw new UserException(loginType.getRetryLimitCount(), errorNumber);
}
}
// 登录成功 清空错误次数
RedisUtils.deleteObject(errorKey);
}
}
@@ -0,0 +1,108 @@
package org.dromara.web.service;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.model.RegisterBody;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.user.CaptchaException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.log.event.LogininforEvent;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.system.domain.SysUser;
import org.dromara.system.domain.bo.SysUserBo;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.system.service.SysUserService;
import org.springframework.stereotype.Service;
/**
* 注册校验方法
*
* @author pengles
*/
@RequiredArgsConstructor
@Service
public class SysRegisterService {
private final SysUserService userService;
private final SysUserMapper userMapper;
private final CaptchaProperties captchaProperties;
/**
* 注册
*/
public void register(RegisterBody registerBody) {
String username = registerBody.getUsername();
String password = registerBody.getPassword();
// 校验用户类型是否存在
String userType = UserType.getUserType(registerBody.getUserType()).getUserType();
boolean captchaEnabled = captchaProperties.getEnable();
// 验证码开关
if (captchaEnabled) {
validateCaptcha(username, registerBody.getCode(), registerBody.getUuid());
}
SysUserBo sysUser = new SysUserBo();
sysUser.setUserName(username);
sysUser.setNickName(username);
sysUser.setPassword(BCrypt.hashpw(password));
sysUser.setUserType(userType);
boolean exist = !userService.checkUserNameUnique(sysUser);
if (exist) {
throw new UserException("user.register.save.error", username);
}
boolean regFlag = userService.registerUser(sysUser);
if (!regFlag) {
throw new UserException("user.register.error");
}
recordLogininfor(username, Constants.REGISTER, MessageUtils.message("user.register.success"));
}
/**
* 校验验证码
*
* @param username 用户名
* @param code 验证码
* @param uuid 唯一标识
*/
public void validateCaptcha(String username, String code, String uuid) {
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + StringUtils.blankToDefault(uuid, "");
String captcha = RedisUtils.getCacheObject(verifyKey);
RedisUtils.deleteObject(verifyKey);
if (captcha == null) {
recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha)) {
recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error"));
throw new CaptchaException();
}
}
/**
* 记录登录信息
*
* @param username 用户名
* @param status 状态
* @param message 消息内容
* @return
*/
private void recordLogininfor(String username, String status, String message) {
LogininforEvent logininforEvent = new LogininforEvent();
logininforEvent.setUsername(username);
logininforEvent.setStatus(status);
logininforEvent.setMessage(message);
logininforEvent.setRequest(ServletUtils.getRequest());
SpringUtils.context().publishEvent(logininforEvent);
}
}
@@ -0,0 +1,99 @@
package org.dromara.web.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.model.EmailLoginBody;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.service.SysUserService;
import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService;
import org.springframework.stereotype.Service;
/**
* 邮件认证策略
*
* @author pengles
*/
@Slf4j
@Service("email" + IAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class EmailAuthStrategy implements IAuthStrategy {
private final SysLoginService loginService;
private final SysUserService userService;
@Override
public LoginVo login(String body, SysClientVo client) {
EmailLoginBody loginBody = JsonUtils.parseObject(body, EmailLoginBody.class);
ValidatorUtils.validate(loginBody);
String email = loginBody.getEmail();
String emailCode = loginBody.getEmailCode();
SysUserVo user = loadUserByEmail(email);
loginService.checkLogin(LoginType.EMAIL, user.getUserName(), () -> !validateEmailCode(email, emailCode));
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
LoginUser loginUser = loginService.buildLoginUser(user);
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
return loginVo;
}
/**
* 校验邮箱验证码
*/
private boolean validateEmailCode(String email, String emailCode) {
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + email);
if (StringUtils.isBlank(code)) {
loginService.recordLogininfor(email, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
return code.equals(emailCode);
}
private SysUserVo loadUserByEmail(String email) {
SysUserVo user = userService.selectUserByEmail(email);
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", email);
throw new UserException("user.not.exists", email);
} else if (SystemConstants.DISABLE.equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", email);
throw new UserException("user.blocked", email);
}
return user;
}
}
@@ -0,0 +1,120 @@
package org.dromara.web.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.BCrypt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.domain.model.PasswordLoginBody;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.user.CaptchaException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.service.SysUserService;
import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService;
import org.springframework.stereotype.Service;
/**
* 密码认证策略
*
* @author pengles
*/
@Slf4j
@Service("password" + IAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class PasswordAuthStrategy implements IAuthStrategy {
private final CaptchaProperties captchaProperties;
private final SysLoginService loginService;
private final SysUserService userService;
@Override
public LoginVo login(String body, SysClientVo client) {
PasswordLoginBody loginBody = JsonUtils.parseObject(body, PasswordLoginBody.class);
ValidatorUtils.validate(loginBody);
String username = loginBody.getUsername();
String password = loginBody.getPassword();
String code = loginBody.getCode();
String uuid = loginBody.getUuid();
boolean captchaEnabled = captchaProperties.getEnable();
// 验证码开关
if (captchaEnabled) {
validateCaptcha(username, code, uuid);
}
SysUserVo user = loadUserByUsername(username);
loginService.checkLogin(LoginType.PASSWORD, username, () -> !BCrypt.checkpw(password, user.getPassword()));
// 此处可根据登录用户的数据不同 自行创建 loginUser
LoginUser loginUser = loginService.buildLoginUser(user);
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
return loginVo;
}
/**
* 校验验证码
*
* @param username 用户名
* @param code 验证码
* @param uuid 唯一标识
*/
private void validateCaptcha(String username, String code, String uuid) {
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + StringUtils.blankToDefault(uuid, "");
String captcha = RedisUtils.getCacheObject(verifyKey);
RedisUtils.deleteObject(verifyKey);
if (captcha == null) {
loginService.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha)) {
loginService.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error"));
throw new CaptchaException();
}
}
private SysUserVo loadUserByUsername(String username) {
SysUserVo user = userService.selectUserByUserName(username);
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", username);
throw new UserException("user.not.exists", username);
} else if (SystemConstants.DISABLE.equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", username);
throw new UserException("user.blocked", username);
}
return user;
}
}
@@ -0,0 +1,99 @@
package org.dromara.web.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.domain.model.SmsLoginBody;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.service.SysUserService;
import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 短信认证策略
*
* @author pengles
*/
@Slf4j
@Service("sms" + IAuthStrategy.BASE_NAME)
public class SmsAuthStrategy implements IAuthStrategy {
@Autowired
private SysLoginService loginService;
@Autowired
private SysUserService userService;
@Override
public LoginVo login(String body, SysClientVo client) {
SmsLoginBody loginBody = JsonUtils.parseObject(body, SmsLoginBody.class);
ValidatorUtils.validate(loginBody);
String phonenumber = loginBody.getPhonenumber();
String smsCode = loginBody.getSmsCode();
SysUserVo user = loadUserByPhonenumber(phonenumber);
loginService.checkLogin(LoginType.SMS, user.getUserName(), () -> !validateSmsCode(phonenumber, smsCode));
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
LoginUser loginUser = loginService.buildLoginUser(user);
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
return loginVo;
}
/**
* 校验短信验证码
*/
private boolean validateSmsCode(String phonenumber, String smsCode) {
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + phonenumber);
if (StringUtils.isBlank(code)) {
loginService.recordLogininfor(phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
return code.equals(smsCode);
}
private SysUserVo loadUserByPhonenumber(String phonenumber) {
SysUserVo user = userService.selectUserByPhonenumber(phonenumber);
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", phonenumber);
throw new UserException("user.not.exists", phonenumber);
} else if (SystemConstants.DISABLE.equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", phonenumber);
throw new UserException("user.blocked", phonenumber);
}
return user;
}
}
@@ -0,0 +1,107 @@
package org.dromara.web.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.social.config.properties.SocialProperties;
import org.dromara.common.social.utils.SocialUtils;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.domain.vo.SysSocialVo;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.service.SysUserService;
import org.dromara.system.service.SysSocialService;
import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 第三方授权策略
*
* @author pengles
*/
@Slf4j
@Service("social" + IAuthStrategy.BASE_NAME)
@RequiredArgsConstructor
public class SocialAuthStrategy implements IAuthStrategy {
private final SocialProperties socialProperties;
private final SysSocialService sysSocialService;
private final SysUserService userService;
private final SysLoginService loginService;
/**
* 登录-第三方授权登录
*
* @param body 登录信息
* @param client 客户端信息
*/
@Override
public LoginVo login(String body, SysClientVo client) {
SocialLoginBody loginBody = JsonUtils.parseObject(body, SocialLoginBody.class);
ValidatorUtils.validate(loginBody);
AuthResponse<AuthUser> response = SocialUtils.loginAuth(
loginBody.getSource(), loginBody.getSocialCode(),
loginBody.getSocialState(), socialProperties);
if (!response.ok()) {
throw new ServiceException(response.getMsg());
}
AuthUser authUserData = response.getData();
List<SysSocialVo> list = sysSocialService.selectByAuthId(authUserData.getSource() + authUserData.getUuid());
if (CollUtil.isEmpty(list)) {
throw new ServiceException("你还没有绑定第三方账号,绑定后才可以登录!");
}
SysSocialVo social = list.get(0);
SysUserVo user = loadUser(social.getUserId());
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
LoginUser loginUser = loginService.buildLoginUser(user);
loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType());
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
// 例如: 后台用户30分钟过期 app用户1天过期
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
// 生成token
LoginHelper.login(loginUser, model);
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
return loginVo;
}
private SysUserVo loadUser(Long userId) {
SysUserVo user = userService.selectUserById(userId);
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", "");
throw new UserException("user.not.exists", "");
} else if (SystemConstants.DISABLE.equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", "");
throw new UserException("user.blocked", "");
}
return user;
}
}
Binary file not shown.
@@ -0,0 +1,274 @@
# AI模块配置
ruoyi:
ai:
# 是否启用AI模块
enabled: true
# 默认配置
default:
# 默认超时时间(秒)
timeout: 30
# 默认最大重试次数
max-retries: 3
# 默认最大并发数
max-concurrency: 10
# 默认权重
weight: 1
# 默认优先级
priority: 0
# 缓存配置
cache:
# 是否启用缓存
enabled: true
# 缓存过期时间(分钟)
expire-minutes: 60
# 缓存键前缀
key-prefix: "ai:chat:cache:"
# 健康检查配置
health:
# 健康检查间隔(秒)
check-interval: 60
# 健康检查超时时间(秒)
check-timeout: 10
# 缓存键前缀
cache-prefix: "ai:health:"
# 流式响应配置
stream:
# 是否启用流式响应
enabled: true
# 流式响应缓冲区大小
buffer-size: 1024
# 流式响应超时时间(秒)
timeout: 60
# 负载均衡配置
load-balance:
# 负载均衡策略(ROUND_ROBIN, WEIGHTED_ROUND_ROBIN, RANDOM, LEAST_CONNECTIONS)
strategy: WEIGHTED_ROUND_ROBIN
# 是否启用健康检查
health-check: true
# 熔断器配置
circuit-breaker:
# 是否启用熔断器
enabled: true
# 失败阈值
failure-threshold: 5
# 恢复时间(秒)
recovery-timeout: 30
# 半开状态最大请求数
half-open-max-calls: 3
# 限流配置
rate-limit:
# 是否启用限流
enabled: true
# 每秒最大请求数
max-requests-per-second: 100
# 突发请求数
burst-capacity: 200
# 监控配置
monitoring:
# 是否启用监控
enabled: true
# 统计数据保留天数
stats-retention-days: 30
# 是否记录详细日志
detailed-logging: false
# 故障转移配置
failover:
enabled: true
check-interval-seconds: 30
failure-threshold: 3
recovery-threshold: 2
# 知识库配置
knowledge:
enabled: true
default-search-count: 5
similarity-threshold: 0.7
max-context-length: 4000
reranker-enabled: false
embedding:
base-url:
default-model: bge-large-zh
vector-store:
type: db
qdrant:
base-url: http://localhost:6333
api-key:
collection: knowledge_vectors
distance: Cosine
weaviate:
base-url: http://localhost:8080
api-key:
class-name: KnowledgeVector
chunk:
size: 1000
overlap: 100
# 外部AI配置
external-ai:
# OpenAI配置
openai:
enabled: false
api-key: ${OPENAI_API_KEY:}
base-url: https://api.openai.com
default-model: gpt-3.5-turbo
timeout: 60s
# Azure配置
azure:
enabled: false
api-key: ${AZURE_API_KEY:}
endpoint: ${AZURE_ENDPOINT:}
deployment-name: ${AZURE_DEPLOYMENT_NAME:}
api-version: 2023-12-01-preview
# ChatGLM配置
chatglm:
enabled: false
api-key: ${CHATGLM_API_KEY:}
base-url: https://open.bigmodel.cn
default-model: glm-4
# 通义千问配置
qwen:
enabled: false
api-key: ${QWEN_API_KEY:}
base-url: https://dashscope.aliyuncs.com
default-model: qwen-turbo
# 智谱AI配置
zhipu:
enabled: false
api-key: ${ZHIPU_API_KEY:}
base-url: https://open.bigmodel.cn
default-model: glm-4
# AI平台配置
ai-platform:
# FastGPT配置
fastgpt:
enabled: false
base-url: ${FASTGPT_BASE_URL:}
api-key: ${FASTGPT_API_KEY:}
default-app-id: ${FASTGPT_DEFAULT_APP_ID:}
# Coze配置
coze:
enabled: false
base-url: https://www.coze.com
api-key: ${COZE_API_KEY:}
default-bot-id: ${COZE_DEFAULT_BOT_ID:}
# DIFY配置
dify:
enabled: false
base-url: ${DIFY_BASE_URL:}
api-key: ${DIFY_API_KEY:}
default-app-id: ${DIFY_DEFAULT_APP_ID:}
# Provider特定配置
providers:
# OpenAI配置
openai:
# API基础URL
base-url: "https://api.openai.com/v1"
# 默认模型
default-model: "gpt-3.5-turbo"
# 支持的模型列表
supported-models:
- "gpt-3.5-turbo"
- "gpt-4"
- "gpt-4-turbo"
# Azure OpenAI配置
azure-openai:
# API版本
api-version: "2023-12-01-preview"
# 默认模型
default-model: "gpt-35-turbo"
# 支持的模型列表
supported-models:
- "gpt-35-turbo"
- "gpt-4"
# ChatGLM配置
chatglm:
# API基础URL
base-url: "https://open.bigmodel.cn/api/paas/v4"
# 默认模型
default-model: "glm-4"
# 支持的模型列表
supported-models:
- "glm-4"
- "glm-3-turbo"
# 通义千问配置
qwen:
# API基础URL
base-url: "https://dashscope.aliyuncs.com/api/v1"
# 默认模型
default-model: "qwen-turbo"
# 支持的模型列表
supported-models:
- "qwen-turbo"
- "qwen-plus"
- "qwen-max"
# 智谱AI配置
zhipu:
# API基础URL
base-url: "https://open.bigmodel.cn/api/paas/v4"
# 默认模型
default-model: "glm-4"
# 支持的模型列表
supported-models:
- "glm-4"
- "glm-3-turbo"
# FastGPT配置
fastgpt:
# 默认模型
default-model: "fastgpt"
# 支持的模型列表
supported-models:
- "fastgpt"
# Coze配置
coze:
# API基础URL
base-url: "https://www.coze.cn/api"
# 默认模型
default-model: "coze"
# 支持的模型列表
supported-models:
- "coze"
# Dify配置
dify:
# 默认模型
default-model: "dify"
# 支持的模型列表
supported-models:
- "dify"
# 知识库配置
knowledge:
# 是否启用
enabled: true
# 默认模型
default-model: "knowledge"
# 支持的模型列表
supported-models:
- "knowledge"
# 相似度阈值
similarity-threshold: 0.7
# 最大返回结果数
max-results: 10
@@ -0,0 +1,337 @@
--- # 监控中心配置
spring.boot.admin.client:
# 增加客户端开关
enabled: false
url: http://localhost:9090/admin
instance:
service-host-type: IP
metadata:
username: ${spring.boot.admin.client.username}
userpassword: ${spring.boot.admin.client.password}
username: @monitor.username@
password: @monitor.password@
--- # snail-job 配置
snail-job:
enabled: false
# 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务
group: "ruoyi_group"
# SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config` 表
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server:
host: 127.0.0.1
port: 17888
# 命名空间UUID 详见 script/sql/ry_job.sql `sj_namespace`表`unique_id`字段
namespace: ${spring.profiles.active}
# 随主应用端口漂移
port: 2${server.port}
# 客户端ip指定
host:
# RPC类型: netty, grpc
rpc-type: grpc
--- # 数据源配置
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
# 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content
dynamic:
# 性能分析插件(有性能损耗 不建议生产环境使用)
p6spy: true
# 设置默认的数据源或者数据源组,默认值即为 master
primary: master
# 严格模式 匹配不到数据源则报错
strict: true
datasource:
# 主库数据源
master:
type: ${spring.datasource.type}
driverClassName: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/postgres?useUnicode=true&characterEncoding=utf8&useSSL=true&autoReconnect=true&reWriteBatchedInserts=true
username: postgres
password: 123456
# # 从库数据源
# slave:
# lazy: true
# type: ${spring.datasource.type}
# driverClassName: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
# username:
# password:
# oracle:
# type: ${spring.datasource.type}
# driverClassName: oracle.jdbc.OracleDriver
# url: jdbc:oracle:thin:@//localhost:1521/XE
# username: ROOT
# password: root
# postgres:
# type: ${spring.datasource.type}
# driverClassName: org.postgresql.Driver
# url: jdbc:postgresql://localhost:5432/postgres?useUnicode=true&characterEncoding=utf8&useSSL=true&autoReconnect=true&reWriteBatchedInserts=true
# username: root
# password: root
# sqlserver:
# type: ${spring.datasource.type}
# driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
# url: jdbc:sqlserver://localhost:1433;DatabaseName=tempdb;SelectMethod=cursor;encrypt=false;rewriteBatchedStatements=true
# username: SA
# password: root
hikari:
# 最大连接池数量
maxPoolSize: 20
# 最小空闲线程数量
minIdle: 10
# 配置获取连接等待超时的时间
connectionTimeout: 30000
# 校验超时时间
validationTimeout: 5000
# 空闲连接存活最大时间,默认10分钟
idleTimeout: 600000
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认30分钟
maxLifetime: 1800000
# 多久检查一次连接的活性
keepaliveTime: 30000
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
redis:
# 地址
host: localhost
# 端口,默认为6379
port: 6379
# 数据库索引
database: 0
# redis 密码必须配置
password: 123456
# 连接超时时间
timeout: 10s
# 是否开启ssl连接
ssl.enabled: false
# redisson 配置
redisson:
# redis key前缀
keyPrefix:
# 线程池数量
threads: 4
# Netty线程池数量
nettyThreads: 8
# 单节点配置
singleServerConfig:
# 客户端名称 不能用中文
clientName: RuoYi-Vue-Plus
# 最小空闲连接数
connectionMinimumIdleSize: 8
# 连接池大小
connectionPoolSize: 32
# 连接空闲超时,单位:毫秒
idleConnectionTimeout: 10000
# 命令等待超时,单位:毫秒
timeout: 3000
# 发布和订阅连接池大小
subscriptionConnectionPoolSize: 50
--- # mail 邮件发送
mail:
enabled: false
host: smtp.163.com
port: 465
# 是否需要用户名密码验证
auth: true
# 发送方,遵循RFC-822标准
from: xxx@163.com
# 用户名(注意:如果使用foxmail邮箱,此处user为qq号)
user: xxx@163.com
# 密码(注意,某些邮箱需要为SMTP服务单独设置密码,详情查看相关帮助)
pass: xxxxxxxxxx
# 使用 STARTTLS安全连接,STARTTLS是对纯文本通信协议的扩展。
starttlsEnable: true
# 使用SSL安全连接
sslEnable: true
# SMTP超时时长,单位毫秒,缺省值不超时
timeout: 0
# Socket连接超时值,单位毫秒,缺省值不超时
connectionTimeout: 0
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
sms:
# 配置源类型用于标定配置来源(interface,yaml)
config-type: yaml
# 用于标定yml中的配置是否开启短信拦截,接口配置不受此限制
restricted: true
# 短信拦截限制单手机号每分钟最大发送,只对开启了拦截的配置有效
minute-max: 1
# 短信拦截限制单手机号每日最大发送量,只对开启了拦截的配置有效
account-max: 30
# 以下配置来自于 org.dromara.sms4j.provider.config.BaseConfig类中
blends:
# 唯一ID 用于发送短信寻找具体配置 随便定义别用中文即可
# 可以同时存在两个相同厂商 例如: ali1 ali2 两个不同的阿里短信账号
config1:
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: alibaba
# 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
access-key-id: 您的accessKey
# 称为accessSecret有些称之为apiSecret
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
config2:
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: tencent
access-key-id: 您的accessKey
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
--- # 三方授权
justauth:
# 前端外网访问地址
address: http://localhost:80
type:
maxkey:
# maxkey 服务器地址
# 注意 如下均配置均不需要修改 maxkey 已经内置好了数据
server-url: http://sso.maxkey.top
client-id: 876892492581044224
client-secret: x1Y5MTMwNzIwMjMxNTM4NDc3Mzche8
redirect-uri: ${justauth.address}/social-callback?source=maxkey
topiam:
# topiam 服务器地址
server-url: http://127.0.0.1:1898/api/v1/authorize/y0q************spq***********8ol
client-id: 449c4*********937************759
client-secret: ac7***********1e0************28d
redirect-uri: ${justauth.address}/social-callback?source=topiam
scopes: [ openid, email, phone, profile ]
qq:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=qq
union-id: false
weibo:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=weibo
gitee:
client-id: 9ceb2ba3150369c0278f69f7eab35ba41dd011a568368c65bb132125a4d95821
client-secret: 9801fe69875da3b56f0773ae6737d416b6888f3f4af04d6bca5b63055728859c
redirect-uri: ${justauth.address}/social-callback?source=gitee
dingtalk:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=dingtalk
baidu:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=baidu
csdn:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=csdn
coding:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=coding
coding-group-name: xx
oschina:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=oschina
alipay_wallet:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=alipay_wallet
alipay-public-key: MIIB**************DAQAB
wechat_open:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_open
wechat_mp:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_mp
wechat_miniprogram:
client-id: wx329e1cc66e69f88a
client-secret: 181c3b076969a5cb935fb88f5fe6347b
redirect-uri: ${justauth.address}/social-callback?source=wechat_miniprogram
wechat_enterprise:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_enterprise
agent-id: 1000002
gitlab:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=gitlab
gitea:
# 前端改动 https://gitee.com/JavaLionLi/plus-ui/pulls/204
# gitea 服务器地址
server-url: https://demo.gitea.com
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=gitea
--- # 微信平台
wx:
miniapp:
configs:
# 测试号
- appid: wxb1e1ffda7cb636e9
secret: 80bae6f0fd391a95a89f5ad867bd6991
token: mvlcmI66IVHDLZP8ZFdCKy6okr2thHgr
aesKey: #微信小程序消息服务器配置的EncodingAESKey
msgDataFormat: JSON
# 测试号
- appid: wxb1e1ffda7cb636e1
secret: 083ae010dae63a0e2efa851245581802
token: mvlcmI66IVHDLZP8ZFdCKy6okr2thHgr
aesKey: Jv70essZrhV4cguBvUh4nwu3bjVTQcRJeAUzqBBbdqz
msgDataFormat: JSON
mp:
useRedis: false
redisConfig:
host:
port:
database:
password:
timeout:
configs:
# 测试号
- appId: wxb1e1ffda7cb636e9
secret: aab69b83d10226bd8608ec4cf6c6ec0c
token: mvlcmI66IVHDLZP8ZFdCKy6okr2thHgr
aesKey:
oauth2RedirectUrl: http://192.168.0.3:8080/pages/home/main
qrConnectRedirectUrl: http://192.168.0.3:8080/pages/home/main
# 测试号
- appId: wxb1e1ffda7cb636e1
secret: aab69b83d10226bd8608ec4cf6c6ec0c
token: mvlcmI66IVHDLZP8ZFdCKy6okr2thHgr
aesKey: WCnqUoRueykM2E3oDsFAacXBUjioC10EfxwO2TMhYhT
oauth2RedirectUrl: http://192.168.0.22:9090/#/pages/index
qrConnectRedirectUrl: http://192.168.0.22:8080/#/pages/index
pay:
alipay:
appId: 2021003164606727
mchId: 2088541440500474
serverUrl: https://openapi.alipay.com/gateway.do
alipayPublicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmDCp3S+3WiWZsZPrTZU40wgnb2LlGPxlUnPrlGGZipKtSx+qNrquh0gfWFFXx4PxArkJN2bh39eeUHlNb+WUTQJLq8eMa7jLw8WRpfi5e0O0zzwv8XGGqc3Rt97RAQqOTXTPUJ0hVMtr6DLX/0aE9p3kTh4+BK9pGo/o9HK/NDG5zKP6DDuAfAOpY48ktFmhZOHd799Ki8p7w2CRLxAmuicC8RaQsR7jv/AJLEIwF7WIAmJmviDDhjuHOyUaqoHEC14EHmVw68IBK5xcFct7QvRjJoi703zc7yXlzClRdkjw06PC8SQ267TRg0z1jYRPyDBzDRhjvv1rZPijtwC7JQIDAQAB
privateKey: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCYMKndL7daJZmxk+tNlTjTCCdvYuUY/GVSc+uUYZmKkq1LH6o2uq6HSB9YUVfHg/ECuQk3ZuHf155QeU1v5ZRNAkurx4xruMvDxZGl+Ll7Q7TPPC/xcYapzdG33tEBCo5NdM9QnSFUy2voMtf/RoT2neROHj4Er2kaj+j0cr80MbnMo/oMO4B8A6ljjyS0WaFk4d3v30qLynvDYJEvECa6JwLxFpCxHuO/8AksQjAXtYgCYma+IMOGO4c7JRqqgcQLXgQeZXDrwgErnFwVy3tC9GMmiLvTfNzvJeXMKVF2SPDTo8LxJDbrtNGDTPWNhE/IMHMNGGO+/Wtk+KO3ALslAgMBAAECggEAUBx9rgLqHzffuYxFtqcwLFYEfCuwsQBbTZXbklX1/u3K7tqSPRDzwh6C8XiQHmQjv+0rHtC3YZ8cpPvVeWt+LzNgJeJ61lGGYV1kl9tft7UiPlxOWGgMHOJM3N8bYdLuqXMtlh/AZeRWvvnUnXm/kBn5De35c3nqd6L9W3/zikM5y9MuMQR23iSaNtoeBVGLkX/i9otf12+UrWv/loLJpqLCw8C5MT+zbXZgFZG/zBHjk6oUoiYB9NLUj0C+V9w6/ROGXoBAfBYEbxVgiQKIlSySdozYOlzt3Mp0FSS/RevjNoVHj4eDiOQZDxGkpzEg8LRSwhUn254/echQSyuimQKBgQDafIXftqpt9ts27S/q4GSHMB1qN9QiKJ6iZAAel98bEe42Zvum0nEUxxGQ2tADBtz+lnHBi4VvSrid9O1fsmih0FtXMZcG6hMBY49G1fXpbhGI6kSZBxKPeDgiLb0rgmvAdCBtgUx6ML50lW0dVwEXbobQ2LKVqOE/LNlnPU5QnwKBgQCyUh0q8Lqk4cXDg/qnYHbtv43CBOend/frBsSxrYKkf6CiV2kbKJFeBSjBOD8YtGad1Pm2EhloAotJUAh2nqybV4CQkBZUEYQDtM2b5yJF8xCn5lbf+S7rmij1zP0dyOtLdKiOMwtRdBFJJthw1gt6eFhpD+c5CvpqSTD00yHJuwKBgQCb98iZqwx+83oJ+8f5I7afyvk0miYVPGoCAuES9deOu34R1/JNZGzVKEah4ZIclwmrtDoAsFjQ2cZw/Cd36SRIXzTVSdFGXlKy6x+csaCawrhBxPqzQxk80dVAkOY56SCCgmOjyGmP0Lwk/YanKzTcRUp4TDkwHR6uupV33YvKgQKBgDqNKdSirLZdB8G2AUSaMRLJtfNCBwp/IuGCHG226lG3Mnh7uSBYxrqXeRVQsa8b9SHX/5JgCQWU6EVPSSgh2806AxX0qdA63B0XbffGAgPz1sE6qcXrHRPxT4e+IlJ4WYIyMPJYIlxBfzeE0MbkEWrKP0VoGmUpjKX/mFqbRNnnAoGAf3YEakCzCyd6PylB+gWsajoQfiZOaOkwReX8/5P7atVmpNSNPOaehvtQE8Bd8lu/9Bh3LYfpb2+giVVsdujqPDShez+q6hSMyrW1TSw8we7iY1J0J0r9RwxAdokdLH2J8JX6LuWP7NY9VHDNXbERcDVjat15gukJmuK85KqMFSc=
notifyUrl: http://bdhdm3.natappfree.cc/no-auth/ali/notify/trade
returnUrl: xxxxpay/notify
signType: RSA2
charset: UTF-8
wechat:
appMp1Id: wxb7250f9a052e9015
appMp2Id: wx11fde1504adee855
mchId: 1635571318
mchSerialNo: 3177DDC954F864ADE851086DB3E24A7AB932A711
privateKey: -----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDG0SuHJE8dXLNk
-----END PRIVATE KEY-----
apiV3Key: geneface27bdaf31d54447d8b7b7c7d7
notifyUrl: https://ppm-stage.geneface.com/gf-api
@@ -0,0 +1,276 @@
--- # 临时文件存储位置 避免临时文件被系统清理报错
spring.servlet.multipart.location: /ruoyi/server/temp
--- # 监控中心配置
spring.boot.admin.client:
# 增加客户端开关
enabled: true
url: http://localhost:9090/admin
instance:
service-host-type: IP
metadata:
username: ${spring.boot.admin.client.username}
userpassword: ${spring.boot.admin.client.password}
username: @monitor.username@
password: @monitor.password@
--- # snail-job 配置
snail-job:
enabled: true
# 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务
group: "ruoyi_group"
# SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config`表
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server:
host: 127.0.0.1
port: 17888
# 命名空间UUID 详见 script/sql/ry_job.sql `sj_namespace`表`unique_id`字段
namespace: ${spring.profiles.active}
# 随主应用端口漂移
port: 2${server.port}
# 客户端ip指定
host:
# RPC类型: netty, grpc
rpc-type: grpc
--- # 数据源配置
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
# 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content
dynamic:
# 性能分析插件(有性能损耗 不建议生产环境使用)
p6spy: false
# 设置默认的数据源或者数据源组,默认值即为 master
primary: master
# 严格模式 匹配不到数据源则报错
strict: true
datasource:
# 主库数据源
master:
type: ${spring.datasource.type}
driverClassName: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/postgres?useUnicode=true&characterEncoding=utf8&useSSL=true&autoReconnect=true&reWriteBatchedInserts=true
username: postgres
password: 123456
# # 从库数据源
# slave:
# lazy: true
# type: ${spring.datasource.type}
# driverClassName: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
# username:
# password:
# oracle:
# type: ${spring.datasource.type}
# driverClassName: oracle.jdbc.OracleDriver
# url: jdbc:oracle:thin:@//localhost:1521/XE
# username: ROOT
# password: root
# postgres:
# type: ${spring.datasource.type}
# driverClassName: org.postgresql.Driver
# url: jdbc:postgresql://localhost:5432/postgres?useUnicode=true&characterEncoding=utf8&useSSL=true&autoReconnect=true&reWriteBatchedInserts=true
# username: root
# password: root
# sqlserver:
# type: ${spring.datasource.type}
# driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
# url: jdbc:sqlserver://localhost:1433;DatabaseName=tempdb;SelectMethod=cursor;encrypt=false;rewriteBatchedStatements=true
# username: SA
# password: root
hikari:
# 最大连接池数量
maxPoolSize: 20
# 最小空闲线程数量
minIdle: 10
# 配置获取连接等待超时的时间
connectionTimeout: 30000
# 校验超时时间
validationTimeout: 5000
# 空闲连接存活最大时间,默认10分钟
idleTimeout: 600000
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认30分钟
maxLifetime: 1800000
# 多久检查一次连接的活性
keepaliveTime: 30000
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
redis:
# 地址
host: localhost
# 端口,默认为6379
port: 6379
# 数据库索引
database: 0
# redis 密码必须配置
password: ruoyi123
# 连接超时时间
timeout: 10s
# 是否开启ssl
ssl.enabled: false
# redisson 配置
redisson:
# redis key前缀
keyPrefix:
# 线程池数量
threads: 16
# Netty线程池数量
nettyThreads: 32
# 单节点配置
singleServerConfig:
# 客户端名称 不能用中文
clientName: RuoYi-Vue-Plus
# 最小空闲连接数
connectionMinimumIdleSize: 32
# 连接池大小
connectionPoolSize: 64
# 连接空闲超时,单位:毫秒
idleConnectionTimeout: 10000
# 命令等待超时,单位:毫秒
timeout: 3000
# 发布和订阅连接池大小
subscriptionConnectionPoolSize: 50
--- # mail 邮件发送
mail:
enabled: false
host: smtp.163.com
port: 465
# 是否需要用户名密码验证
auth: true
# 发送方,遵循RFC-822标准
from: xxx@163.com
# 用户名(注意:如果使用foxmail邮箱,此处user为qq号)
user: xxx@163.com
# 密码(注意,某些邮箱需要为SMTP服务单独设置密码,详情查看相关帮助)
pass: xxxxxxxxxx
# 使用 STARTTLS安全连接,STARTTLS是对纯文本通信协议的扩展。
starttlsEnable: true
# 使用SSL安全连接
sslEnable: true
# SMTP超时时长,单位毫秒,缺省值不超时
timeout: 0
# Socket连接超时值,单位毫秒,缺省值不超时
connectionTimeout: 0
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
sms:
# 配置源类型用于标定配置来源(interface,yaml)
config-type: yaml
# 用于标定yml中的配置是否开启短信拦截,接口配置不受此限制
restricted: true
# 短信拦截限制单手机号每分钟最大发送,只对开启了拦截的配置有效
minute-max: 1
# 短信拦截限制单手机号每日最大发送量,只对开启了拦截的配置有效
account-max: 30
# 以下配置来自于 org.dromara.sms4j.provider.config.BaseConfig类中
blends:
# 唯一ID 用于发送短信寻找具体配置 随便定义别用中文即可
# 可以同时存在两个相同厂商 例如: ali1 ali2 两个不同的阿里短信账号
config1:
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: alibaba
# 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
access-key-id: 您的accessKey
# 称为accessSecret有些称之为apiSecret
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
config2:
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: tencent
access-key-id: 您的accessKey
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
# 三方授权
justauth:
# 前端外网访问地址
address: http://localhost:80
type:
maxkey:
# maxkey 服务器地址
# 注意 如下均配置均不需要修改 maxkey 已经内置好了数据
server-url: http://sso.maxkey.top
client-id: 876892492581044224
client-secret: x1Y5MTMwNzIwMjMxNTM4NDc3Mzche8
redirect-uri: ${justauth.address}/social-callback?source=maxkey
topiam:
# topiam 服务器地址
server-url: http://127.0.0.1:1989/api/v1/authorize/y0q************spq***********8ol
client-id: 449c4*********937************759
client-secret: ac7***********1e0************28d
redirect-uri: ${justauth.address}/social-callback?source=topiam
scopes: [ openid, email, phone, profile ]
qq:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=qq
union-id: false
weibo:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=weibo
gitee:
client-id: 91436b7940090d09c72c7daf85b959cfd5f215d67eea73acbf61b6b590751a98
client-secret: 02c6fcfd70342980cd8dd2f2c06c1a350645d76c754d7a264c4e125f9ba915ac
redirect-uri: ${justauth.address}/social-callback?source=gitee
dingtalk:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=dingtalk
baidu:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=baidu
csdn:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=csdn
coding:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=coding
coding-group-name: xx
oschina:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=oschina
alipay_wallet:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=alipay_wallet
alipay-public-key: MIIB**************DAQAB
wechat_open:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_open
wechat_mp:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_mp
wechat_miniprogram:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_miniprogram
wechat_enterprise:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_enterprise
agent-id: 1000002
gitlab:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=gitlab
gitea:
# 前端改动 https://gitee.com/JavaLionLi/plus-ui/pulls/204
# gitea 服务器地址
server-url: https://demo.gitea.com
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=gitea
@@ -0,0 +1,274 @@
# 开发环境配置
server:
# 服务器的HTTP端口,默认为8080
port: 8080
servlet:
# 应用的访问路径
context-path: /
# undertow 配置
undertow:
# HTTP post内容的最大大小。当值为-1时,默认值为大小是无限的
max-http-post-size: -1
# 以下的配置会影响buffer,这些buffer会用于服务器连接的IO操作,有点类似netty的池化内存管理
# 每块buffer的空间大小,越小的空间被利用越充分
buffer-size: 512
# 是否分配的直接内存
direct-buffers: true
threads:
# 设置IO线程数, 它主要执行非阻塞的任务,它们会负责多个连接, 默认设置每个CPU核心一个线程
io: 8
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
worker: 256
captcha:
# 是否启用验证码校验
enable: true
# 验证码类型 math 数组计算 char 字符验证
type: MATH
# line 线段干扰 circle 圆圈干扰 shear 扭曲干扰
category: CIRCLE
# 数字验证码位数
numberLength: 1
# 字符验证码长度
charLength: 4
# 日志配置
logging:
level:
org.dromara: @logging.level@
org.springframework: warn
org.mybatis.spring.mapper: error
org.apache.fury: warn
config: classpath:logback-plus.xml
# 用户配置
user:
password:
# 密码最大错误次数
maxRetryCount: 5
# 密码锁定时间(默认10分钟)
lockTime: 10
auth:
# 验证码配置
captcha:
# App端验证码开关
enabled: false
# 验证码类型 math 数组计算 char 字符验证
type: math
# 白名单配置(多个路径用逗号分隔)
whitelist: /app/user/profile,/app/user/bind,/app/auth/logout
# Spring配置
spring:
application:
name: RuoYi-Vue-Plus
threads:
# 开启虚拟线程 仅jdk21可用
virtual:
enabled: false
# 资源信息
messages:
# 国际化资源文件路径
basename: i18n/messages
profiles:
active: @profiles.active@
# 文件上传
servlet:
multipart:
# 单个文件大小
max-file-size: 10MB
# 设置总上传的文件大小
max-request-size: 20MB
mvc:
# 设置静态资源路径 防止所有请求都去查静态资源
static-path-pattern: /static/**
format:
date-time: yyyy-MM-dd HH:mm:ss
jackson:
# 日期格式化
date-format: yyyy-MM-dd HH:mm:ss
serialization:
# 格式化输出
indent_output: false
# 忽略无法转换的对象
fail_on_empty_beans: false
deserialization:
# 允许对象忽略json中不存在的属性
fail_on_unknown_properties: false
# Sa-Token配置
sa-token:
# token名称 (同时也是cookie名称)
token-name: Authorization
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: false
# jwt秘钥
jwt-secret-key: abcdefghijklmnopqrstuvwxyz
# security配置
security:
# 排除路径
excludes:
- /*.html
- /**/*.html
- /**/*.css
- /**/*.js
- /favicon.ico
- /error
- /*/api-docs
- /*/api-docs/**
- /warm-flow-ui/config
- /chat/**
# MyBatisPlus配置
# https://baomidou.com/config/
mybatis-plus:
# 自定义配置 是否全局开启逻辑删除 关闭后 所有逻辑删除功能将失效
enableLogicDelete: true
# 多包名使用 例如 org.dromara.**.mapper,org.xxx.**.mapper
mapperPackage: org.dromara.**.mapper
# 对应的 XML 文件位置
mapperLocations: classpath*:mapper/**/*Mapper.xml
# 实体扫描,多个package用逗号或者分号分隔
typeAliasesPackage: org.dromara.**.domain
global-config:
dbConfig:
# 主键类型
# AUTO 自增 NONE 空 INPUT 用户输入 ASSIGN_ID 雪花 ASSIGN_UUID 唯一 UUID
# 如需改为自增 需要将数据库表全部设置为自增
idType: ASSIGN_ID
# 数据加密
mybatis-encryptor:
# 是否开启加密
enable: false
# 默认加密算法
algorithm: BASE64
# 编码方式 BASE64/HEX。默认BASE64
encode: BASE64
# 安全秘钥 对称算法的秘钥 如:AES,SM4
password:
# 公私钥 非对称算法的公私钥 如:SM2,RSA
publicKey:
privateKey:
# api接口加密
api-decrypt:
# 是否开启全局接口加密
enabled: true
# AES 加密头标识
headerFlag: encrypt-key
# 响应加密公钥 非对称算法的公私钥 如:SM2,RSA 使用者请自行更换
# 对应前端解密私钥 MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE=
publicKey: MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJnNwrj4hi/y3CCJu868ghCG5dUj8wZK++RNlTLcXoMmdZWEQ/u02RgD5LyLAXGjLOjbMtC+/J9qofpSGTKSx/MCAwEAAQ==
# 请求解密私钥 非对称算法的公私钥 如:SM2,RSA 使用者请自行更换
# 对应前端加密公钥 MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==
privateKey: MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAqhHyZfSsYourNxaY7Nt+PrgrxkiA50efORdI5U5lsW79MmFnusUA355oaSXcLhu5xxB38SMSyP2KvuKNPuH3owIDAQABAkAfoiLyL+Z4lf4Myxk6xUDgLaWGximj20CUf+5BKKnlrK+Ed8gAkM0HqoTt2UZwA5E2MzS4EI2gjfQhz5X28uqxAiEA3wNFxfrCZlSZHb0gn2zDpWowcSxQAgiCstxGUoOqlW8CIQDDOerGKH5OmCJ4Z21v+F25WaHYPxCFMvwxpcw99EcvDQIgIdhDTIqD2jfYjPTY8Jj3EDGPbH2HHuffvflECt3Ek60CIQCFRlCkHpi7hthhYhovyloRYsM+IS9h/0BzlEAuO0ktMQIgSPT3aFAgJYwKpqRYKlLDVcflZFCKY7u3UP8iWi1Qw0Y=
springdoc:
api-docs:
# 是否开启接口文档
enabled: true
info:
# 标题
title: '标题:RuoYi-Vue-Plus管理系统_接口文档'
# 描述
description: '描述:用于管理集团旗下公司的人员信息,具体包括XXX,XXX模块...'
# 版本
version: '版本号: ${project.version}'
# 作者信息
contact:
name: Lion Li
email: crazylionli@163.com
url: https://gitee.com/dromara/RuoYi-Vue-Plus
components:
# 鉴权方式配置
security-schemes:
apiKey:
type: APIKEY
in: HEADER
name: ${sa-token.token-name}
#这里定义了两个分组,可定义多个,也可以不定义
group-configs:
- group: 1.演示模块
packages-to-scan: org.dromara.demo
- group: 2.通用模块
packages-to-scan: org.dromara.web
- group: 3.系统模块
packages-to-scan: org.dromara.system
- group: 4.代码生成模块
packages-to-scan: org.dromara.generator
- group: 5.工作流模块
packages-to-scan: org.dromara.workflow
- group: 6.App端模块
packages-to-scan: org.dromara.app
# 防止XSS攻击
xss:
# 过滤开关
enabled: true
# 排除链接(多个用逗号分隔)
excludeUrls:
- /system/notice
# 全局线程池相关配置
# 如使用JDK21请直接使用虚拟线程 不要开启此配置
thread-pool:
# 是否开启线程池
enabled: false
# 队列最大长度
queueCapacity: 128
# 线程池维护线程所允许的空闲时间
keepAliveSeconds: 300
--- # 分布式锁 lock4j 全局配置
lock4j:
# 获取分布式锁超时时间,默认为 3000 毫秒
acquire-timeout: 3000
# 分布式锁的超时时间,默认为 30 秒
expire: 30000
--- # Actuator 监控端点的配置项
management:
endpoints:
web:
exposure:
include: '*'
endpoint:
health:
show-details: ALWAYS
logfile:
external-file: ./logs/sys-console.log
--- # 默认/推荐使用sse推送
sse:
enabled: true
path: /resource/sse
--- # websocket
websocket:
# 如果关闭 需要和前端开关一起关闭
enabled: false
# 路径
path: /resource/websocket
# 设置访问源地址
allowedOrigins: '*'
--- # warm-flow工作流配置
warm-flow:
# 是否开启工作流,默认true
enabled: false
# 是否开启设计器ui
ui: true
# 默认Authorization,如果有多个token,用逗号分隔
token-name: ${sa-token.token-name},clientid
# 流程状态对应的三元色
chart-status-color:
## 未办理
- 62,62,62
## 待办理
- 255,205,23
## 已办理
- 157,255,0
@@ -0,0 +1,8 @@
Application Version: ${revision}
Spring Boot Version: ${spring-boot.version}
__________ _____.___.__ ____ ____ __________.__
\______ \__ __ ____\__ | |__| \ \ / /_ __ ____ \______ \ | __ __ ______
| _/ | \/ _ \/ | | | ______ \ Y / | \_/ __ \ ______ | ___/ | | | \/ ___/
| | \ | ( <_> )____ | | /_____/ \ /| | /\ ___/ /_____/ | | | |_| | /\___ \
|____|_ /____/ \____// ______|__| \___/ |____/ \___ > |____| |____/____//____ >
\/ \/ \/ \/
@@ -0,0 +1,56 @@
#错误消息
not.null=* 必须填写
user.jcaptcha.error=验证码错误
user.jcaptcha.expire=验证码已失效
user.not.exists=对不起, 您的账号:{0} 不存在.
user.password.not.match=用户不存在/密码错误
user.password.retry.limit.count=密码输入错误{0}次
user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定{1}分钟
user.password.delete=对不起,您的账号:{0} 已被删除
user.blocked=对不起,您的账号:{0} 已禁用,请联系管理员
role.blocked=角色已封禁,请联系管理员
user.logout.success=退出成功
length.not.valid=长度必须在{min}到{max}个字符之间
user.username.not.blank=用户名不能为空
user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头
user.username.length.valid=账户长度必须在{min}到{max}个字符之间
user.password.not.blank=用户密码不能为空
user.password.length.valid=用户密码长度必须在{min}到{max}个字符之间
user.password.not.valid=* 5-50个字符
user.email.not.valid=邮箱格式错误
user.email.not.blank=邮箱不能为空
user.phonenumber.not.blank=用户手机号不能为空
user.mobile.phone.number.not.valid=手机号格式错误
user.login.success=登录成功
user.register.success=注册成功
user.register.save.error=保存用户 {0} 失败,注册账号已存在
user.register.error=注册失败,请联系系统管理人员
user.notfound=请重新登录
user.forcelogout=管理员强制退出,请重新登录
user.unknown.error=未知错误,请重新登录
auth.grant.type.error=认证权限类型错误
auth.grant.type.blocked=认证权限类型已禁用
auth.grant.type.not.blank=认证权限类型不能为空
auth.clientid.not.blank=认证客户端id不能为空
##文件上传消息
upload.exceed.maxSize=上传的文件大小超出限制的文件大小!<br/>允许的文件最大大小是:{0}MB!
upload.filename.exceed.length=上传的文件名最长{0}个字符
##权限
no.permission=您没有数据的权限,请联系管理员添加权限 [{0}]
no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}]
no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}]
no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}]
no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}]
no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}]
repeat.submit.message=不允许重复提交,请稍候再试
rate.limiter.message=访问过于频繁,请稍候再试
sms.code.not.blank=短信验证码不能为空
sms.code.retry.limit.count=短信验证码输入错误{0}次
sms.code.retry.limit.exceed=短信验证码输入错误{0}次,帐户锁定{1}分钟
email.code.not.blank=邮箱验证码不能为空
email.code.retry.limit.count=邮箱验证码输入错误{0}次
email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,帐户锁定{1}分钟
xcx.code.not.blank=小程序[code]不能为空
social.source.not.blank=第三方登录平台[source]不能为空
social.code.not.blank=第三方登录平台[code]不能为空
social.state.not.blank=第三方登录平台[state]不能为空
@@ -0,0 +1,56 @@
#错误消息
not.null=* Required fill in
user.jcaptcha.error=Captcha error
user.jcaptcha.expire=Captcha invalid
user.not.exists=Sorry, your account: {0} does not exist
user.password.not.match=User does not exist/Password error
user.password.retry.limit.count=Password input error {0} times
user.password.retry.limit.exceed=Password input error {0} times, account locked for {1} minutes
user.password.delete=Sorry, your account{0} has been deleted
user.blocked=Sorry, your account: {0} has been disabled. Please contact the administrator
role.blocked=Role disabledplease contact administrators
user.logout.success=Exit successful
length.not.valid=The length must be between {min} and {max} characters
user.username.not.blank=Username cannot be blank
user.username.not.valid=* 2 to 20 chinese characters, letters, numbers or underscores, and must start with a non number
user.username.length.valid=Account length must be between {min} and {max} characters
user.password.not.blank=Password cannot be empty
user.password.length.valid=Password length must be between {min} and {max} characters
user.password.not.valid=* 5-50 characters
user.email.not.valid=Mailbox format error
user.email.not.blank=Mailbox cannot be blank
user.phonenumber.not.blank=Phone number cannot be blank
user.mobile.phone.number.not.valid=Phone number format error
user.login.success=Login successful
user.register.success=Register successful
user.register.save.error=Failed to save user {0}, The registered account already exists
user.register.error=Register failed, please contact system administrator
user.notfound=Please login again
user.forcelogout=The administrator is forced to exitplease login again
user.unknown.error=Unknown error, please login again
auth.grant.type.error=Auth grant type error
auth.grant.type.blocked=Auth grant type disabled
auth.grant.type.not.blank=Auth grant type cannot be blank
auth.clientid.not.blank=Auth clientid cannot be blank
##文件上传消息
upload.exceed.maxSize=The uploaded file size exceeds the limit file size<br/>the maximum allowed file size is{0}MB
upload.filename.exceed.length=The maximum length of uploaded file name is {0} characters
##权限
no.permission=You do not have permission to the dataplease contact your administrator to add permissions [{0}]
no.create.permission=You do not have permission to create dataplease contact your administrator to add permissions [{0}]
no.update.permission=You do not have permission to modify dataplease contact your administrator to add permissions [{0}]
no.delete.permission=You do not have permission to delete dataplease contact your administrator to add permissions [{0}]
no.export.permission=You do not have permission to export dataplease contact your administrator to add permissions [{0}]
no.view.permission=You do not have permission to view dataplease contact your administrator to add permissions [{0}]
repeat.submit.message=Repeat submit is not allowed, please try again later
rate.limiter.message=Visit too frequently, please try again later
sms.code.not.blank=Sms code cannot be blank
sms.code.retry.limit.count=Sms code input error {0} times
sms.code.retry.limit.exceed=Sms code input error {0} times, account locked for {1} minutes
email.code.not.blank=Email code cannot be blank
email.code.retry.limit.count=Email code input error {0} times
email.code.retry.limit.exceed=Email code input error {0} times, account locked for {1} minutes
xcx.code.not.blank=Mini program [code] cannot be blank
social.source.not.blank=Social login platform [source] cannot be blank
social.code.not.blank=Social login platform [code] cannot be blank
social.state.not.blank=Social login platform [state] cannot be blank
@@ -0,0 +1,56 @@
#错误消息
not.null=* 必须填写
user.jcaptcha.error=验证码错误
user.jcaptcha.expire=验证码已失效
user.not.exists=对不起, 您的账号:{0} 不存在.
user.password.not.match=用户不存在/密码错误
user.password.retry.limit.count=密码输入错误{0}次
user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定{1}分钟
user.password.delete=对不起,您的账号:{0} 已被删除
user.blocked=对不起,您的账号:{0} 已禁用,请联系管理员
role.blocked=角色已封禁,请联系管理员
user.logout.success=退出成功
length.not.valid=长度必须在{min}到{max}个字符之间
user.username.not.blank=用户名不能为空
user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头
user.username.length.valid=账户长度必须在{min}到{max}个字符之间
user.password.not.blank=用户密码不能为空
user.password.length.valid=用户密码长度必须在{min}到{max}个字符之间
user.password.not.valid=* 5-50个字符
user.email.not.valid=邮箱格式错误
user.email.not.blank=邮箱不能为空
user.phonenumber.not.blank=用户手机号不能为空
user.mobile.phone.number.not.valid=手机号格式错误
user.login.success=登录成功
user.register.success=注册成功
user.register.save.error=保存用户 {0} 失败,注册账号已存在
user.register.error=注册失败,请联系系统管理人员
user.notfound=请重新登录
user.forcelogout=管理员强制退出,请重新登录
user.unknown.error=未知错误,请重新登录
auth.grant.type.error=认证权限类型错误
auth.grant.type.blocked=认证权限类型已禁用
auth.grant.type.not.blank=认证权限类型不能为空
auth.clientid.not.blank=认证客户端id不能为空
##文件上传消息
upload.exceed.maxSize=上传的文件大小超出限制的文件大小!<br/>允许的文件最大大小是:{0}MB!
upload.filename.exceed.length=上传的文件名最长{0}个字符
##权限
no.permission=您没有数据的权限,请联系管理员添加权限 [{0}]
no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}]
no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}]
no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}]
no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}]
no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}]
repeat.submit.message=不允许重复提交,请稍候再试
rate.limiter.message=访问过于频繁,请稍候再试
sms.code.not.blank=短信验证码不能为空
sms.code.retry.limit.count=短信验证码输入错误{0}次
sms.code.retry.limit.exceed=短信验证码输入错误{0}次,帐户锁定{1}分钟
email.code.not.blank=邮箱验证码不能为空
email.code.retry.limit.count=邮箱验证码输入错误{0}次
email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,帐户锁定{1}分钟
xcx.code.not.blank=小程序[code]不能为空
social.source.not.blank=第三方登录平台[source]不能为空
social.code.not.blank=第三方登录平台[code]不能为空
social.state.not.blank=第三方登录平台[state]不能为空
Binary file not shown.
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="log.path" value="./logs"/>
<property name="console.log.pattern"
value="%cyan(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %boldMagenta(%logger{36}%n) - %msg%n"/>
<property name="log.pattern" value="%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${console.log.pattern}</pattern>
<charset>utf-8</charset>
</encoder>
</appender>
<!-- 控制台输出 -->
<appender name="file_console" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-console.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-console.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大 1天 -->
<maxHistory>1</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
</filter>
</appender>
<!-- 系统日志输出 -->
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-info.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-error.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>ERROR</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- info异步输出 -->
<appender name="async_info" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_info"/>
</appender>
<!-- error异步输出 -->
<appender name="async_error" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_error"/>
</appender>
<!-- 整合 skywalking 控制台输出 tid -->
<!-- <appender name="console" class="ch.qos.logback.core.ConsoleAppender">-->
<!-- <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">-->
<!-- <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">-->
<!-- <pattern>[%tid] ${console.log.pattern}</pattern>-->
<!-- </layout>-->
<!-- <charset>utf-8</charset>-->
<!-- </encoder>-->
<!-- </appender>-->
<!-- 整合 skywalking 推送采集日志 -->
<!-- <appender name="sky_log" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender">-->
<!-- <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">-->
<!-- <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">-->
<!-- <pattern>[%tid] ${console.log.pattern}</pattern>-->
<!-- </layout>-->
<!-- <charset>utf-8</charset>-->
<!-- </encoder>-->
<!-- </appender>-->
<!--系统操作日志-->
<root level="info">
<appender-ref ref="console"/>
<appender-ref ref="async_info"/>
<appender-ref ref="async_error"/>
<appender-ref ref="file_console"/>
<!-- <appender-ref ref="sky_log"/>-->
</root>
</configuration>