login(@RequestBody @Valid QrCodeTicketForm form) {
+ return Result.success(qrCodeLoginService.login(form.getTicket()));
+ }
+
+ /** 从请求头或连接信息中提取客户端 IP,兼容反向代理 */
+ private String getClientIp(HttpServletRequest request) {
+ String ip = request.getHeader("X-Forwarded-For");
+ if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
+ int comma = ip.indexOf(',');
+ return comma > 0 ? ip.substring(0, comma).trim() : ip.trim();
+ }
+ ip = request.getHeader("X-Real-IP");
+ if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
+ return ip.trim();
+ }
+ return request.getRemoteAddr();
+ }
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java
new file mode 100644
index 00000000..724c4f2e
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java
@@ -0,0 +1,39 @@
+package com.youlai.boot.auth.qrcode.model;
+
+import lombok.Data;
+
+/**
+ * 扫码登录票据上下文,序列化为 JSON 存入 Redis。
+ *
+ * 字段说明见 docs/youlai-boot/scan-code-login.md 的 Redis 存储设计。
+ */
+@Data
+public class QrCodeLoginContext {
+
+ /** 票据,UUID 无连字符 */
+ private String ticket;
+
+ /** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */
+ private String status;
+
+ /** 扫码用户 ID,scan 时写入 */
+ private Long userId;
+
+ /** 用户原始昵称(未脱敏),status 接口返回时脱敏 */
+ private String nickname;
+
+ /** 用户头像 URL */
+ private String avatar;
+
+ /** 创建时间戳(毫秒) */
+ private Long createdAt;
+
+ /** 扫码时间戳(毫秒) */
+ private Long scannedAt;
+
+ /** 确认时间戳(毫秒) */
+ private Long confirmedAt;
+
+ /** generate 时的 PC 端 IP,用于审计 */
+ private String clientIp;
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java
new file mode 100644
index 00000000..1f993c6e
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java
@@ -0,0 +1,21 @@
+package com.youlai.boot.auth.qrcode.model;
+
+/**
+ * 扫码登录票据状态。
+ *
+ * WAITING 票据已创建,等待 APP 扫码
+ * SCANNED APP 已扫码,等待用户在手机上确认
+ * CONFIRMED 用户已在 APP 上确认登录
+ * LOGGED_IN PC 已用票据换取会话令牌,票据作废,不可再用
+ * CANCELED 用户在 APP 上取消登录
+ * EXPIRED 票据超时,由 Redis TTL 自动清理,内存中通常不会出现该值
+ */
+public enum QrCodeLoginStatusEnum {
+
+ WAITING,
+ SCANNED,
+ CONFIRMED,
+ LOGGED_IN,
+ CANCELED,
+ EXPIRED
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java b/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java
new file mode 100644
index 00000000..51d151b7
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java
@@ -0,0 +1,14 @@
+package com.youlai.boot.auth.qrcode.model.form;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 扫码登录票据表单,用于 scan/confirm/cancel/login 接口。
+ */
+@Data
+public class QrCodeTicketForm {
+
+ @NotBlank(message = "票据不能为空")
+ private String ticket;
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java
new file mode 100644
index 00000000..117ef015
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java
@@ -0,0 +1,20 @@
+package com.youlai.boot.auth.qrcode.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * generate 接口响应。
+ */
+@Data
+@Builder
+@Schema(description = "扫码票据生成结果")
+public class QrCodeGenerateVO {
+
+ @Schema(description = "票据")
+ private String ticket;
+
+ @Schema(description = "有效期(秒)")
+ private Integer expireSeconds;
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java
new file mode 100644
index 00000000..9b3d649e
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java
@@ -0,0 +1,29 @@
+package com.youlai.boot.auth.qrcode.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * status/scan/confirm/cancel 接口响应。
+ */
+@Data
+@Builder
+@Schema(description = "扫码登录状态")
+public class QrCodeStatusVO {
+
+ @Schema(description = "票据")
+ private String ticket;
+
+ @Schema(description = "状态:WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED")
+ private String status;
+
+ @Schema(description = "脱敏昵称,SCANNED 之后返回")
+ private String nickname;
+
+ @Schema(description = "头像 URL,SCANNED 之后返回")
+ private String avatar;
+
+ @Schema(description = "剩余有效期(秒)")
+ private Integer expireSeconds;
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java
new file mode 100644
index 00000000..9c8a2012
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java
@@ -0,0 +1,29 @@
+package com.youlai.boot.auth.qrcode.service;
+
+import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
+import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
+import com.youlai.boot.framework.security.model.AuthenticationToken;
+
+/**
+ * 扫码登录服务。
+ */
+public interface QrCodeLoginService {
+
+ /** 生成票据,未登录调用 */
+ QrCodeGenerateVO generate(String clientIp);
+
+ /** 查询状态,未登录调用 */
+ QrCodeStatusVO status(String ticket);
+
+ /** APP 标记已扫码,需 APP 端已登录 */
+ QrCodeStatusVO scan(String ticket, Long userId);
+
+ /** APP 确认登录,需 APP 端已登录 */
+ QrCodeStatusVO confirm(String ticket, Long userId);
+
+ /** APP 取消登录,需 APP 端已登录 */
+ QrCodeStatusVO cancel(String ticket, Long userId);
+
+ /** PC 端用票据换取会话令牌,未登录调用 */
+ AuthenticationToken login(String ticket);
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java b/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java
new file mode 100644
index 00000000..9a488c60
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java
@@ -0,0 +1,228 @@
+package com.youlai.boot.auth.qrcode.service.impl;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import com.youlai.boot.auth.qrcode.model.QrCodeLoginContext;
+import com.youlai.boot.auth.qrcode.model.QrCodeLoginStatusEnum;
+import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
+import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
+import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
+import com.youlai.boot.auth.qrcode.util.QrCodeNicknameMasker;
+import com.youlai.boot.common.constant.RedisConstants;
+import com.youlai.boot.common.result.ResultCode;
+import com.youlai.boot.framework.security.exception.TokenInvalidException;
+import com.youlai.boot.framework.security.model.AuthenticationToken;
+import com.youlai.boot.framework.security.model.SecurityUser;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
+import com.youlai.boot.framework.security.token.TokenManager;
+import com.youlai.boot.system.service.UserSocialService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Service;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 扫码登录服务实现。
+ *
+ * 票据上下文以 JSON 形式存入 Redis,TTL 与有效期一致;状态流转的合法性全部由
+ * {@link #requireStatus} / {@link #requireSameUser} 在写入前拦截,非法迁移直接抛业务异常,
+ * 因此同一 ticket 被多端并发操作时不需要分布式锁——最坏情况是后到的操作在状态校验处失败。
+ * PC 端换取会话时直接复用 {@link TokenManager#generateToken(Authentication)},
+ * 生成的令牌与账号密码登录走的是同一套会话治理(单/多设备、登出、刷新),前端无差别处理。
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class QrCodeLoginServiceImpl implements QrCodeLoginService {
+
+ /** 票据默认有效期(秒),二维码在此时间内未走完流程即由 Redis TTL 自动清理 */
+ private static final int DEFAULT_EXPIRE_SECONDS = 300;
+
+ /** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值,避免临界点票据在下一步操作前被清掉 */
+ private static final int MIN_REMAIN_SECONDS = 30;
+
+ /**
+ * 从 Redis 读回的值可能是不带类型信息的 Map(序列化器以 Object.class 存储),
+ * 用它把 Map 转回强类型上下文对象。JsonMapper 线程安全,复用单例。
+ */
+ private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build();
+
+ private final RedisTemplate redisTemplate;
+ private final TokenManager tokenManager;
+ private final UserSocialService userSocialService;
+
+ @Override
+ public QrCodeGenerateVO generate(String clientIp) {
+ String ticket = IdUtil.fastSimpleUUID();
+ QrCodeLoginContext ctx = new QrCodeLoginContext();
+ ctx.setTicket(ticket);
+ ctx.setStatus(QrCodeLoginStatusEnum.WAITING.name());
+ ctx.setCreatedAt(System.currentTimeMillis());
+ ctx.setClientIp(clientIp);
+ save(ctx, DEFAULT_EXPIRE_SECONDS);
+ return QrCodeGenerateVO.builder()
+ .ticket(ticket)
+ .expireSeconds(DEFAULT_EXPIRE_SECONDS)
+ .build();
+ }
+
+ @Override
+ public QrCodeStatusVO status(String ticket) {
+ QrCodeLoginContext ctx = loadOrThrow(ticket);
+ return toVO(ctx, remainingSeconds(ticket));
+ }
+
+ @Override
+ public QrCodeStatusVO scan(String ticket, Long userId) {
+ QrCodeLoginContext ctx = loadOrThrow(ticket);
+ requireStatus(ctx, QrCodeLoginStatusEnum.WAITING);
+ fillUserInfo(ctx, userId);
+ ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name());
+ ctx.setScannedAt(System.currentTimeMillis());
+ save(ctx, refreshTtl(ticket));
+ return toVO(ctx, remainingSeconds(ticket));
+ }
+
+ @Override
+ public QrCodeStatusVO confirm(String ticket, Long userId) {
+ QrCodeLoginContext ctx = loadOrThrow(ticket);
+ requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED);
+ requireSameUser(ctx, userId);
+ ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name());
+ ctx.setConfirmedAt(System.currentTimeMillis());
+ save(ctx, refreshTtl(ticket));
+ return toVO(ctx, remainingSeconds(ticket));
+ }
+
+ @Override
+ public QrCodeStatusVO cancel(String ticket, Long userId) {
+ QrCodeLoginContext ctx = loadOrThrow(ticket);
+ QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
+ // 只有还在流程中的票据(等待扫码 / 已扫码 / 已确认)允许取消,已登录或已取消的重复操作直接拒绝
+ if (current != QrCodeLoginStatusEnum.WAITING
+ && current != QrCodeLoginStatusEnum.SCANNED
+ && current != QrCodeLoginStatusEnum.CONFIRMED) {
+ throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
+ }
+ // 一旦有人扫过码,取消权就归扫码本人,防止他人替扫码用户取消
+ if (current != QrCodeLoginStatusEnum.WAITING && ctx.getUserId() != null) {
+ requireSameUser(ctx, userId);
+ }
+ ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name());
+ save(ctx, refreshTtl(ticket));
+ return toVO(ctx, remainingSeconds(ticket));
+ }
+
+ @Override
+ public AuthenticationToken login(String ticket) {
+ QrCodeLoginContext ctx = loadOrThrow(ticket);
+ requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED);
+ SecurityUser securityUser = userSocialService.getAuthInfoByUserId(ctx.getUserId());
+ if (securityUser == null) {
+ throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND);
+ }
+ SecurityUserDetails details = new SecurityUserDetails(securityUser);
+ Authentication authentication = new UsernamePasswordAuthenticationToken(
+ details, null, details.getAuthorities());
+ AuthenticationToken token = tokenManager.generateToken(authentication);
+ // 换取令牌成功后立即把票据置为已使用(一次性),再次 login 会在 requireStatus(CONFIRMED) 处被拒,杜绝重放
+ ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name());
+ save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS));
+ return token;
+ }
+
+ // ======================== private ========================
+
+ /** 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */
+ private QrCodeLoginContext loadOrThrow(String ticket) {
+ if (StrUtil.isBlank(ticket)) {
+ throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
+ }
+ Object raw = redisTemplate.opsForValue().get(key(ticket));
+ if (raw == null) {
+ throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
+ }
+ if (raw instanceof QrCodeLoginContext ctx) {
+ return ctx;
+ }
+ // 序列化器以 Object.class 存储、未带类型信息,读回来是 Map,这里再转回强类型
+ try {
+ return JSON_MAPPER.convertValue(raw, QrCodeLoginContext.class);
+ } catch (Exception e) {
+ log.error("扫码登录上下文反序列化失败, ticket={}", ticket, e);
+ throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
+ }
+ }
+
+ /** 当前状态必须等于期望状态,否则说明流程被跳步或重复操作 */
+ private void requireStatus(QrCodeLoginContext ctx, QrCodeLoginStatusEnum expected) {
+ if (!expected.name().equals(ctx.getStatus())) {
+ throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
+ }
+ }
+
+ /** 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认 */
+ private void requireSameUser(QrCodeLoginContext ctx, Long userId) {
+ if (ctx.getUserId() == null || !ctx.getUserId().equals(userId)) {
+ throw new TokenInvalidException(ResultCode.QR_CODE_USER_MISMATCH);
+ }
+ }
+
+ /** 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */
+ private void fillUserInfo(QrCodeLoginContext ctx, Long userId) {
+ SecurityUser info = userSocialService.getAuthInfoByUserId(userId);
+ if (info == null) {
+ throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND);
+ }
+ ctx.setUserId(userId);
+ ctx.setNickname(info.getNickname());
+ ctx.setAvatar(info.getAvatar());
+ }
+
+ /** 重写整个上下文并刷新 TTL */
+ private void save(QrCodeLoginContext ctx, int ttl) {
+ redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS);
+ }
+
+ /** 票据在 Redis 中的剩余秒数,取不到或已过期返回 0 */
+ private int remainingSeconds(String ticket) {
+ Long ttl = redisTemplate.getExpire(key(ticket), TimeUnit.SECONDS);
+ return ttl == null ? 0 : Math.max(ttl.intValue(), 0);
+ }
+
+ /** 状态流转时写回的 TTL:维持剩余时间,不足 MIN_REMAIN_SECONDS 则补足 */
+ private int refreshTtl(String ticket) {
+ int remain = remainingSeconds(ticket);
+ return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain;
+ }
+
+ /** 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露 */
+ private QrCodeStatusVO toVO(QrCodeLoginContext ctx, int expireSeconds) {
+ QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
+ String nickname = null;
+ String avatar = null;
+ // WAITING 阶段谁都能查状态,此时不能泄露用户信息;扫码/确认后才回传脱敏昵称与头像
+ if (status == QrCodeLoginStatusEnum.SCANNED
+ || status == QrCodeLoginStatusEnum.CONFIRMED) {
+ nickname = QrCodeNicknameMasker.mask(ctx.getNickname());
+ avatar = ctx.getAvatar();
+ }
+ return QrCodeStatusVO.builder()
+ .ticket(ctx.getTicket())
+ .status(status.name())
+ .nickname(nickname)
+ .avatar(avatar)
+ .expireSeconds(expireSeconds)
+ .build();
+ }
+
+ /** 拼接票据在 Redis 中的完整 Key */
+ private String key(String ticket) {
+ return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket);
+ }
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java b/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java
new file mode 100644
index 00000000..01fcc760
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java
@@ -0,0 +1,34 @@
+package com.youlai.boot.auth.qrcode.util;
+
+/**
+ * 昵称脱敏:保留首尾各一个字符,中间用 * 替换。
+ *
+ * 长度 1:原样返回
+ * 长度 2:首字 + *
+ * 长度 ≥3:首字 + (n-2) 个 * + 末字
+ */
+public final class QrCodeNicknameMasker {
+
+ private QrCodeNicknameMasker() {
+ }
+
+ public static String mask(String nickname) {
+ if (nickname == null || nickname.isEmpty()) {
+ return "";
+ }
+ int len = nickname.length();
+ if (len == 1) {
+ return nickname;
+ }
+ if (len == 2) {
+ return nickname.charAt(0) + "*";
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append(nickname.charAt(0));
+ for (int i = 0; i < len - 2; i++) {
+ sb.append('*');
+ }
+ sb.append(nickname.charAt(len - 1));
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java b/src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java
similarity index 63%
rename from src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java
rename to src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java
index da39053e..2c8412d6 100644
--- a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java
+++ b/src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java
@@ -1,18 +1,20 @@
-package com.youlai.boot.framework.security.config;
+package com.youlai.boot.auth.security.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.security.filter.MobileApiSignatureFilter;
import com.youlai.boot.framework.captcha.service.CaptchaService;
-import com.youlai.boot.framework.security.filter.CaptchaValidationFilter;
+import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
-import com.youlai.boot.framework.security.handler.MyAccessDeniedHandler;
-import com.youlai.boot.framework.security.handler.MyAuthenticationEntryPoint;
-import com.youlai.boot.framework.security.provider.SmsAuthenticationProvider;
-import com.youlai.boot.framework.security.provider.WxMaAuthenticationProvider;
+import com.youlai.boot.framework.security.port.UserAuthenticationPort;
+import com.youlai.boot.framework.security.service.SecurityUserDetailsService;
import com.youlai.boot.framework.security.token.TokenManager;
-import com.youlai.boot.framework.security.service.SysUserDetailsService;
-import com.youlai.boot.system.service.UserService;
+import com.youlai.boot.auth.security.filter.CaptchaValidationFilter;
+import com.youlai.boot.auth.security.handler.JsonAccessDeniedHandler;
+import com.youlai.boot.auth.security.handler.JsonAuthenticationEntryPoint;
+import com.youlai.boot.auth.security.provider.SmsAuthenticationProvider;
+import com.youlai.boot.auth.security.provider.WxMaAuthenticationProvider;
+import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -30,9 +32,13 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.access.intercept.AuthorizationFilter;
/**
- * Spring Security 配置类
+ * Spring Security 配置类。
+ *
+ * 归使用方(auth 模块),安全规则(放行路径、CORS、Provider 装配、响应格式)
+ * 因项目而异,不应由框架层强制装配。
*
* @author Ray.Hao
* @since 4.3.1
@@ -45,22 +51,15 @@ public class SecurityConfig {
private final RedisTemplate redisTemplate;
private final PasswordEncoder passwordEncoder;
-
private final TokenManager tokenManager;
- private final UserService userService;
- private final SysUserDetailsService userDetailsService;
-
+ private final SecurityUserDetailsService userDetailsService;
private final CaptchaService captchaService;
private final SecurityProperties securityProperties;
- /**
- * 配置安全过滤链 SecurityFilterChain
- */
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(requestMatcherRegistry -> {
- // 配置无需登录即可访问的公开接口(配置文件方式)
String[] ignoreUrls = securityProperties.getIgnoreUrls();
if (ArrayUtil.isNotEmpty(ignoreUrls)) {
requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll();
@@ -75,33 +74,25 @@ public class SecurityConfig {
)
.exceptionHandling(configurer ->
configurer
- .authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 未认证异常处理器
- .accessDeniedHandler(new MyAccessDeniedHandler()) // 无权限访问异常处理器
+ .authenticationEntryPoint(new JsonAuthenticationEntryPoint())
+ .accessDeniedHandler(new JsonAccessDeniedHandler())
)
-
- // 禁用默认的 Spring Security 特性,适用于前后端分离架构
.sessionManagement(configurer ->
- configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态认证,不使用 Session
+ configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
- .csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF 防护,前后端分离无需此防护机制
- .formLogin(AbstractHttpConfigurer::disable) // 禁用默认的表单登录功能,前后端分离采用 Token 认证方式
- .httpBasic(AbstractHttpConfigurer::disable) // 禁用 HTTP Basic 认证,避免弹窗式登录
- // 禁用 X-Frame-Options 响应头,允许页面被嵌套到 iframe 中
+ .csrf(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
- // 验证码校验过滤器
+ // 验证码校验(使用方过滤器,直接写 JSON 响应)
.addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class)
+ // Token 认证(Starter 过滤器,抛 AuthenticationException 交给 ExceptionTranslationFilter 处理)
+ .addFilterBefore(new TokenAuthenticationFilter(tokenManager), AuthorizationFilter.class)
// 移动设备API签名验证过滤器(仅对 /api/v1/sn/** 路径生效)
.addFilterBefore(new MobileApiSignatureFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class)
- // 验证和解析过滤器
- .addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class)
.build();
}
- /**
- * 配置Web安全自定义器,以忽略特定请求路径的安全性检查。
- *
- * 该配置用于指定哪些请求路径不经过Spring Security过滤器链。通常用于静态资源文件。
- */
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> {
@@ -112,38 +103,27 @@ public class SecurityConfig {
};
}
- /**
- * 默认密码认证的 Provider
- */
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
- DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(userDetailsService);
- daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
- return daoAuthenticationProvider;
+ DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
+ provider.setPasswordEncoder(passwordEncoder);
+ return provider;
}
- /**
- * 短信验证码认证 Provider
- */
@Bean
- public SmsAuthenticationProvider smsAuthenticationProvider() {
- return new SmsAuthenticationProvider(userService, redisTemplate);
+ public SmsAuthenticationProvider smsAuthenticationProvider(UserAuthenticationPort userAuthPort) {
+ return new SmsAuthenticationProvider(userAuthPort, redisTemplate);
}
- /**
- * 微信小程序认证 Provider
- */
@Bean
public WxMaAuthenticationProvider wechatMiniAuthenticationProvider(
WxMaService wxMaService,
- SysUserDetailsService sysUserDetailsService
+ UserAuthenticationPort userAuthenticationPort,
+ UserSocialService userSocialService
) {
- return new WxMaAuthenticationProvider(wxMaService, sysUserDetailsService);
+ return new WxMaAuthenticationProvider(wxMaService, userAuthenticationPort, userSocialService);
}
- /**
- * 认证管理器
- */
@Bean
public AuthenticationManager authenticationManager(
DaoAuthenticationProvider daoAuthenticationProvider,
@@ -156,5 +136,4 @@ public class SecurityConfig {
wxMaAuthenticationProvider
);
}
-
}
diff --git a/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java b/src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java
similarity index 60%
rename from src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java
rename to src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java
index 706b6179..099b9ee3 100644
--- a/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java
+++ b/src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java
@@ -1,17 +1,16 @@
-package com.youlai.boot.framework.security.exception;
+package com.youlai.boot.auth.security.exception;
import org.springframework.security.core.AuthenticationException;
/**
- * 需要绑定手机号异常
+ * 需要绑定手机号异常(微信小程序登录未绑定手机号时抛出)。
*/
-public class NeedBindMobileException extends AuthenticationException {
+public class MobileNotBoundException extends AuthenticationException {
private final String openid;
-
private final String sessionKey;
- public NeedBindMobileException(String openid, String sessionKey) {
+ public MobileNotBoundException(String openid, String sessionKey) {
super("需要绑定手机号");
this.openid = openid;
this.sessionKey = sessionKey;
@@ -24,5 +23,4 @@ public class NeedBindMobileException extends AuthenticationException {
public String getSessionKey() {
return sessionKey;
}
-
}
diff --git a/src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java b/src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java
similarity index 83%
rename from src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java
rename to src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java
index 8fca2cd9..d5bfd0ea 100644
--- a/src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java
+++ b/src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.framework.security.exception;
+package com.youlai.boot.auth.security.exception;
import org.springframework.security.core.AuthenticationException;
@@ -9,6 +9,7 @@ import org.springframework.security.core.AuthenticationException;
* @since 2025/3/1
*/
public class SmsCaptchaException extends AuthenticationException {
+
public SmsCaptchaException(String msg) {
super(msg);
}
diff --git a/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java b/src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java
similarity index 93%
rename from src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java
rename to src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java
index 30504bc2..db2b4973 100644
--- a/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java
+++ b/src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.framework.security.filter;
+package com.youlai.boot.auth.security.filter;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
@@ -29,7 +29,9 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
- * 图形验证码校验过滤器
+ * 图形验证码校验过滤器。
+ *
+ * 归使用方,因为验证码规则(哪些接口需要验证码、验证码类型)因项目而异。
*/
public class CaptchaValidationFilter extends OncePerRequestFilter {
@@ -49,20 +51,17 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
- // 非登录接口直接放行
if (!LOGIN_PATH_REQUEST_MATCHER.matches(request)) {
chain.doFilter(request, response);
return;
}
- // 仅支持 JSON 登录
String contentType = request.getContentType();
if (contentType == null || !contentType.contains(MediaType.APPLICATION_JSON_VALUE)) {
ResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
return;
}
- // 包装请求,确保下游还能读取 body
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request, -1);
byte[] bodyBytes = StreamUtils.copyToByteArray(requestWrapper.getInputStream());
@@ -85,9 +84,6 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
}
}
- /**
- * Simple wrapper to allow repeated reads of the request body after we've parsed it here.
- */
private static class RepeatableReadRequestWrapper extends HttpServletRequestWrapper {
private final byte[] cachedBody;
@@ -139,5 +135,3 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
}
}
}
-
-
diff --git a/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java b/src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java
similarity index 64%
rename from src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java
rename to src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java
index b4b28ab4..3124829f 100644
--- a/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java
+++ b/src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.framework.security.handler;
+package com.youlai.boot.auth.security.handler;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
@@ -9,17 +9,18 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
- * 无权限访问处理器
+ * 无权限访问处理器。
+ *
+ * 归使用方,因为 JSON 响应格式因项目而异。
*
* @author Ray.Hao
* @since 2.0.0
*/
-public class MyAccessDeniedHandler implements AccessDeniedHandler {
+public class JsonAccessDeniedHandler implements AccessDeniedHandler {
@Override
- public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
- // 权限不足返回 403 Forbidden
+ public void handle(HttpServletRequest request, HttpServletResponse response,
+ AccessDeniedException accessDeniedException) {
ResponseWriter.writeError(response, ResultCode.ACCESS_PERMISSION_EXCEPTION);
}
-
}
diff --git a/src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java b/src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java
similarity index 54%
rename from src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java
rename to src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java
index 4f4257c1..0a265125 100644
--- a/src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java
+++ b/src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.framework.security.handler;
+package com.youlai.boot.auth.security.handler;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
@@ -14,35 +14,24 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
- * 统一处理 Spring Security 认证失败响应
+ * 统一处理 Spring Security 认证失败响应。
+ *
+ * 归使用方,因为 JSON 响应格式(Result 结构、错误码)因项目而异。
*
* @author Ray.Hao
* @since 2.0.0
*/
-public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
+public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
- /**
- * 认证失败处理入口方法
- *
- * @param request 触发异常的请求对象(可用于获取请求头、参数等)
- * @param response 响应对象(用于写入错误信息)
- * @param authException 认证异常对象(包含具体失败原因)
- */
@Override
- public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
+ public void commence(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException authException) throws IOException, ServletException {
if (authException instanceof BadCredentialsException) {
- // 用户名或密码错误
ResponseWriter.writeError(response, ResultCode.USER_PASSWORD_ERROR);
- } else if(authException instanceof InsufficientAuthenticationException){
- // 请求头缺失Authorization、Token格式错误、Token过期、签名验证失败
+ } else if (authException instanceof InsufficientAuthenticationException) {
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
} else {
- // 其他未明确处理的认证异常(如账户被锁定、账户禁用等)
ResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage());
}
}
}
-
-
-
-
diff --git a/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java
similarity index 54%
rename from src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java
rename to src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java
index bdcb60fe..064e277b 100644
--- a/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java
+++ b/src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.framework.security.model;
+package com.youlai.boot.auth.security.model;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
@@ -8,13 +8,9 @@ import java.io.Serial;
import java.util.Collection;
/**
- * 短信验证码认证 Token
+ * 短信验证码认证 Token。
*
- * 用于短信验证码登录场景,遵循 Spring Security 认证模型:
- *
- * 未认证状态:principal 为手机号,credentials 为验证码
- * 已认证状态:principal 为用户详情,credentials 为 null
- *
+ * 未认证:principal=手机号,credentials=验证码;已认证:principal=SecurityUserDetails,credentials=null。
*
* @author Ray.Hao
* @since 2.20.0
@@ -24,30 +20,9 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 621L;
- /**
- * 认证信息
- *
- * 未认证时:手机号
- * 已认证时:SysUserDetails 用户详情
- *
- */
private final Object principal;
-
- /**
- * 凭证信息
- *
- * 未认证时:短信验证码
- * 已认证时:null
- *
- */
private final Object credentials;
- /**
- * 创建未认证的 Token
- *
- * @param mobile 手机号
- * @param verifyCode 短信验证码
- */
public SmsAuthenticationToken(String mobile, String verifyCode) {
super(AuthorityUtils.NO_AUTHORITIES);
this.principal = mobile;
@@ -55,12 +30,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
setAuthenticated(false);
}
- /**
- * 创建已认证的 Token
- *
- * @param principal 用户详情(SysUserDetails)
- * @param authorities 授权信息
- */
public SmsAuthenticationToken(Object principal, Collection extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
@@ -68,13 +37,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
super.setAuthenticated(true);
}
- /**
- * 创建已认证的 Token(静态工厂方法)
- *
- * @param principal 用户详情(SysUserDetails)
- * @param authorities 授权信息
- * @return 已认证的 SmsAuthenticationToken
- */
public static SmsAuthenticationToken authenticated(Object principal, Collection extends GrantedAuthority> authorities) {
return new SmsAuthenticationToken(principal, authorities);
}
diff --git a/src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java
similarity index 68%
rename from src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java
rename to src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java
index 584929ef..3f012894 100644
--- a/src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java
+++ b/src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.framework.security.model;
+package com.youlai.boot.auth.security.model;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
@@ -8,7 +8,9 @@ import java.io.Serial;
import java.util.Collection;
/**
- * 微信小程序认证 Token
+ * 微信小程序认证 Token。
+ *
+ * 未认证:principal=微信code;已认证:principal=SecurityUserDetails。
*
* @author Ray.Hao
* @since 4.0.0
@@ -18,25 +20,9 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 622L;
- /**
- * 认证信息
- * 未认证时:微信code
- * 已认证时:SysUserDetails 用户详情
- */
private final Object principal;
-
- /**
- * 凭证信息
- * 未认证时:null
- * 已认证时:null
- */
private final Object credentials;
- /**
- * 创建未认证的 Token
- *
- * @param code 微信小程序code
- */
public WxMaAuthenticationToken(String code) {
super(AuthorityUtils.NO_AUTHORITIES);
this.principal = code;
@@ -44,12 +30,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
setAuthenticated(false);
}
- /**
- * 创建已认证的 Token
- *
- * @param principal 用户详情(SysUserDetails)
- * @param authorities 授权信息
- */
public WxMaAuthenticationToken(Object principal, Collection extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
@@ -57,9 +37,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
super.setAuthenticated(true);
}
- /**
- * 创建已认证的 Token(静态工厂方法)
- */
public static WxMaAuthenticationToken authenticated(Object principal, Collection extends GrantedAuthority> authorities) {
return new WxMaAuthenticationToken(principal, authorities);
}
diff --git a/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java
similarity index 61%
rename from src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java
rename to src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java
index 3450bd7a..68626416 100644
--- a/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java
+++ b/src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java
@@ -1,13 +1,13 @@
-package com.youlai.boot.framework.security.provider;
+package com.youlai.boot.auth.security.provider;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
-import com.youlai.boot.framework.security.exception.SmsCaptchaException;
-import com.youlai.boot.framework.security.model.SmsAuthenticationToken;
-import com.youlai.boot.framework.security.model.SysUserDetails;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
-import com.youlai.boot.system.service.UserService;
+import com.youlai.boot.framework.security.model.SecurityUser;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
+import com.youlai.boot.framework.security.port.UserAuthenticationPort;
+import com.youlai.boot.auth.security.exception.SmsCaptchaException;
+import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationProvider;
@@ -19,47 +19,34 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
/**
* 短信验证码认证 Provider
*
- * 实现 Spring Security 的 {@link AuthenticationProvider} 接口,处理短信验证码登录认证。
- *
* 认证流程:
*
* 根据手机号查询用户信息
- * 校验用户状态(是否禁用)
+ * 校验用户状态
* 校验短信验证码(与 Redis 缓存比对)
- * 验证成功后删除验证码,防止重复使用
+ * 验证成功后删除验证码
* 返回已认证的 Authentication
*
*
* @author Ray.Hao
* @since 2.17.0
- * @see SmsAuthenticationToken
- * @see AuthenticationProvider
*/
@Slf4j
public class SmsAuthenticationProvider implements AuthenticationProvider {
- private final UserService userService;
-
+ private final UserAuthenticationPort userAuthPort;
private final RedisTemplate redisTemplate;
- public SmsAuthenticationProvider(UserService userService, RedisTemplate redisTemplate) {
- this.userService = userService;
+ public SmsAuthenticationProvider(UserAuthenticationPort userAuthPort, RedisTemplate redisTemplate) {
+ this.userAuthPort = userAuthPort;
this.redisTemplate = redisTemplate;
}
- /**
- * 执行短信验证码认证
- *
- * @param authentication 未认证的 {@link SmsAuthenticationToken}
- * @return 已认证的 {@link SmsAuthenticationToken}
- * @throws AuthenticationException 认证失败异常
- */
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String mobile = (String) authentication.getPrincipal();
String inputVerifyCode = (String) authentication.getCredentials();
- // 参数校验
if (StrUtil.isBlank(mobile)) {
log.warn("短信验证码登录失败:手机号为空");
throw new SmsCaptchaException("手机号不能为空");
@@ -69,21 +56,18 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
throw new SmsCaptchaException("验证码不能为空");
}
- // 根据手机号获取用户信息
- UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile);
+ SecurityUser securityUser = userAuthPort.getAuthInfoByMobile(mobile);
- if (userAuthInfo == null) {
+ if (securityUser == null) {
log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile);
throw new UsernameNotFoundException("用户不存在");
}
- // 检查用户状态是否有效
- if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
- log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername());
+ if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) {
+ log.warn("短信验证码登录失败:用户已禁用,用户名={}", securityUser.getUsername());
throw new DisabledException("用户已被禁用");
}
- // 校验短信验证码
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey);
@@ -97,24 +81,13 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
throw new SmsCaptchaException("验证码错误");
}
- // 验证成功后删除验证码,防止重复使用
redisTemplate.delete(cacheKey);
- // 构建认证后的用户详情信息
- SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
-
- log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile);
-
- // 创建已认证的 SmsAuthenticationToken
+ SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
+ log.info("短信验证码登录成功:用户名={},手机号={}", securityUser.getUsername(), mobile);
return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
}
- /**
- * 支持的认证类型
- *
- * @param authentication 认证类型
- * @return 是否支持该认证类型
- */
@Override
public boolean supports(Class> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
diff --git a/src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java
similarity index 64%
rename from src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java
rename to src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java
index d30bfd60..3b40f2f5 100644
--- a/src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java
+++ b/src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java
@@ -1,14 +1,16 @@
-package com.youlai.boot.framework.security.provider;
+package com.youlai.boot.auth.security.provider;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.hutool.core.util.ObjectUtil;
-import com.youlai.boot.framework.security.exception.NeedBindMobileException;
-import com.youlai.boot.framework.security.model.SysUserDetails;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
-import com.youlai.boot.framework.security.model.WxMaAuthenticationToken;
-import com.youlai.boot.framework.security.service.SysUserDetailsService;
+import com.youlai.boot.common.enums.SocialPlatformEnum;
+import com.youlai.boot.framework.security.model.SecurityUser;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
+import com.youlai.boot.framework.security.port.UserAuthenticationPort;
+import com.youlai.boot.auth.security.exception.MobileNotBoundException;
+import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.system.model.entity.UserSocial;
+import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException;
@@ -26,7 +28,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
public class WxMaAuthenticationProvider implements AuthenticationProvider {
private final WxMaService wxMaService;
- private final SysUserDetailsService sysUserDetailsService;
+ private final UserAuthenticationPort userAuthPort;
+ private final UserSocialService userSocialService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
@@ -38,44 +41,35 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider {
}
try {
- // 1. 用 code 换取 openid
WxMaJscode2SessionResult session = wxMaService.jsCode2SessionInfo(code);
String openid = session.getOpenid();
String sessionKey = session.getSessionKey();
log.info("微信小程序登录:openid={}", openid);
- // 2. 根据 openid 查询绑定信息
- UserSocial userSocial = sysUserDetailsService.getWechatMiniBindInfo(openid);
+ UserSocial userSocial = userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
if (userSocial == null) {
- // 未绑定,抛出异常提示需要绑定手机号
log.info("微信小程序登录:用户未绑定手机号,openid={}", openid);
- throw new NeedBindMobileException(openid, sessionKey);
+ throw new MobileNotBoundException(openid, sessionKey);
}
- // 3. 获取用户认证信息
- UserAuthInfo userAuthInfo = sysUserDetailsService.getAuthInfoByWechatOpenid(openid);
+ SecurityUser securityUser = userAuthPort.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
- if (userAuthInfo == null) {
+ if (securityUser == null) {
log.warn("微信小程序登录失败:用户不存在,openid={}", openid);
throw new UsernameNotFoundException("用户不存在");
}
- // 4. 检查用户状态
- if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
- log.warn("微信小程序登录失败:用户已禁用,username={}", userAuthInfo.getUsername());
+ if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) {
+ log.warn("微信小程序登录失败:用户已禁用,username={}", securityUser.getUsername());
throw new DisabledException("用户已被禁用");
}
- // 5. 更新 session_key
- sysUserDetailsService.updateWechatSessionKey(userSocial.getId(), sessionKey);
-
- // 6. 构建已认证 Token
- SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
-
- log.info("微信小程序登录成功:username={}, openid={}", userAuthInfo.getUsername(), openid);
+ userSocialService.updateSessionKey(userSocial.getId(), sessionKey);
+ SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
+ log.info("微信小程序登录成功:username={}, openid={}", securityUser.getUsername(), openid);
return WxMaAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
} catch (WxErrorException e) {
@@ -88,5 +82,4 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider {
public boolean supports(Class> authentication) {
return WxMaAuthenticationToken.class.isAssignableFrom(authentication);
}
-
}
diff --git a/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java
index 086caa17..a2bc3c44 100644
--- a/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java
+++ b/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java
@@ -6,8 +6,8 @@ import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.model.AuthenticationToken;
-import com.youlai.boot.framework.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
+import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
@@ -58,9 +58,9 @@ public class AuthServiceImpl implements AuthService {
// 2. 执行认证(认证中)
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
// 默认情况下会委托给 DaoAuthenticationProvider:
- // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SysUserDetailsService 实现)
+ // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现)
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
- // 认证通过后返回已认证的 Authentication(principal 为 SysUserDetails,authorities 为角色/权限集合)。
+ // 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
diff --git a/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java b/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java
index 9a6e0aa6..439cb329 100644
--- a/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java
+++ b/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java
@@ -8,12 +8,12 @@ import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.common.constant.RedisConstants;
-import com.youlai.boot.framework.security.exception.NeedBindMobileException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
-import com.youlai.boot.framework.security.model.SysUserDetails;
-import com.youlai.boot.framework.security.model.WxMaAuthenticationToken;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.TokenManager;
-import com.youlai.boot.system.enums.SocialPlatformEnum;
+import com.youlai.boot.auth.security.exception.MobileNotBoundException;
+import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
+import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
@@ -69,7 +69,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService {
.tokenType(authToken.getTokenType())
.expiresIn(authToken.getExpiresIn())
.build();
- } catch (NeedBindMobileException e) {
+ } catch (MobileNotBoundException e) {
return WxMaLoginVO.builder()
.isNewUser(true)
.needBindMobile(true)
@@ -234,7 +234,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService {
* 生成认证令牌
*/
private AuthenticationToken generateAuthToken(String mobile) {
- SysUserDetails userDetails = new SysUserDetails(userService.getAuthInfoByMobile(mobile));
+ SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile));
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
diff --git a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java
index 7e589f1a..e9746d53 100644
--- a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java
+++ b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java
@@ -11,7 +11,7 @@ import com.youlai.boot.codegen.model.form.GenConfigForm;
import com.youlai.boot.codegen.model.query.TableQuery;
import com.youlai.boot.codegen.model.vo.CodegenPreviewVO;
import com.youlai.boot.codegen.model.vo.TablePageVO;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.codegen.service.GenTableService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
diff --git a/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java b/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java
index 51f5f289..188bbd26 100644
--- a/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java
+++ b/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java
@@ -2,7 +2,7 @@ package com.youlai.boot.codegen.model.entity;
import com.baomidou.mybatisplus.annotation.*;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java b/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java
index a2640747..fdebd6d4 100644
--- a/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java
+++ b/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.codegen.enums.FormTypeEnum;
import com.youlai.boot.codegen.enums.QueryTypeEnum;
import lombok.Getter;
diff --git a/src/main/java/com/youlai/boot/common/annotation/RateLimit.java b/src/main/java/com/youlai/boot/common/annotation/RateLimit.java
deleted file mode 100644
index f53893bc..00000000
--- a/src/main/java/com/youlai/boot/common/annotation/RateLimit.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.youlai.boot.common.annotation;
-
-import java.lang.annotation.*;
-import java.util.concurrent.TimeUnit;
-
-/**
- * 接口限流
- * 标注在 Controller 方法上,基于 Redis 计数窗口实现
- *
- * @author Ray.Hao
- * @since 4.3.1
- */
-@Target(ElementType.METHOD)
-@Retention(RetentionPolicy.RUNTIME)
-@Documented
-public @interface RateLimit {
-
- int limit() default 0;
-
- int window() default 0;
-
- TimeUnit timeUnit() default TimeUnit.SECONDS;
-
- String prefix() default "rate_limit:";
-}
diff --git a/src/main/java/com/youlai/boot/common/base/BaseQuery.java b/src/main/java/com/youlai/boot/common/base/BaseQuery.java
index 7548937d..40b6a78e 100644
--- a/src/main/java/com/youlai/boot/common/base/BaseQuery.java
+++ b/src/main/java/com/youlai/boot/common/base/BaseQuery.java
@@ -1,6 +1,6 @@
package com.youlai.boot.common.base;
-import com.youlai.boot.common.annotation.ValidField;
+import com.youlai.boot.framework.annotation.ValidField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java
index 9aaadcda..61be1d98 100644
--- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java
+++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java
@@ -33,9 +33,9 @@ public interface JwtClaimConstants {
String DATA_SCOPES = "dataScopes";
/**
- * 权限(角色Code)集合
+ * 角色编码集合(不带 ROLE_ 前缀)
*/
- String AUTHORITIES = "authorities";
+ String ROLES = "roles";
/**
* Token 版本号
diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
index 035d6792..64c9f805 100644
--- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
+++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
@@ -11,12 +11,12 @@ public interface RedisConstants {
/**
* 限流相关键
*/
- interface RateLimiter {
- /** 接口级限流 Key(示例:login:rate_limit:token:/api/v1/auth/login) */
- String API = "{}rate_limit:{}:{}";
+ interface RateLimit {
+ /** 接口级限流 Key(示例:rate_limit:api:{user}:{uri}) */
+ String API = "rate_limit:{}:{}:{}";
- /** IP 全局限流 Key(示例:rate_limiter:ip:192.168.1.100) */
- String IP = "rate_limiter:ip:{}";
+ /** IP 全局限流 Key(示例:rate_limit:ip:192.168.1.100) */
+ String IP = "rate_limit:ip:{}";
}
/**
@@ -43,6 +43,8 @@ public interface RedisConstants {
String REVOKED_JTI = BLACKLIST_TOKEN;
// 用户 Token 版本号(用于按用户失效历史 JWT):token.tokenVersion != redis.tokenVersion => token 无效
String USER_TOKEN_VERSION = "auth:user:token_version:{}";
+ // 扫码登录票据上下文(ticket -> QrCodeLoginContext)
+ String QR_CODE_LOGIN = "auth:qr_code:{}";
}
/**
diff --git a/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java b/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java
similarity index 94%
rename from src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java
rename to src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java
index 0779f594..c6e85c13 100644
--- a/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java
+++ b/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.system.enums;
+package com.youlai.boot.common.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.youlai.boot.common.base.IBaseEnum;
diff --git a/src/main/java/com/youlai/boot/common/result/ResultCode.java b/src/main/java/com/youlai/boot/common/result/ResultCode.java
index af2ebb8a..31169976 100644
--- a/src/main/java/com/youlai/boot/common/result/ResultCode.java
+++ b/src/main/java/com/youlai/boot/common/result/ResultCode.java
@@ -80,6 +80,12 @@ public enum ResultCode implements IResultCode, Serializable {
USER_VERIFICATION_CODE_ATTEMPT_LIMIT_EXCEEDED("A0241", "用户验证码尝试次数超限"),
USER_VERIFICATION_CODE_EXPIRED("A0242", "用户验证码过期"),
+ /** A025x:扫码登录异常 */
+ QR_CODE_NOT_FOUND("A0250", "扫码登录票据不存在或已过期"),
+ QR_CODE_STATUS_ILLEGAL("A0251", "当前扫码状态不允许该操作"),
+ QR_CODE_USER_MISMATCH("A0252", "操作用户与扫码用户不一致"),
+ QR_CODE_ALREADY_USED("A0253", "扫码登录票据已被使用"),
+
/** A03xx:访问权限异常 */
ACCESS_PERMISSION_EXCEPTION("A0300", "访问权限异常"),
ACCESS_UNAUTHORIZED("A0301", "访问未授权"),
diff --git a/src/main/java/com/youlai/boot/common/validator/FieldValidator.java b/src/main/java/com/youlai/boot/common/validator/FieldValidator.java
index 200e67c8..9659627f 100644
--- a/src/main/java/com/youlai/boot/common/validator/FieldValidator.java
+++ b/src/main/java/com/youlai/boot/common/validator/FieldValidator.java
@@ -1,6 +1,6 @@
package com.youlai.boot.common.validator;
-import com.youlai.boot.common.annotation.ValidField;
+import com.youlai.boot.framework.annotation.ValidField;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
diff --git a/src/main/java/com/youlai/boot/device/controller/ContactController.java b/src/main/java/com/youlai/boot/device/controller/ContactController.java
index 7b282250..4956421f 100644
--- a/src/main/java/com/youlai/boot/device/controller/ContactController.java
+++ b/src/main/java/com/youlai/boot/device/controller/ContactController.java
@@ -1,6 +1,6 @@
package com.youlai.boot.device.controller;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceController.java b/src/main/java/com/youlai/boot/device/controller/DeviceController.java
index 48c27d2c..3d2db2fe 100644
--- a/src/main/java/com/youlai/boot/device/controller/DeviceController.java
+++ b/src/main/java/com/youlai/boot/device/controller/DeviceController.java
@@ -1,6 +1,6 @@
package com.youlai.boot.device.controller;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.PageResult;
diff --git a/src/main/java/com/youlai/boot/device/controller/MobileController.java b/src/main/java/com/youlai/boot/device/controller/MobileController.java
index 775eb735..e35ad4fd 100644
--- a/src/main/java/com/youlai/boot/device/controller/MobileController.java
+++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java
@@ -1,7 +1,7 @@
package com.youlai.boot.device.controller;
import cn.hutool.core.util.IdUtil;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.config.FilePath;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
diff --git a/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java b/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java
index 3db11714..10c79257 100644
--- a/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java
+++ b/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java
@@ -2,7 +2,7 @@ package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.youlai.boot.common.annotation.DataPermission;
+import com.youlai.boot.framework.annotation.DataPermission;
import com.youlai.boot.device.model.entity.SnDeviceInfo;
import com.youlai.boot.device.model.query.DeviceQuery;
import com.youlai.boot.device.model.vo.DevicePageVO;
diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnContact.java b/src/main/java/com/youlai/boot/device/model/entity/SnContact.java
index 7e17502b..142b4419 100644
--- a/src/main/java/com/youlai/boot/device/model/entity/SnContact.java
+++ b/src/main/java/com/youlai/boot/device/model/entity/SnContact.java
@@ -2,7 +2,7 @@ package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java
index 034bbdb3..123c3e92 100644
--- a/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java
+++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java
@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java b/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java
index d756f82e..f56e4743 100644
--- a/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java
+++ b/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java
@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java
index d3264da6..5e99d235 100644
--- a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java
+++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java
@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java
index b3fb2a45..fe3ac2aa 100644
--- a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java
+++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java
@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java b/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java
index d6a09a61..6f8ffc25 100644
--- a/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java
+++ b/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java
@@ -2,7 +2,7 @@ package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java b/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java
index a9b68324..114fa8c1 100644
--- a/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java
+++ b/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java
@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/file/config/AliyunOssProperties.java b/src/main/java/com/youlai/boot/file/config/AliyunOssProperties.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java b/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java
new file mode 100644
index 00000000..e00f199e
--- /dev/null
+++ b/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java
@@ -0,0 +1,52 @@
+package com.youlai.boot.file.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 文件存储顶层配置:存储类型与上传限制。
+ *
+ * minio / aliyun / local 子树由各自 {@code FileService} 实现类绑定,
+ * 此处仅承载 {@code type} 与 {@code upload}。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "file-storage")
+public class FileStorageProperties {
+
+ /** 存储类型:minio | aliyun | local */
+ private String type;
+
+ private Upload upload = new Upload();
+
+ @Data
+ public static class Upload {
+
+ /** 单文件大小上限,如 50MB;同时作为 spring.servlet.multipart 的上限 */
+ private String maxFileSize;
+
+ /** 允许的文件扩展名白名单(置空表示不限制) */
+ private List allowedExtensions;
+ }
+
+ /**
+ * 允许扩展名集合(小写、不含点);空集合表示不限制。
+ */
+ public Set getAllowedExtensions() {
+ if (upload == null || upload.allowedExtensions == null) {
+ return Collections.emptySet();
+ }
+ return upload.allowedExtensions.stream()
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .map(s -> s.startsWith(".") ? s.substring(1) : s)
+ .map(String::toLowerCase)
+ .collect(Collectors.toUnmodifiableSet());
+ }
+}
diff --git a/src/main/java/com/youlai/boot/file/config/LocalFileProperties.java b/src/main/java/com/youlai/boot/file/config/LocalFileProperties.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/file/config/MinioProperties.java b/src/main/java/com/youlai/boot/file/config/MinioProperties.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/file/controller/FileController.java b/src/main/java/com/youlai/boot/file/controller/FileController.java
index 0975f8c7..154d981b 100644
--- a/src/main/java/com/youlai/boot/file/controller/FileController.java
+++ b/src/main/java/com/youlai/boot/file/controller/FileController.java
@@ -1,8 +1,13 @@
package com.youlai.boot.file.controller;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.util.StrUtil;
+import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.Result;
-import com.youlai.boot.file.service.FileService;
+import com.youlai.boot.common.result.ResultCode;
+import com.youlai.boot.file.config.FileStorageProperties;
import com.youlai.boot.file.model.FileInfo;
+import com.youlai.boot.file.service.FileService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -13,6 +18,8 @@ import lombok.SneakyThrows;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
+import java.util.Set;
+
/**
* 文件控制层
*
@@ -26,6 +33,7 @@ import org.springframework.web.multipart.MultipartFile;
public class FileController {
private final FileService fileService;
+ private final FileStorageProperties fileStorageProperties;
@PostMapping
@Operation(summary = "文件上传")
@@ -39,6 +47,7 @@ public class FileController {
)
@RequestPart(value = "file") MultipartFile file
) {
+ validateFileExtension(file);
FileInfo fileInfo = fileService.uploadFile(file);
return Result.success(fileInfo);
}
@@ -52,4 +61,19 @@ public class FileController {
boolean result = fileService.deleteFile(filePath);
return Result.judge(result);
}
+
+ /**
+ * 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制。
+ */
+ private void validateFileExtension(MultipartFile file) {
+ Set allowedExtensions = fileStorageProperties.getAllowedExtensions();
+ if (allowedExtensions.isEmpty()) {
+ return;
+ }
+ String suffix = FileUtil.getSuffix(file.getOriginalFilename());
+ if (StrUtil.isBlank(suffix) || !allowedExtensions.contains(suffix.toLowerCase())) {
+ throw new BusinessException(ResultCode.UPLOAD_FILE_EXCEPTION,
+ "不支持的文件类型: " + (StrUtil.isBlank(suffix) ? "" : "." + suffix));
+ }
+ }
}
diff --git a/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java
index 55b6ee90..6d4e93e0 100644
--- a/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java
+++ b/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java
@@ -23,8 +23,8 @@ import java.io.InputStream;
import java.time.LocalDateTime;
@Component
-@ConditionalOnProperty(value = "oss.type", havingValue = "aliyun")
-@ConfigurationProperties(prefix = "oss.aliyun")
+@ConditionalOnProperty(value = "file-storage.type", havingValue = "aliyun")
+@ConfigurationProperties(prefix = "file-storage.aliyun")
@RequiredArgsConstructor
@Data
public class AliyunFileServiceImpl implements FileService {
@@ -32,7 +32,7 @@ public class AliyunFileServiceImpl implements FileService {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
- private String bucketName;
+ private String bucket;
private OSS aliyunOssClient;
@@ -51,12 +51,12 @@ public class AliyunFileServiceImpl implements FileService {
try (InputStream inputStream = file.getInputStream()) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
- PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileName, inputStream, metadata);
+ PutObjectRequest putObjectRequest = new PutObjectRequest(bucket, fileName, inputStream, metadata);
aliyunOssClient.putObject(putObjectRequest);
} catch (Exception e) {
throw new RuntimeException("文件上传失败");
}
- String fileUrl = "https://" + bucketName + "." + endpoint + "/" + fileName;
+ String fileUrl = "https://" + bucket + "." + endpoint + "/" + fileName;
FileInfo fileInfo = new FileInfo();
fileInfo.setName(originalFilename);
fileInfo.setUrl(fileUrl);
@@ -66,9 +66,9 @@ public class AliyunFileServiceImpl implements FileService {
@Override
public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空");
- String fileHost = "https://" + bucketName + "." + endpoint;
+ String fileHost = "https://" + bucket + "." + endpoint;
String fileName = filePath.substring(fileHost.length() + 1);
- aliyunOssClient.deleteObject(bucketName, fileName);
+ aliyunOssClient.deleteObject(bucket, fileName);
return true;
}
}
diff --git a/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java
index 555408e6..4905625d 100644
--- a/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java
+++ b/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java
@@ -22,13 +22,13 @@ import java.time.LocalDateTime;
@Data
@Slf4j
@Component
-@ConditionalOnProperty(value = "oss.type", havingValue = "local")
-@ConfigurationProperties(prefix = "oss.local")
+@ConditionalOnProperty(value = "file-storage.type", havingValue = "local")
+@ConfigurationProperties(prefix = "file-storage.local")
@RequiredArgsConstructor
public class LocalFileServiceImpl implements FileService {
- @Value("${oss.local.storage-path}")
- private String storagePath;
+ @Value("${file-storage.local.path}")
+ private String path;
@Override
public FileInfo uploadFile(MultipartFile file) {
@@ -36,7 +36,7 @@ public class LocalFileServiceImpl implements FileService {
String suffix = FileUtil.getSuffix(originalFilename);
String fileName = IdUtil.simpleUUID()+ "." + suffix;;
String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
- String filePrefix = storagePath.endsWith(File.separator) ? storagePath : storagePath + File.separator;
+ String filePrefix = path.endsWith(File.separator) ? path : path + File.separator;
try (InputStream inputStream = file.getInputStream()) {
FileUtil.writeFromStream(inputStream, filePrefix + folder + File.separator + fileName);
} catch (Exception e) {
@@ -55,9 +55,9 @@ public class LocalFileServiceImpl implements FileService {
if (filePath == null || filePath.isEmpty()) {
return false;
}
- if (FileUtil.isDirectory(storagePath + filePath)) {
+ if (FileUtil.isDirectory(path + filePath)) {
return false;
}
- return FileUtil.del(storagePath + filePath);
+ return FileUtil.del(path + filePath);
}
}
diff --git a/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java
index 3a0d6caa..b69a2acb 100644
--- a/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java
+++ b/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java
@@ -25,8 +25,8 @@ import java.io.InputStream;
import java.time.LocalDateTime;
@Component
-@ConditionalOnProperty(value = "oss.type", havingValue = "minio")
-@ConfigurationProperties(prefix = "oss.minio")
+@ConditionalOnProperty(value = "file-storage.type", havingValue = "minio")
+@ConfigurationProperties(prefix = "file-storage.minio")
@RequiredArgsConstructor
@Data
@Slf4j
@@ -35,8 +35,8 @@ public class MinioFileServiceImpl implements FileService {
private String endpoint;
private String accessKey;
private String secretKey;
- private String bucketName;
- private String customDomain;
+ private String bucket;
+ private String domain;
private MinioClient minioClient;
@@ -50,7 +50,7 @@ public class MinioFileServiceImpl implements FileService {
@Override
public FileInfo uploadFile(MultipartFile file) {
- createBucketIfAbsent(bucketName);
+ createBucketIfAbsent(bucket);
String originalFilename = file.getOriginalFilename();
String suffix = FileUtil.getSuffix(originalFilename);
String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd");
@@ -58,7 +58,7 @@ public class MinioFileServiceImpl implements FileService {
try (InputStream inputStream = file.getInputStream()) {
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
- .bucket(bucketName)
+ .bucket(bucket)
.object(dateFolder + "/"+ fileName)
.contentType(file.getContentType())
.stream(inputStream, inputStream.available(), -1)
@@ -66,16 +66,16 @@ public class MinioFileServiceImpl implements FileService {
minioClient.putObject(putObjectArgs);
String fileUrl;
- if (StrUtil.isBlank(customDomain)) {
+ if (StrUtil.isBlank(domain)) {
GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
- .bucket(bucketName)
+ .bucket(bucket)
.object(dateFolder + "/"+ fileName)
.method(Method.GET)
.build();
fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
fileUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
} else {
- fileUrl = customDomain + "/"+ bucketName + "/"+ dateFolder + "/"+ fileName;
+ fileUrl = domain + "/"+ bucket + "/"+ dateFolder + "/"+ fileName;
}
FileInfo fileInfo = new FileInfo();
@@ -93,13 +93,13 @@ public class MinioFileServiceImpl implements FileService {
Assert.notBlank(filePath, "删除文件路径不能为空");
try {
String fileName;
- if (StrUtil.isNotBlank(customDomain)) {
- fileName = filePath.substring(customDomain.length() + 1 + bucketName.length() + 1);
+ if (StrUtil.isNotBlank(domain)) {
+ fileName = filePath.substring(domain.length() + 1 + bucket.length() + 1);
} else {
- fileName = filePath.substring(endpoint.length() + 1 + bucketName.length() + 1);
+ fileName = filePath.substring(endpoint.length() + 1 + bucket.length() + 1);
}
minioClient.removeObject(RemoveObjectArgs.builder()
- .bucket(bucketName).object(fileName).build());
+ .bucket(bucket).object(fileName).build());
return true;
} catch (Exception e) {
log.error("删除文件失败", e);
@@ -107,23 +107,23 @@ public class MinioFileServiceImpl implements FileService {
}
}
- private static String publicBucketPolicy(String bucketName) {
+ private static String publicBucketPolicy(String bucket) {
return "{\"Version\":\"2012-10-17\","
+ "\"Statement\":[{\"Effect\":\"Allow\","
+ "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"],"
- + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},"
+ + "\"Resource\":[\"arn:aws:s3:::" + bucket + "\"]},"
+ "{\"Effect\":\"Allow\"," + "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"],"
- + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}";
+ + "\"Resource\":[\"arn:aws:s3:::" + bucket + "/*\"]}]}";
}
@SneakyThrows
- private void createBucketIfAbsent(String bucketName) {
- if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) {
- minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
+ private void createBucketIfAbsent(String bucket) {
+ if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
+ minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder()
- .bucket(bucketName).config(publicBucketPolicy(bucketName)).build());
+ .bucket(bucket).config(publicBucketPolicy(bucket)).build());
}
}
}
diff --git a/src/main/java/com/youlai/boot/common/annotation/DataPermission.java b/src/main/java/com/youlai/boot/framework/annotation/DataPermission.java
similarity index 91%
rename from src/main/java/com/youlai/boot/common/annotation/DataPermission.java
rename to src/main/java/com/youlai/boot/framework/annotation/DataPermission.java
index f63832c8..d3fd0b4c 100644
--- a/src/main/java/com/youlai/boot/common/annotation/DataPermission.java
+++ b/src/main/java/com/youlai/boot/framework/annotation/DataPermission.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.common.annotation;
+package com.youlai.boot.framework.annotation;
import java.lang.annotation.*;
@@ -25,4 +25,3 @@ public @interface DataPermission {
String userIdColumnName() default "create_by";
}
-
diff --git a/src/main/java/com/youlai/boot/common/annotation/Log.java b/src/main/java/com/youlai/boot/framework/annotation/Log.java
similarity index 95%
rename from src/main/java/com/youlai/boot/common/annotation/Log.java
rename to src/main/java/com/youlai/boot/framework/annotation/Log.java
index c02d5608..16e9ec06 100644
--- a/src/main/java/com/youlai/boot/common/annotation/Log.java
+++ b/src/main/java/com/youlai/boot/framework/annotation/Log.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.common.annotation;
+package com.youlai.boot.framework.annotation;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
diff --git a/src/main/java/com/youlai/boot/framework/annotation/RateLimit.java b/src/main/java/com/youlai/boot/framework/annotation/RateLimit.java
new file mode 100644
index 00000000..1605d977
--- /dev/null
+++ b/src/main/java/com/youlai/boot/framework/annotation/RateLimit.java
@@ -0,0 +1,30 @@
+package com.youlai.boot.framework.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * 接口限流
+ * 标注在 Controller 方法上,基于 Redis 滑动窗口实现
+ *
+ * @author Ray.Hao
+ * @since 4.3.1
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface RateLimit {
+
+ /**
+ * 窗口内允许的最大请求数。
+ *
+ * {@code <=0} 时使用全局默认值 {@code rate-limit.default-limit}。
+ */
+ int limit() default 0;
+
+ /**
+ * 滑动窗口大小(秒)。
+ *
+ * {@code <=0} 时使用全局默认值 {@code rate-limit.default-window}。
+ */
+ int window() default 0;
+}
diff --git a/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java b/src/main/java/com/youlai/boot/framework/annotation/RepeatSubmit.java
similarity index 90%
rename from src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java
rename to src/main/java/com/youlai/boot/framework/annotation/RepeatSubmit.java
index 053f1320..f489a982 100644
--- a/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java
+++ b/src/main/java/com/youlai/boot/framework/annotation/RepeatSubmit.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.common.annotation;
+package com.youlai.boot.framework.annotation;
import java.lang.annotation.*;
diff --git a/src/main/java/com/youlai/boot/common/annotation/ValidField.java b/src/main/java/com/youlai/boot/framework/annotation/ValidField.java
similarity index 93%
rename from src/main/java/com/youlai/boot/common/annotation/ValidField.java
rename to src/main/java/com/youlai/boot/framework/annotation/ValidField.java
index 559b06e1..b7be969d 100644
--- a/src/main/java/com/youlai/boot/common/annotation/ValidField.java
+++ b/src/main/java/com/youlai/boot/framework/annotation/ValidField.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.common.annotation;
+package com.youlai.boot.framework.annotation;
import com.youlai.boot.common.validator.FieldValidator;
import jakarta.validation.Constraint;
diff --git a/src/main/java/com/youlai/boot/common/base/BaseEntity.java b/src/main/java/com/youlai/boot/framework/base/BaseEntity.java
similarity index 76%
rename from src/main/java/com/youlai/boot/common/base/BaseEntity.java
rename to src/main/java/com/youlai/boot/framework/base/BaseEntity.java
index 70cd1a92..7b3fb9b5 100644
--- a/src/main/java/com/youlai/boot/common/base/BaseEntity.java
+++ b/src/main/java/com/youlai/boot/framework/base/BaseEntity.java
@@ -1,12 +1,11 @@
-package com.youlai.boot.common.base;
+package com.youlai.boot.framework.base;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
-import com.fasterxml.jackson.annotation.JsonFormat;
-import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
+import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serial;
import java.io.Serializable;
@@ -15,7 +14,8 @@ import java.time.LocalDateTime;
/**
* 基础实体类
*
- *
实体类的基类,包含了实体类的公共属性,如创建时间、更新时间、逻辑删除标识等
+ * 包含主键和创建/更新时间,适用于所有实体(含关联表)
+ * 审计字段(createBy/updateBy)和逻辑删除字段(isDeleted)由需要的实体自行声明
*
* @author Ray.Hao
* @since 4.3.1
@@ -37,7 +37,6 @@ public class BaseEntity implements Serializable {
*/
@TableField(fill = FieldFill.INSERT)
@JsonInclude(value = JsonInclude.Include.NON_NULL)
- @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/**
@@ -45,7 +44,6 @@ public class BaseEntity implements Serializable {
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@JsonInclude(value = JsonInclude.Include.NON_NULL)
- @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}
diff --git a/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java b/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java
index 610380d9..8014f69f 100644
--- a/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java
+++ b/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java
@@ -24,12 +24,10 @@ public class RedisConfig {
* 修改 Redis 序列化方式,默认 JdkSerializationRedisSerializer
*
* @param redisConnectionFactory {@link RedisConnectionFactory}
- * @param jsonMapper Jackson 序列化器(统一使用不写入类型信息的配置)
* @return {@link RedisTemplate}
*/
@Bean
- public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory,
- JsonMapper jsonMapper) {
+ public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
@@ -38,7 +36,9 @@ public class RedisConfig {
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
- // Value 使用自定义 JSON 序列化(不写入类型信息,避免 HashSet 等集合被序列化成带 @class 的结构)
+ JsonMapper jsonMapper = JsonMapper.builder()
+ .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
+ .build();
JacksonJsonRedisSerializer jsonSerializer = new JacksonJsonRedisSerializer<>(jsonMapper, Object.class);
redisTemplate.setValueSerializer(jsonSerializer);
@@ -48,17 +48,4 @@ public class RedisConfig {
return redisTemplate;
}
- /**
- * 统一的 JsonMapper Bean
- *
- * 禁止将日期序列化为时间戳;不写入类型信息,保证 Redis 存储的 JSON 纯净可读。
- * 需要反序列化到特定类型时,调用方应使用 {@link JsonMapper#convertValue(Object, Class)} 显式转换。
- */
- @Bean
- public JsonMapper jsonMapper() {
- return JsonMapper.builder()
- .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
- .build();
- }
-
}
diff --git a/src/main/java/com/youlai/boot/framework/mybatis/exception/DataPermissionException.java b/src/main/java/com/youlai/boot/framework/mybatis/exception/DataPermissionException.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/framework/mybatis/handler/AutoFillMetaObjectHandler.java b/src/main/java/com/youlai/boot/framework/mybatis/handler/AutoFillMetaObjectHandler.java
index f5e9d893..80d260e6 100644
--- a/src/main/java/com/youlai/boot/framework/mybatis/handler/AutoFillMetaObjectHandler.java
+++ b/src/main/java/com/youlai/boot/framework/mybatis/handler/AutoFillMetaObjectHandler.java
@@ -1,6 +1,7 @@
package com.youlai.boot.framework.mybatis.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
+import com.youlai.boot.framework.security.util.SecurityUtils;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
@@ -9,6 +10,9 @@ import java.time.LocalDateTime;
/**
* MyBatis-Plus 字段自动填充
*
+ *
自动填充 createTime/updateTime(时间戳)和 createBy/updateBy(操作人ID)
+ * 实体字段需标注 {@code @TableField(fill = FieldFill.INSERT)} 或 {@code FieldFill.INSERT_UPDATE} 才会触发
+ *
* @author Ray.Hao
* @since 3.0.0
*/
@@ -18,11 +22,14 @@ public class AutoFillMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
+ this.strictInsertFill(metaObject, "createBy", SecurityUtils::getUserId, Long.class);
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
+ this.strictUpdateFill(metaObject, "updateBy", SecurityUtils::getUserId, Long.class);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
+ this.strictUpdateFill(metaObject, "updateBy", SecurityUtils::getUserId, Long.class);
}
}
diff --git a/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java b/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java
index 7dd4e003..74d6ad33 100644
--- a/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java
+++ b/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java
@@ -4,10 +4,10 @@ import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler;
-import com.youlai.boot.common.annotation.DataPermission;
+import com.youlai.boot.framework.annotation.DataPermission;
import com.youlai.boot.common.enums.DataScopeEnum;
import com.youlai.boot.framework.security.model.RoleDataScope;
-import com.youlai.boot.framework.security.model.SysUserDetails;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.util.SecurityUtils;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
@@ -58,7 +58,7 @@ public class MyDataPermissionHandler implements DataPermissionHandler {
// 获取当前用户的数据权限列表
List dataScopes = SecurityUtils.getUser()
- .map(SysUserDetails::getDataScopes)
+ .map(SecurityUserDetails::getDataScopes)
.orElse(List.of());
// 如果任一角色是 ALL,则跳过数据权限过滤(并集策略)
diff --git a/src/main/java/com/youlai/boot/framework/apidoc/Knife4jOpenApiCustomizer.java b/src/main/java/com/youlai/boot/framework/openapi/Knife4jOpenApiCustomizer.java
similarity index 100%
rename from src/main/java/com/youlai/boot/framework/apidoc/Knife4jOpenApiCustomizer.java
rename to src/main/java/com/youlai/boot/framework/openapi/Knife4jOpenApiCustomizer.java
diff --git a/src/main/java/com/youlai/boot/framework/apidoc/OpenApiConfig.java b/src/main/java/com/youlai/boot/framework/openapi/OpenApiConfig.java
similarity index 100%
rename from src/main/java/com/youlai/boot/framework/apidoc/OpenApiConfig.java
rename to src/main/java/com/youlai/boot/framework/openapi/OpenApiConfig.java
diff --git a/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java b/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java
index 4aad3db0..c4298f35 100644
--- a/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java
+++ b/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java
@@ -2,78 +2,72 @@ package com.youlai.boot.framework.security.filter;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.SecurityConstants;
-import com.youlai.boot.common.result.ResultCode;
-import com.youlai.boot.framework.web.util.ResponseWriter;
import com.youlai.boot.framework.security.token.TokenManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
+import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
- * Token 认证校验过滤器
+ * Token 认证过滤器。
+ *
+ * 仅负责解析 Token 和填充 {@link SecurityContextHolder}。
+ * 无效 Token 抛出 {@link AuthenticationException},由使用方的
+ * {@code AuthenticationEntryPoint} 统一处理响应格式。
+ *
+ * 必须注册在 {@code ExceptionTranslationFilter} 之后(即 {@code AuthorizationFilter} 之前),
+ * 这样抛出的异常才能被 {@code ExceptionTranslationFilter} 捕获。
*
* @author wangtao
- * @since 2025/3/6 16:50
+ * @since 2025/3/6
*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {
- /**
- * Token 管理器
- */
private final TokenManager tokenManager;
public TokenAuthenticationFilter(TokenManager tokenManager) {
this.tokenManager = tokenManager;
}
- /**
- * 校验 Token ,包括验签和是否过期
- * 如果 Token 有效,将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中
- */
@Override
- protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
String rawToken = resolveToken(request);
- try {
- if (StrUtil.isNotBlank(rawToken)) {
- // 执行令牌有效性检查(包含密码学验签和过期时间验证)
- boolean isValidToken = tokenManager.validateToken(rawToken);
- if (!isValidToken) {
- ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
- return;
+ if (StrUtil.isNotBlank(rawToken)) {
+ try {
+ boolean isValid = tokenManager.validateToken(rawToken);
+ if (!isValid) {
+ SecurityContextHolder.clearContext();
+ throw new InsufficientAuthenticationException("Token 无效或已过期");
}
-
- // 将令牌解析为 Spring Security 上下文认证对象
Authentication authentication = tokenManager.parseToken(rawToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
+ } catch (AuthenticationException ex) {
+ SecurityContextHolder.clearContext();
+ throw ex;
+ } catch (Exception ex) {
+ SecurityContextHolder.clearContext();
+ throw new InsufficientAuthenticationException("Token 认证失败", ex);
}
- } catch (Exception ex) {
- // 安全上下文清除保障(防止上下文残留)
- SecurityContextHolder.clearContext();
- ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
- return;
}
- // 继续后续过滤器链执行
filterChain.doFilter(request, response);
}
- /**
- * 从请求中解析 Token(仅支持 Authorization Header)
- */
private String resolveToken(HttpServletRequest request) {
- String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
- if (StrUtil.isNotBlank(authorizationHeader)
- && authorizationHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
- return authorizationHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
+ String header = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (StrUtil.isNotBlank(header) && header.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
+ return header.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
}
return null;
}
diff --git a/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java
similarity index 73%
rename from src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java
rename to src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java
index e6b17d21..96ee67ef 100644
--- a/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java
+++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java
@@ -6,16 +6,16 @@ import java.util.List;
import java.util.Set;
/**
- * 用户认证信息
+ * 安全模块用户数据 POJO。
*
- * 用于登录认证过程中的用户信息承载,包含用户名、密码、状态、角色等与认证/授权相关的数据。
- *
+ * 作为端口接口的返回类型,承载用户认证所需的全部数据。
+ * 纯 JDK 类型,无 system 模块依赖,可直接序列化。
*
* @author Ray.Hao
* @since 2025/12/16
*/
@Data
-public class UserAuthInfo {
+public class SecurityUser {
/**
* 用户ID
@@ -48,7 +48,12 @@ public class UserAuthInfo {
private Integer status;
/**
- * 角色集合
+ * 头像 URL
+ */
+ private String avatar;
+
+ /**
+ * 角色编码集合
*/
private Set roles;
diff --git a/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java
similarity index 65%
rename from src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java
rename to src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java
index 2e733b11..eeecd5f3 100644
--- a/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java
+++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java
@@ -13,17 +13,18 @@ import java.util.*;
import java.util.stream.Collectors;
/**
- * Spring Security 用户认证对象
+ * Spring Security 用户认证对象。
*
- * 封装了用户的基本信息和权限信息,供 Spring Security 进行用户认证与授权。
- * 实现了 {@link UserDetails} 接口,提供用户的核心信息。
+ * 实现 {@link UserDetails},封装用户标识、角色、数据权限等认证信息。
+ * {@link #roles} 字段存储角色编码(不带 ROLE_ 前缀),
+ * {@link #getAuthorities()} 运行时补前缀并转为 {@link SimpleGrantedAuthority}。
*
* @author Ray.Hao
* @version 3.0.0
*/
@Data
@NoArgsConstructor
-public class SysUserDetails implements UserDetails {
+public class SecurityUserDetails implements UserDetails {
/**
* 用户ID
@@ -52,42 +53,35 @@ public class SysUserDetails implements UserDetails {
/**
* 数据权限列表
- *
- * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
*/
private List dataScopes;
/**
- * 用户角色权限集合
+ * 角色编码集合(不带 ROLE_ 前缀)
*/
- private Collection authorities;
+ private Set roles;
/**
- * 构造函数:根据用户认证信息初始化用户详情对象
- *
- * @param user 用户认证信息对象 {@link UserAuthInfo}
+ * 构造函数:根据 {@link SecurityUser} 初始化。
*/
- public SysUserDetails(UserAuthInfo user) {
+ public SecurityUserDetails(SecurityUser user) {
this.userId = user.getUserId();
this.username = user.getUsername();
this.password = user.getPassword();
this.enabled = ObjectUtil.equal(user.getStatus(), 1);
this.deptId = user.getDeptId();
this.dataScopes = user.getDataScopes();
-
- // 初始化角色权限集合
- this.authorities = CollectionUtil.isNotEmpty(user.getRoles())
- ? user.getRoles().stream()
- // 角色名加上前缀 "ROLE_",用于区分角色 (ROLE_ADMIN) 和权限 (user:add)
- .map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role))
- .collect(Collectors.toSet())
- : Collections.emptySet();
+ this.roles = user.getRoles();
}
-
@Override
public Collection extends GrantedAuthority> getAuthorities() {
- return this.authorities;
+ if (CollectionUtil.isEmpty(roles)) {
+ return Collections.emptySet();
+ }
+ return roles.stream()
+ .map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role))
+ .collect(Collectors.toSet());
}
@Override
@@ -107,8 +101,6 @@ public class SysUserDetails implements UserDetails {
/**
* 判断是否包含"全部数据"权限
- *
- * @return 是否有全部数据权限
*/
public boolean hasAllDataScope() {
if (CollectionUtil.isEmpty(dataScopes)) {
@@ -119,9 +111,7 @@ public class SysUserDetails implements UserDetails {
}
/**
- * 获取数据权限列表
- *
- * @return 数据权限列表,永不为null
+ * 获取数据权限列表,永不为 null
*/
public List getDataScopes() {
return dataScopes != null ? dataScopes : Collections.emptyList();
diff --git a/src/main/java/com/youlai/boot/framework/security/model/UserSession.java b/src/main/java/com/youlai/boot/framework/security/model/UserSession.java
deleted file mode 100644
index a798a4c5..00000000
--- a/src/main/java/com/youlai/boot/framework/security/model/UserSession.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.youlai.boot.framework.security.model;
-
-import lombok.AllArgsConstructor;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.util.List;
-import java.util.Set;
-
-/**
- * 用户会话信息
- *
- * 存储在Token中的用户会话快照,包含用户身份、数据权限和角色权限信息。
- * 用于Redis-Token模式下的会话管理,支持在线用户查询和会话控制。
- *
- * @author wangtao
- * @since 2025/2/27 10:31
- */
-@Data
-@NoArgsConstructor
-@AllArgsConstructor
-public class UserSession {
-
- /**
- * 用户ID
- */
- private Long userId;
-
- /**
- * 用户名
- */
- private String username;
-
- /**
- * 部门ID
- */
- private Long deptId;
-
- /**
- * 数据权限列表
- */
- private List dataScopes;
-
- /**
- * 角色权限集合
- */
- private Set roles;
-
-}
diff --git a/src/main/java/com/youlai/boot/framework/security/model/WxMaBindInfo.java b/src/main/java/com/youlai/boot/framework/security/model/WxMaBindInfo.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java b/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java
new file mode 100644
index 00000000..9417eded
--- /dev/null
+++ b/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java
@@ -0,0 +1,22 @@
+package com.youlai.boot.framework.security.port;
+
+import java.util.Set;
+
+/**
+ * 权限查询端口。
+ *
+ * 由 system 模块提供适配器实现,framework 层通过此接口获取角色权限集合,
+ * 不直接依赖 system 模块的 {@code RoleMenuService}。
+ *
+ * @see com.youlai.boot.system.security.adapter.PermissionAdapter
+ */
+public interface PermissionPort {
+
+ /**
+ * 根据角色编码集合查询权限标识集合。
+ *
+ * @param roleCodes 角色编码集合
+ * @return 权限标识集合,如 "sys:user:create"
+ */
+ Set getRolePerms(Set roleCodes);
+}
diff --git a/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java b/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java
new file mode 100644
index 00000000..82acea2c
--- /dev/null
+++ b/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java
@@ -0,0 +1,40 @@
+package com.youlai.boot.framework.security.port;
+
+import com.youlai.boot.common.enums.SocialPlatformEnum;
+import com.youlai.boot.framework.security.model.SecurityUser;
+
+/**
+ * 用户认证信息查询端口。
+ *
+ * 由 system 模块提供适配器实现,framework 层通过此接口获取认证数据,
+ * 不直接依赖 system 模块的 {@code UserService} / {@code UserSocialService}。
+ *
+ * @see com.youlai.boot.system.security.adapter.UserAuthenticationAdapter
+ */
+public interface UserAuthenticationPort {
+
+ /**
+ * 根据用户名查询认证信息。
+ *
+ * @param username 用户名
+ * @return 认证信息,不存在返回 null
+ */
+ SecurityUser getAuthInfoByUsername(String username);
+
+ /**
+ * 根据手机号查询认证信息。
+ *
+ * @param mobile 手机号
+ * @return 认证信息,不存在返回 null
+ */
+ SecurityUser getAuthInfoByMobile(String mobile);
+
+ /**
+ * 根据第三方平台 openid 查询认证信息。
+ *
+ * @param platform 第三方平台
+ * @param openid openid
+ * @return 认证信息,未绑定返回 null
+ */
+ SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
+}
diff --git a/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java
index 46516f3c..ef7cc446 100644
--- a/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java
+++ b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java
@@ -2,8 +2,8 @@ package com.youlai.boot.framework.security.service;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
+import com.youlai.boot.framework.security.port.PermissionPort;
import com.youlai.boot.framework.security.util.SecurityUtils;
-import com.youlai.boot.system.service.RoleMenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -12,11 +12,10 @@ import org.springframework.util.PatternMatchUtils;
import java.util.Set;
/**
- * Spring Security 权限校验组件
+ * Spring Security 权限校验组件。
*
- * 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:create')")
- *
- * 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存)
+ * 用于 SpEL 表达式:{@code @PreAuthorize("@ss.hasPerm('sys:user:create')")}。
+ * 通过 {@link PermissionPort} 查询角色权限,不直接依赖 system 模块。
*
* @author Ray.Hao
* @since 0.0.1
@@ -26,39 +25,30 @@ import java.util.Set;
@Slf4j
public class PermissionService {
- private final RoleMenuService roleMenuService;
+ private final PermissionPort permissionPort;
/**
- * 判断当前登录用户是否拥有操作权限
- *
- * 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:create"、"sys:user:delete" 等
- *
- * @param requiredPerm 所需权限
- * @return 是否有权限
+ * 判断当前用户是否拥有操作权限,支持通配符匹配。
*/
public boolean hasPerm(String requiredPerm) {
if (StrUtil.isBlank(requiredPerm)) {
return false;
}
- // 超级管理员放行
if (SecurityUtils.isRoot()) {
return true;
}
- // 获取当前登录用户的角色编码集合
Set roleCodes = SecurityUtils.getRoles();
if (CollectionUtil.isEmpty(roleCodes)) {
return false;
}
- // 获取当前登录用户的所有角色的权限列表(从缓存读取)
- Set rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes);
+ Set rolePerms = permissionPort.getRolePerms(roleCodes);
if (CollectionUtil.isEmpty(rolePerms)) {
return false;
}
- // 判断权限列表中是否包含所需权限(支持通配符)
boolean hasPermission = rolePerms.stream()
.anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm));
diff --git a/src/main/java/com/youlai/boot/framework/security/service/RolePermissionService.java b/src/main/java/com/youlai/boot/framework/security/service/RolePermissionService.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java b/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java
new file mode 100644
index 00000000..9b998c22
--- /dev/null
+++ b/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java
@@ -0,0 +1,41 @@
+package com.youlai.boot.framework.security.service;
+
+import com.youlai.boot.framework.security.model.SecurityUser;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
+import com.youlai.boot.framework.security.port.UserAuthenticationPort;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+/**
+ * 系统用户认证 DetailsService。
+ *
+ * 通过 {@link UserAuthenticationPort} 获取认证信息,不直接依赖 system 模块。
+ *
+ * @author Ray.Hao
+ * @since 2021/10/19
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class SecurityUserDetailsService implements UserDetailsService {
+
+ private final UserAuthenticationPort userAuthPort;
+
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ try {
+ SecurityUser securityUser = userAuthPort.getAuthInfoByUsername(username);
+ if (securityUser == null) {
+ throw new UsernameNotFoundException(username);
+ }
+ return new SecurityUserDetails(securityUser);
+ } catch (Exception e) {
+ log.error("认证异常:{}", e.getMessage());
+ throw e;
+ }
+ }
+}
diff --git a/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java b/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java
deleted file mode 100644
index 2bbe035b..00000000
--- a/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.youlai.boot.framework.security.service;
-
-import com.youlai.boot.framework.security.model.SysUserDetails;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
-import com.youlai.boot.system.enums.SocialPlatformEnum;
-import com.youlai.boot.system.model.entity.UserSocial;
-import com.youlai.boot.system.service.UserSocialService;
-import com.youlai.boot.system.service.UserService;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.security.core.userdetails.UserDetails;
-import org.springframework.security.core.userdetails.UserDetailsService;
-import org.springframework.security.core.userdetails.UsernameNotFoundException;
-import org.springframework.stereotype.Service;
-
-/**
- * 系统用户认证 DetailsService
- *
- * @author Ray.Hao
- * @since 2021/10/19
- */
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class SysUserDetailsService implements UserDetailsService {
-
- private final UserService userService;
- private final UserSocialService userSocialService;
-
- /**
- * 根据用户名获取用户信息
- *
- * @param username 用户名
- * @return 用户信息
- * @throws UsernameNotFoundException 用户名未找到异常
- */
- @Override
- public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
- try {
- UserAuthInfo userAuthInfo = userService.getAuthInfoByUsername(username);
- if (userAuthInfo == null) {
- throw new UsernameNotFoundException(username);
- }
- return new SysUserDetails(userAuthInfo);
- } catch (Exception e) {
- // 记录异常日志
- log.error("认证异常:{}", e.getMessage());
- // 抛出异常
- throw e;
- }
- }
-
- /**
- * 根据微信小程序openid查询绑定信息
- *
- * @param openid 微信小程序openid
- * @return 绑定信息,未绑定返回null
- */
- public UserSocial getWechatMiniBindInfo(String openid) {
- return userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
- }
-
- /**
- * 根据微信小程序openid获取用户认证信息
- *
- * @param openid 微信小程序openid
- * @return 用户认证信息,用户不存在返回null
- */
- public UserAuthInfo getAuthInfoByWechatOpenid(String openid) {
- return userSocialService.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
- }
-
- /**
- * 更新微信小程序session_key
- *
- * @param bindId 绑定记录ID
- * @param sessionKey session_key
- */
- public void updateWechatSessionKey(Long bindId, String sessionKey) {
- userSocialService.updateSessionKey(bindId, sessionKey);
- }
-}
diff --git a/src/main/java/com/youlai/boot/framework/security/service/UserAuthQueryService.java b/src/main/java/com/youlai/boot/framework/security/service/UserAuthQueryService.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/framework/security/service/WxMaUserAuthQueryService.java b/src/main/java/com/youlai/boot/framework/security/service/WxMaUserAuthQueryService.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java
index 1b153f64..bdd44dab 100644
--- a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java
+++ b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java
@@ -17,30 +17,25 @@ import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.RoleDataScope;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
import org.apache.commons.lang3.StringUtils;
-import com.youlai.boot.framework.security.model.SysUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
-import org.springframework.security.core.GrantedAuthority;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.util.*;
-import java.util.concurrent.TimeUnit; // Import TimeUnit
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
- * JWT Token 管理器
+ * JWT Token 管理器。
*
- * 实现基于JWT的无状态认证,支持:
- *
- * Access Token + Refresh Token 双令牌机制
- * Token 撤销(jti黑名单)
- * 用户级会话失效(tokenVersion)
- * 多角色数据权限存储
- *
+ * 基于 JWT 的无状态认证,支持 Access + Refresh 双令牌、Token 撤销(jti 黑名单)、
+ * 用户级会话失效(tokenVersion)、多角色数据权限存储。
+ *
+ * JWT claims 中存储角色编码(不带 ROLE_ 前缀),解析后由 {@link SecurityUserDetails#getAuthorities()} 运行时补前缀。
*
* @author Ray.Hao
* @since 2024/11/15
@@ -59,12 +54,6 @@ public class JwtTokenManager implements TokenManager {
this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes();
}
- /**
- * 生成令牌
- *
- * @param authentication 认证信息
- * @return 令牌响应对象
- */
@Override
public AuthenticationToken generateToken(Authentication authentication) {
int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive();
@@ -81,22 +70,15 @@ public class JwtTokenManager implements TokenManager {
.build();
}
- /**
- * 解析令牌
- *
- * @param token JWT Token
- * @return Authentication 对象
- */
@Override
public Authentication parseToken(String token) {
-
JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads();
- SysUserDetails userDetails = new SysUserDetails();
- userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
- userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
+ SecurityUserDetails userDetails = new SecurityUserDetails();
+ userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID));
+ userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID));
- // 解析数据权限列表
+ // 数据权限
JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES);
if (dataScopesArray != null && !dataScopesArray.isEmpty()) {
List dataScopes = dataScopesArray.stream()
@@ -115,88 +97,60 @@ public class JwtTokenManager implements TokenManager {
userDetails.setDataScopes(dataScopes);
}
- userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
- // 角色集合
- Set authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES)
- .stream()
- .map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
- .collect(Collectors.toSet());
+ userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT));
- return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
+ // 角色编码(不带 ROLE_ 前缀)
+ JSONArray rolesArray = payloads.getJSONArray(JwtClaimConstants.ROLES);
+ if (rolesArray != null && !rolesArray.isEmpty()) {
+ Set roles = rolesArray.stream()
+ .map(Convert::toStr)
+ .collect(Collectors.toSet());
+ userDetails.setRoles(roles);
+ }
+
+ return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
- /**
- * 校验令牌
- *
- * @param token JWT Token
- * @return 是否有效
- */
@Override
public boolean validateToken(String token) {
return validateToken(token, false);
}
- /**
- * 校验刷新令牌
- *
- * @param refreshToken JWT Token
- * @return 验证结果
- */
@Override
public boolean validateRefreshToken(String refreshToken) {
return validateToken(refreshToken, true);
}
- /**
- * 校验令牌
- *
- * 校验流程(按顺序执行):
- *
- * 签名验证 + 过期时间检查
- * 刷新令牌类型校验(仅刷新场景)
- * tokenVersion 校验(用户级会话失效)
- * jti 黑名单校验(单Token撤销)
- *
- *
- * @param token JWT Token
- * @param validateRefreshToken 是否校验刷新令牌类型
- * @return 是否有效
- */
private boolean validateToken(String token, boolean validateRefreshToken) {
JWT jwt = JWTUtil.parseToken(token);
- // 检查 Token 是否有效(验签 + 是否过期)
boolean isValid = jwt.setKey(secretKey).validate(0);
if (isValid) {
JSONObject payloads = jwt.getPayloads();
- // 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用)
+ // 刷新令牌类型校验
String jti = payloads.getStr(JWTPayload.JWT_ID);
if (validateRefreshToken) {
- //刷新token需要校验token类别
boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE);
if (!isRefreshToken) {
return false;
}
}
- // 2. 校验 tokenVersion(用于按用户维度失效历史 Token)
- // 场景示例:用户修改密码、被管理员强制下线、手动"踢所有端"后,递增 tokenVersion,
- // 之前签发的 Token 因版本号不匹配而失效
+
+ // tokenVersion 校验(用户维度 Token 失效)
Long userId = payloads.getLong(JwtClaimConstants.USER_ID);
if (userId != null) {
Integer tokenVersion = payloads.getInt(JwtClaimConstants.TOKEN_VERSION);
-
+
String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId);
Object currentVersionObj = redisTemplate.opsForValue().get(versionKey);
int currentVersion = currentVersionObj != null ? Convert.toInt(currentVersionObj) : 0;
- // 版本号不匹配则 Token 无效(新签发的 Token 版本号必须 >= Redis 中的版本号)
if (tokenVersion == null || tokenVersion < currentVersion) {
return false;
}
}
- // 3. 判断 Token 是否已被撤销(单端退出/会话注销)
- // 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等
+ // jti 黑名单校验
if (isTokenRevoked(jti)) {
return false;
}
@@ -204,17 +158,11 @@ public class JwtTokenManager implements TokenManager {
return isValid;
}
- /**
- * 将令牌加入黑名单
- *
- * @param token JWT Token
- */
@Override
public void invalidateToken(String token) {
if (StringUtils.isBlank(token)) {
return;
}
-
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
}
@@ -225,77 +173,15 @@ public class JwtTokenManager implements TokenManager {
revokeTokenByJti(jti, expirationAt);
}
- /**
- * 检查Token是否已被撤销
- *
- * @param jti Token唯一标识
- * @return true-已撤销,false-未撤销
- */
- private boolean isTokenRevoked(String jti) {
- if (StringUtils.isBlank(jti)) {
- return false;
- }
- return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti)));
- }
-
- /**
- * 将Token加入撤销黑名单
- *
- * 黑名单有效期与Token剩余有效期一致,避免永久存储
- *
- * @param jti Token唯一标识
- * @param expirationAt Token过期时间戳
- */
- private void revokeTokenByJti(String jti, Integer expirationAt) {
- if (StringUtils.isBlank(jti)) {
- return;
- }
-
- String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti);
- if (expirationAt != null) {
- int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
- if (expirationAt < currentTimeSeconds) {
- return;
- }
- int expirationIn = expirationAt - currentTimeSeconds;
- redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
- } else {
- redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
- }
- }
-
- /**
- * 失效指定用户的所有会话
- *
- * 通过递增用户 tokenVersion,使该用户之前签发的所有 Token 因版本号不匹配而失效。
- *
- * 适用场景:
- *
- * 用户修改密码
- * 管理员强制下线用户
- * 用户主动踢出所有设备
- * 用户被禁用
- *
- *
- * @param userId 用户ID
- */
@Override
public void invalidateUserSessions(Long userId) {
if (userId == null) {
return;
}
-
String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId);
- // 递增版本号,无需设置 TTL(版本号永久有效,避免 TTL 过期导致的安全问题)
redisTemplate.opsForValue().increment(versionKey);
}
- /**
- * 刷新令牌
- *
- * @param refreshToken 刷新令牌
- * @return 令牌响应对象
- */
@Override
public AuthenticationToken refreshToken(String refreshToken) {
boolean isValid = validateRefreshToken(refreshToken);
@@ -313,45 +199,19 @@ public class JwtTokenManager implements TokenManager {
.build();
}
- /**
- * 生成 JWT Token
- *
- * @param authentication 认证信息
- * @param ttl 过期时间(秒),-1表示永不过期
- * @return JWT Token字符串
- */
+ // ======================== private ========================
+
private String generateToken(Authentication authentication, int ttl) {
return generateToken(authentication, ttl, false);
}
-
- /**
- * 生成 JWT Token
- *
- * Payload包含:
- *
- * userId - 用户ID
- * deptId - 部门ID
- * dataScopes - 数据权限列表
- * authorities - 角色权限集合
- * tokenType - 是否为刷新令牌
- * tokenVersion - Token版本号(用于会话失效控制)
- * iat/exp - 签发/过期时间
- * jti - Token唯一标识(用于撤销)
- *
- *
- * @param authentication 认证信息
- * @param ttl 过期时间(秒)
- * @param isRefreshToken 是否为刷新令牌
- * @return JWT Token字符串
- */
private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) {
- SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
+ SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal();
Map payload = new HashMap<>();
- payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
- payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
+ payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId());
+ payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId());
- // 存储数据权限列表
+ // 数据权限
List dataScopes = userDetails.getDataScopes();
if (dataScopes != null && !dataScopes.isEmpty()) {
List> scopesList = dataScopes.stream()
@@ -366,13 +226,12 @@ public class JwtTokenManager implements TokenManager {
payload.put(JwtClaimConstants.DATA_SCOPES, scopesList);
}
- // claims 中添加角色信息
- Set roles = authentication.getAuthorities().stream()
- .map(GrantedAuthority::getAuthority)
- .collect(Collectors.toSet());
- payload.put(JwtClaimConstants.AUTHORITIES, roles);
+ // 角色编码(不带 ROLE_ 前缀)
+ Set roles = userDetails.getRoles();
+ if (roles != null && !roles.isEmpty()) {
+ payload.put(JwtClaimConstants.ROLES, roles);
+ }
- // 获取当前用户的 Token 版本号,用于会话失效控制
Long userId = userDetails.getUserId();
int tokenVersion = 0;
if (userId != null) {
@@ -384,12 +243,8 @@ public class JwtTokenManager implements TokenManager {
Date now = new Date();
payload.put(JWTPayload.ISSUED_AT, now);
- payload.put(JwtClaimConstants.TOKEN_TYPE, false);
- if (isRefreshToken) {
- payload.put(JwtClaimConstants.TOKEN_TYPE, true);
- }
+ payload.put(JwtClaimConstants.TOKEN_TYPE, isRefreshToken);
- // 设置过期时间 -1 表示永不过期
if (ttl != -1) {
Date expiresAt = DateUtil.offsetSecond(now, ttl);
payload.put(JWTPayload.EXPIRES_AT, expiresAt);
@@ -400,4 +255,27 @@ public class JwtTokenManager implements TokenManager {
return JWTUtil.createToken(payload, secretKey);
}
+ private boolean isTokenRevoked(String jti) {
+ if (StringUtils.isBlank(jti)) {
+ return false;
+ }
+ return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti)));
+ }
+
+ private void revokeTokenByJti(String jti, Integer expirationAt) {
+ if (StringUtils.isBlank(jti)) {
+ return;
+ }
+ String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti);
+ if (expirationAt != null) {
+ int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
+ if (expirationAt < currentTimeSeconds) {
+ return;
+ }
+ int expirationIn = expirationAt - currentTimeSeconds;
+ redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
+ } else {
+ redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
+ }
+ }
}
diff --git a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java
index 52481ad4..661a8b46 100644
--- a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java
+++ b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java
@@ -1,6 +1,5 @@
package com.youlai.boot.framework.security.token;
-import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
@@ -9,35 +8,25 @@ import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
-import com.youlai.boot.framework.security.model.UserSession;
-import com.youlai.boot.framework.security.model.SysUserDetails;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
-import org.springframework.security.core.GrantedAuthority;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import tools.jackson.databind.json.JsonMapper;
import java.util.Optional;
-import java.util.Set;
import java.util.concurrent.TimeUnit;
-import java.util.stream.Collectors;
/**
- * Redis Token 管理器
+ * Redis Token 管理器。
*
- * 实现基于Redis的有状态认证,支持:
- *
- * Access Token + Refresh Token 双令牌机制
- * 单设备/多设备登录控制
- * 用户级会话失效
- * 在线用户管理
- *
+ * 基于 Redis 的有状态 Token 认证,支持 Access + Refresh 双令牌、
+ * 单/多设备登录控制、用户级会话失效。
*
- * 与JWT模式相比,Redis模式支持主动踢人、在线用户查询等功能
+ * 直接存取 {@link SecurityUserDetails}(password 置 null),无需 UserSession 中间层。
*
* @author Ray.Hao
* @since 2024/11/15
@@ -58,33 +47,16 @@ public class RedisTokenManager implements TokenManager {
this.jsonMapper = jsonMapper;
}
- /**
- * 生成 Token
- *
- * @param authentication 用户认证信息
- * @return 生成的 AuthenticationToken 对象
- */
@Override
public AuthenticationToken generateToken(Authentication authentication) {
- SysUserDetails user = (SysUserDetails) authentication.getPrincipal();
+ SecurityUserDetails user = (SecurityUserDetails) authentication.getPrincipal();
String accessToken = IdUtil.fastSimpleUUID();
String refreshToken = IdUtil.fastSimpleUUID();
- // 构建用户会话信息
- UserSession userSession = new UserSession(
- user.getUserId(),
- user.getUsername(),
- user.getDeptId(),
- user.getDataScopes(),
- user.getAuthorities().stream()
- .map(GrantedAuthority::getAuthority)
- .collect(Collectors.toSet())
- );
+ // 构建会话快照(不存密码)
+ SecurityUserDetails sessionUser = buildSessionUser(user);
- // 存储访问令牌、刷新令牌和刷新令牌映射
- storeTokensInRedis(accessToken, refreshToken, userSession);
-
- // 单设备登录控制
+ storeTokensInRedis(accessToken, refreshToken, sessionUser);
handleSingleDeviceLogin(user.getUserId(), accessToken);
return AuthenticationToken.builder()
@@ -94,61 +66,24 @@ public class RedisTokenManager implements TokenManager {
.build();
}
- /**
- * 根据 token 解析用户信息
- *
- * @param token Redis Token
- * @return 构建的 Authentication 对象
- */
@Override
public Authentication parseToken(String token) {
Object raw = redisTemplate.opsForValue().get(formatTokenKey(token));
if (raw == null) return null;
- UserSession userSession = jsonMapper.convertValue(raw, UserSession.class);
-
- // 构建用户权限集合
- Set authorities = null;
-
- Set roles = userSession.getRoles();
- if (CollectionUtil.isNotEmpty(roles)) {
- authorities = roles.stream()
- .map(SimpleGrantedAuthority::new)
- .collect(Collectors.toSet());
- }
-
- // 构建用户详情对象
- SysUserDetails userDetails = buildUserDetails(userSession, authorities);
- return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
+ SecurityUserDetails userDetails = jsonMapper.convertValue(raw, SecurityUserDetails.class);
+ return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
- /**
- * 校验 Token 是否有效
- *
- * @param token 访问令牌
- * @return 是否有效
- */
@Override
public boolean validateToken(String token) {
return redisTemplate.hasKey(formatTokenKey(token));
}
- /**
- * 校验 RefreshToken 是否有效
- *
- * @param refreshToken 访问令牌
- * @return 是否有效
- */
@Override
public boolean validateRefreshToken(String refreshToken) {
return redisTemplate.hasKey(formatRefreshTokenKey(refreshToken));
}
- /**
- * 刷新令牌
- *
- * @param refreshToken 刷新令牌
- * @return 新生成的 AuthenticationToken 对象
- */
@Override
public AuthenticationToken refreshToken(String refreshToken) {
Object raw = redisTemplate.opsForValue()
@@ -156,16 +91,16 @@ public class RedisTokenManager implements TokenManager {
if (raw == null) {
throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID);
}
- UserSession userSession = jsonMapper.convertValue(raw, UserSession.class);
- Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()));
- // 删除旧的访问令牌记录
+ SecurityUserDetails sessionUser = jsonMapper.convertValue(raw, SecurityUserDetails.class);
+
+ Object oldAccessTokenValue = redisTemplate.opsForValue()
+ .get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId()));
Optional.of(oldAccessTokenValue)
.map(String.class::cast)
.ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken)));
- // 生成新访问令牌并存储
String newAccessToken = IdUtil.fastSimpleUUID();
- storeAccessToken(newAccessToken, userSession);
+ storeAccessToken(newAccessToken, sessionUser);
int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive();
return AuthenticationToken.builder()
@@ -175,164 +110,91 @@ public class RedisTokenManager implements TokenManager {
.build();
}
- /**
- * Make access token invalid
- *
- * Only deletes the current token, not all sessions for the user.
- * This ensures single-device logout doesn't affect other devices when allowMultiLogin=true.
- *
- * @param token Access token
- */
@Override
public void invalidateToken(String token) {
String cleanToken = cleanBearerPrefix(token);
- // Only delete the current token, not all user sessions
redisTemplate.delete(formatTokenKey(cleanToken));
}
- /**
- * 使指定用户的所有会话失效
- *
- * 适用场景:用户修改密码、管理员强制下线、账号封禁等
- *
- * @param userId 用户ID
- */
@Override
public void invalidateUserSessions(Long userId) {
if (userId == null) {
return;
}
-
- // 1. 删除访问令牌相关
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
if (accessTokenValue instanceof String accessToken) {
redisTemplate.delete(formatTokenKey(accessToken));
}
- // 无论是否存在访问令牌映射,都尝试删除 userAccessKey
redisTemplate.delete(userAccessKey);
- // 2. 删除刷新令牌相关
String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId);
Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey);
if (refreshTokenValue instanceof String refreshToken) {
redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
}
- // 同样清理 userRefreshKey 本身
redisTemplate.delete(userRefreshKey);
}
- /**
- * 将访问令牌和刷新令牌存储至 Redis
- *
- * @param accessToken 访问令牌
- * @param refreshToken 刷新令牌
- * @param userSession 用户会话信息
- */
- private void storeTokensInRedis(String accessToken, String refreshToken, UserSession userSession) {
- // 访问令牌 -> 用户信息
- setRedisValue(formatTokenKey(accessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
+ // ======================== private ========================
- // 刷新令牌 -> 用户信息
- String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
- setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive());
-
- // 用户ID -> 刷新令牌
- setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()),
- refreshToken,
- securityProperties.getSession().getRefreshTokenTimeToLive());
+ private SecurityUserDetails buildSessionUser(SecurityUserDetails user) {
+ SecurityUserDetails sessionUser = new SecurityUserDetails();
+ sessionUser.setUserId(user.getUserId());
+ sessionUser.setUsername(user.getUsername());
+ sessionUser.setDeptId(user.getDeptId());
+ sessionUser.setDataScopes(user.getDataScopes());
+ sessionUser.setRoles(user.getRoles());
+ sessionUser.setEnabled(user.isEnabled());
+ sessionUser.setPassword(null);
+ return sessionUser;
+ }
+
+ private void storeTokensInRedis(String accessToken, String refreshToken, SecurityUserDetails sessionUser) {
+ setRedisValue(formatTokenKey(accessToken), sessionUser,
+ securityProperties.getSession().getAccessTokenTimeToLive());
+ String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
+ setRedisValue(refreshTokenKey, sessionUser,
+ securityProperties.getSession().getRefreshTokenTimeToLive());
+ setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, sessionUser.getUserId()),
+ refreshToken, securityProperties.getSession().getRefreshTokenTimeToLive());
}
- /**
- * 处理单设备登录控制
- *
- * 当配置不允许多设备登录时,新登录会使旧Token失效
- *
- * @param userId 用户ID
- * @param accessToken 新生成的访问令牌
- */
private void handleSingleDeviceLogin(Long userId, String accessToken) {
Boolean allowMultiLogin = securityProperties.getSession().getRedisToken().getAllowMultiLogin();
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
- // 单设备登录控制,删除旧的访问令牌
if (!allowMultiLogin) {
Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
if (oldAccessTokenValue instanceof String oldAccessToken) {
redisTemplate.delete(formatTokenKey(oldAccessToken));
}
}
- // 存储访问令牌映射(用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌
setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive());
}
- /**
- * 存储新的访问令牌
- *
- * @param newAccessToken 新访问令牌
- * @param userSession 用户会话信息
- */
- private void storeAccessToken(String newAccessToken, UserSession userSession) {
- setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
- String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId());
+ private void storeAccessToken(String newAccessToken, SecurityUserDetails sessionUser) {
+ setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), sessionUser,
+ securityProperties.getSession().getAccessTokenTimeToLive());
+ String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId());
setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive());
}
- /**
- * 构建用户详情对象
- *
- * @param userSession 用户会话信息
- * @param authorities 权限集合
- * @return SysUserDetails 用户详情
- */
- private SysUserDetails buildUserDetails(UserSession userSession, Set authorities) {
- SysUserDetails userDetails = new SysUserDetails();
- userDetails.setUserId(userSession.getUserId());
- userDetails.setUsername(userSession.getUsername());
- userDetails.setDeptId(userSession.getDeptId());
- userDetails.setDataScopes(userSession.getDataScopes());
- userDetails.setAuthorities(authorities);
- return userDetails;
- }
-
- /**
- * 格式化访问令牌的 Redis 键
- *
- * @param token 访问令牌
- * @return 格式化后的 Redis 键
- */
private String formatTokenKey(String token) {
return StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token);
}
- /**
- * 格式化刷新令牌的 Redis 键
- *
- * @param refreshToken 访问令牌
- * @return 格式化后的 Redis 键
- */
private String formatRefreshTokenKey(String refreshToken) {
return StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
}
- /**
- * 将值存储到 Redis
- *
- * @param key 键
- * @param value 值
- * @param ttl 过期时间(秒),-1表示永不过期
- */
private void setRedisValue(String key, Object value, int ttl) {
if (ttl != -1) {
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
} else {
- redisTemplate.opsForValue().set(key, value); // ttl=-1时永不过期
+ redisTemplate.opsForValue().set(key, value);
}
}
-
- /**
- * 清理 Bearer 前缀
- */
private String cleanBearerPrefix(String token) {
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
return token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()).trim();
diff --git a/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java
index 3fed56b1..d4f28d3c 100644
--- a/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java
+++ b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java
@@ -1,21 +1,16 @@
package com.youlai.boot.framework.security.util;
-import cn.hutool.core.collection.CollectionUtil;
-import cn.hutool.core.util.StrUtil;
-import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.constant.SystemConstants;
import com.youlai.boot.framework.security.model.RoleDataScope;
-import com.youlai.boot.framework.security.model.SysUserDetails;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
-import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.*;
-import java.util.stream.Collectors;
/**
* Spring Security 工具类
@@ -26,101 +21,73 @@ import java.util.stream.Collectors;
public class SecurityUtils {
/**
- * 获取当前登录人信息
- *
- * @return Optional
+ * 获取当前登录用户
*/
- public static Optional getUser() {
+ public static Optional getUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
Object principal = authentication.getPrincipal();
- if (principal instanceof SysUserDetails) {
- return Optional.of((SysUserDetails) principal);
+ if (principal instanceof SecurityUserDetails) {
+ return Optional.of((SecurityUserDetails) principal);
}
}
return Optional.empty();
}
-
/**
* 获取用户ID
- *
- * @return Long
*/
public static Long getUserId() {
- return getUser().map(SysUserDetails::getUserId).orElse(null);
+ return getUser().map(SecurityUserDetails::getUserId).orElse(null);
}
-
/**
* 获取用户账号
- *
- * @return String 用户账号
*/
public static String getUsername() {
- return getUser().map(SysUserDetails::getUsername).orElse(null);
+ return getUser().map(SecurityUserDetails::getUsername).orElse(null);
}
-
/**
* 获取部门ID
- *
- * @return Long
*/
public static Long getDeptId() {
- return getUser().map(SysUserDetails::getDeptId).orElse(null);
+ return getUser().map(SecurityUserDetails::getDeptId).orElse(null);
}
/**
* 获取数据权限列表
- *
- * @return 数据权限列表
*/
public static List getDataScopes() {
- return getUser().map(SysUserDetails::getDataScopes).orElse(List.of());
+ return getUser().map(SecurityUserDetails::getDataScopes).orElse(List.of());
}
/**
- * 获取角色集合
- *
- * @return 角色集合
+ * 获取角色编码集合,不带 ROLE_ 前缀。
+ *
+ * 直接从 {@link SecurityUserDetails#getRoles()} 取值,不做 stripping 和过滤。
*/
public static Set getRoles() {
- return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
- .map(Authentication::getAuthorities)
- .filter(CollectionUtil::isNotEmpty)
- .stream()
- .flatMap(Collection::stream)
- .map(GrantedAuthority::getAuthority)
- // 筛选角色,authorities 中的角色都是以 ROLE_ 开头
- .filter(authority -> authority.startsWith(SecurityConstants.ROLE_PREFIX))
- .map(authority -> StrUtil.removePrefix(authority, SecurityConstants.ROLE_PREFIX))
- .collect(Collectors.toSet());
+ return getUser().map(SecurityUserDetails::getRoles).orElse(Set.of());
}
/**
* 是否超级管理员
- *
- * 超级管理员忽视任何权限判断
*/
public static boolean isRoot() {
- Set roles = getRoles();
- return roles.contains(SystemConstants.ROOT_ROLE_CODE);
+ return getRoles().contains(SystemConstants.ROOT_ROLE_CODE);
}
/**
- * 获取请求中的 Token
- *
- * @return Token 字符串
+ * 从请求头获取 Token
*/
public static String getAccessToken() {
- ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes());
- if(Objects.isNull(servletRequestAttributes)) {
+ ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ if (attributes == null) {
return null;
}
- HttpServletRequest request = servletRequestAttributes.getRequest();
+ HttpServletRequest request = attributes.getRequest();
return request.getHeader(HttpHeaders.AUTHORIZATION);
}
-
}
diff --git a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java
index 0692e0de..89c044ec 100644
--- a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java
+++ b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java
@@ -5,6 +5,7 @@ import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
+import com.youlai.boot.framework.web.exception.RateLimitException;
import jakarta.servlet.ServletException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
@@ -157,6 +158,12 @@ public class GlobalExceptionHandler {
return Result.failed(e.getResultCode());
}
+ @ExceptionHandler(RateLimitException.class)
+ @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
+ public Result handleRateLimitException(RateLimitException e) {
+ return Result.failed(e.getResultCode(), e.getMessage());
+ }
+
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.OK)
public Result handleBizException(BusinessException e) {
diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/LogAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/LogAspect.java
index 6642a503..00093330 100644
--- a/src/main/java/com/youlai/boot/framework/web/aspect/LogAspect.java
+++ b/src/main/java/com/youlai/boot/framework/web/aspect/LogAspect.java
@@ -3,7 +3,7 @@ package com.youlai.boot.framework.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.util.IPUtils;
diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java
index 7c1940f1..a58fe6e2 100644
--- a/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java
+++ b/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java
@@ -2,13 +2,13 @@ package com.youlai.boot.framework.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
-import com.youlai.boot.common.annotation.RateLimit;
+import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
-import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.framework.web.config.RateLimitProperties;
+import com.youlai.boot.framework.web.exception.RateLimitException;
import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -55,8 +55,9 @@ public class RateLimitAspect {
String key = buildKey(request, rateLimit);
int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit();
- int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow();
- long windowMs = rateLimit.timeUnit().toMillis(window);
+ long windowMs = rateLimit.window() > 0
+ ? rateLimit.window() * 1000L
+ : rateLimitProperties.getDefaultWindow().toMillis();
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
@@ -65,7 +66,7 @@ public class RateLimitAspect {
if (current > limit) {
log.warn("接口限流触发 key={} count={} limit={}", key, current, limit);
- throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
+ throw new RateLimitException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
}
return jp.proceed();
@@ -73,8 +74,7 @@ public class RateLimitAspect {
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
String user = resolveUser(request);
- return StrUtil.format(RedisConstants.RateLimiter.API,
- rateLimit.prefix(), user, request.getRequestURI());
+ return StrUtil.format(RedisConstants.RateLimit.API, user, request.getRequestURI());
}
private String resolveUser(HttpServletRequest request) {
diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java
index 63e2456c..dbea1303 100644
--- a/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java
+++ b/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java
@@ -2,11 +2,12 @@ package com.youlai.boot.framework.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
+import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.exception.BusinessException;
-import com.youlai.boot.common.annotation.RepeatSubmit;
+import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.common.util.IPUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -42,7 +43,7 @@ public class RepeatSubmitAspect {
@Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit")
public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable {
- String lockKey = buildLockKey();
+ String lockKey = buildLockKey(pjp);
int expire = repeatSubmit.expire();
RLock lock = redissonClient.getLock(lockKey);
@@ -56,12 +57,17 @@ public class RepeatSubmitAspect {
/**
* 生成防重复提交锁的 key
+ *
+ * key 由「用户标识 + 接口(method:URI) + 请求体哈希」组成:只有同一用户在同一接口提交
+ * 完全相同的请求体才视为重复提交;若仅按 method:URI 生成,同一接口的不同提交
+ * (如新增菜单与新增目录)会在窗口内被误判为重复。
*/
- private String buildLockKey() {
+ private String buildLockKey(ProceedingJoinPoint pjp) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String userIdentifier = getUserIdentifier(request);
String requestIdentifier = StrUtil.join(":", request.getMethod(), request.getRequestURI());
- return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier);
+ String bodyHash = DigestUtil.sha256Hex(JSONUtil.toJsonStr(pjp.getArgs()));
+ return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier + ":" + bodyHash);
}
/**
diff --git a/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java b/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java
index a62102dc..72d8c5f4 100644
--- a/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java
+++ b/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java
@@ -4,11 +4,11 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
+import java.time.Duration;
+
/**
* 限流配置
- *
- * 对应 application.yml 中的 {@code rate-limit} 配置节点。
- *
+ * 对应 application.yml 中的 {@code rate-limit} 配置节点。
*
* @author Ray.Hao
* @since 4.3.1
@@ -19,14 +19,14 @@ import org.springframework.stereotype.Component;
public class RateLimitProperties {
/**
- * @RateLimit 注解未显式指定 limit 时的默认阈值
+ * @RateLimit 注解未显式指定 limit 时的默认阈值(窗口内最大请求数)
*/
private int defaultLimit = 5;
/**
- * @RateLimit 注解未显式指定 window 时的默认窗口大小(秒)
+ * @RateLimit 注解未显式指定 window 时的默认窗口大小,支持 60s / 1m / 500ms 等 Duration 写法
*/
- private int defaultWindow = 1;
+ private Duration defaultWindow = Duration.ofSeconds(60);
/**
* IP 全局限流配置
@@ -48,9 +48,9 @@ public class RateLimitProperties {
private int limit = 1000;
/**
- * 窗口大小(秒,默认 60)
+ * 滑动窗口大小,支持 60s / 1m / 500ms 等 Duration 写法(默认 60s)
*/
- private int windowSeconds = 60;
+ private Duration window = Duration.ofSeconds(60);
}
diff --git a/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java b/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java
new file mode 100644
index 00000000..9374b6ba
--- /dev/null
+++ b/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java
@@ -0,0 +1,26 @@
+package com.youlai.boot.framework.web.exception;
+
+import com.youlai.boot.common.result.ResultCode;
+import lombok.Getter;
+
+/**
+ * 接口限流异常:请求超出限流阈值时抛出,由全局异常处理器统一映射为 HTTP 429。
+ *
+ * @author Ray.Hao
+ * @since 4.4.0
+ */
+@Getter
+public class RateLimitException extends RuntimeException {
+
+ private final ResultCode resultCode;
+
+ public RateLimitException(ResultCode resultCode) {
+ super(resultCode.getMsg());
+ this.resultCode = resultCode;
+ }
+
+ public RateLimitException(ResultCode resultCode, String message) {
+ super(message);
+ this.resultCode = resultCode;
+ }
+}
diff --git a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
index a05d8f99..b4e3f0a1 100644
--- a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
+++ b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
@@ -30,7 +30,7 @@ import java.io.IOException;
*
* 限流维度
* {@code
- * Key: rate_limiter:ip:{clientIp}
+ * Key: rate_limit:ip:{clientIp}
* 默认: 1000 req / 60s(可通过 rate-limit.ip.* 配置)
* }
*
@@ -67,11 +67,17 @@ public class IpRateLimitFilter extends OncePerRequestFilter {
}
String ip = IPUtils.getIpAddr(request);
- String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip);
- long windowMs = ipConfig.getWindowSeconds() * 1000L;
+ String key = StrUtil.format(RedisConstants.RateLimit.IP, ip);
+ long windowMs = ipConfig.getWindow().toMillis();
- // 执行滑动窗口计数(Lua 原子操作)
- Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
+ // 执行滑动窗口计数(Lua 原子操作);Redis 异常时 Fail-Open 放行
+ Long count;
+ try {
+ count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
+ } catch (Exception e) {
+ log.warn("IP 限流 Redis 异常,Fail-Open 放行 ip={}", ip, e);
+ count = null;
+ }
int limit = ipConfig.getLimit();
int current = count != null ? count.intValue() : 0;
@@ -80,7 +86,7 @@ public class IpRateLimitFilter extends OncePerRequestFilter {
if (current > limit) {
log.warn("IP 限流触发 ip={} count={} limit={}", ip, current, limit);
- response.setHeader("Retry-After", String.valueOf(ipConfig.getWindowSeconds()));
+ response.setHeader("Retry-After", String.valueOf(ipConfig.getWindow().getSeconds()));
ResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
return;
}
diff --git a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java
index a05bc25e..fafe61ad 100644
--- a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java
+++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java
@@ -49,7 +49,7 @@ public final class SlidingWindowScript {
* 执行滑动窗口计数
*
* @param redisTemplate Redis 模板
- * @param key 限流 Key(如 {@code rate_limiter:ip:192.168.1.1})
+ * @param key 限流 Key(如 {@code rate_limit:ip:192.168.1.1})
* @param windowMs 窗口大小(毫秒)
* @return 当前窗口内请求数(包含本次)
*/
diff --git a/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java b/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java
index 02f972ef..118ea7ed 100644
--- a/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java
+++ b/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java
@@ -57,6 +57,7 @@ public final class ResponseWriter {
return switch (resultCode) {
case ACCESS_UNAUTHORIZED, ACCESS_TOKEN_INVALID, REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value();
case ACCESS_PERMISSION_EXCEPTION -> HttpStatus.FORBIDDEN.value();
+ case REQUEST_CONCURRENCY_LIMIT_EXCEEDED -> HttpStatus.TOO_MANY_REQUESTS.value();
default -> HttpStatus.BAD_REQUEST.value();
};
}
diff --git a/src/main/java/com/youlai/boot/message/controller/SseController.java b/src/main/java/com/youlai/boot/message/controller/SseController.java
index f663c96b..3135a842 100644
--- a/src/main/java/com/youlai/boot/message/controller/SseController.java
+++ b/src/main/java/com/youlai/boot/message/controller/SseController.java
@@ -1,7 +1,7 @@
package com.youlai.boot.message.controller;
import com.youlai.boot.common.result.Result;
-import com.youlai.boot.framework.security.model.SysUserDetails;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.message.service.SseService;
import io.swagger.v3.oas.annotations.Operation;
@@ -29,7 +29,7 @@ public class SseController {
@Operation(summary = "建立SSE连接")
@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect() {
- SysUserDetails user = SecurityUtils.getUser().orElse(null);
+ SecurityUserDetails user = SecurityUtils.getUser().orElse(null);
if (user == null) {
log.warn("SSE连接失败:未获取到当前用户");
return null;
diff --git a/src/main/java/com/youlai/boot/message/event/DictChangeEvent.java b/src/main/java/com/youlai/boot/message/event/DictChangeEvent.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/system/controller/ConfigController.java b/src/main/java/com/youlai/boot/system/controller/ConfigController.java
index e68280a8..d4559240 100644
--- a/src/main/java/com/youlai/boot/system/controller/ConfigController.java
+++ b/src/main/java/com/youlai/boot/system/controller/ConfigController.java
@@ -5,7 +5,7 @@ import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.PageResult;
import com.youlai.boot.common.result.Result;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.system.model.form.ConfigForm;
import com.youlai.boot.system.model.query.ConfigQuery;
import com.youlai.boot.system.model.vo.ConfigVO;
@@ -53,11 +53,11 @@ public class ConfigController {
}
@Operation(summary = "获取系统配置表单数据")
- @GetMapping("/{id}/form")
+ @GetMapping("/{configId}/form")
public Result getConfigForm(
- @Parameter(description = "系统配置ID") @PathVariable Long id
+ @Parameter(description = "系统配置ID") @PathVariable Long configId
) {
- ConfigForm formData = configService.getConfigFormData(id);
+ ConfigForm formData = configService.getConfigFormData(configId);
return Result.success(formData);
}
@@ -70,19 +70,19 @@ public class ConfigController {
}
@Operation(summary = "修改系统配置")
- @PutMapping(value = "/{id}")
+ @PutMapping(value = "/{configId}")
@PreAuthorize("@ss.hasPerm('sys:config:update')")
@Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.UPDATE)
- public Result> update(@Valid @PathVariable Long id, @RequestBody ConfigForm configForm) {
- return Result.judge(configService.edit(id, configForm));
+ public Result> update(@Valid @PathVariable Long configId, @RequestBody ConfigForm configForm) {
+ return Result.judge(configService.edit(configId, configForm));
}
@Operation(summary = "删除系统配置")
- @DeleteMapping("/{id}")
+ @DeleteMapping("/{configId}")
@PreAuthorize("@ss.hasPerm('sys:config:delete')")
@Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.DELETE)
- public Result> delete(@PathVariable Long id) {
- return Result.judge(configService.delete(id));
+ public Result> delete(@PathVariable Long configId) {
+ return Result.judge(configService.delete(configId));
}
}
diff --git a/src/main/java/com/youlai/boot/system/controller/DeptController.java b/src/main/java/com/youlai/boot/system/controller/DeptController.java
index 7a54a8fd..2475b702 100644
--- a/src/main/java/com/youlai/boot/system/controller/DeptController.java
+++ b/src/main/java/com/youlai/boot/system/controller/DeptController.java
@@ -2,13 +2,13 @@ package com.youlai.boot.system.controller;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
-import com.youlai.boot.common.annotation.RepeatSubmit;
+import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.system.model.form.DeptForm;
import com.youlai.boot.system.model.query.DeptQuery;
import com.youlai.boot.system.model.vo.DeptVO;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.system.service.DeptService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
diff --git a/src/main/java/com/youlai/boot/system/controller/DictController.java b/src/main/java/com/youlai/boot/system/controller/DictController.java
index ef55538d..c905411e 100644
--- a/src/main/java/com/youlai/boot/system/controller/DictController.java
+++ b/src/main/java/com/youlai/boot/system/controller/DictController.java
@@ -12,9 +12,9 @@ import com.youlai.boot.system.model.query.DictQuery;
import com.youlai.boot.system.model.vo.DictItemOptionVO;
import com.youlai.boot.system.model.vo.DictItemPageVO;
import com.youlai.boot.system.model.vo.DictPageVO;
-import com.youlai.boot.common.annotation.RepeatSubmit;
+import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.system.model.form.DictForm;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.system.service.DictItemService;
import com.youlai.boot.system.service.DictService;
import com.youlai.boot.message.service.SseService;
@@ -67,11 +67,11 @@ public class DictController {
}
@Operation(summary = "获取字典表单数据")
- @GetMapping("/{id}/form")
+ @GetMapping("/{dictId}/form")
public Result getDictForm(
- @Parameter(description = "字典ID") @PathVariable Long id
+ @Parameter(description = "字典ID") @PathVariable Long dictId
) {
- DictForm formData = dictService.getDictForm(id);
+ DictForm formData = dictService.getDictForm(dictId);
return Result.success(formData);
}
@@ -90,14 +90,14 @@ public class DictController {
}
@Operation(summary = "修改字典")
- @PutMapping("/{id}")
+ @PutMapping("/{dictId}")
@PreAuthorize("@ss.hasPerm('sys:dict:update')")
@Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.UPDATE)
public Result> updateDict(
- @PathVariable Long id,
+ @PathVariable Long dictId,
@RequestBody DictForm dictForm
) {
- boolean status = dictService.updateDict(id, dictForm);
+ boolean status = dictService.updateDict(dictId, dictForm);
// 发送字典更新通知
if (status && dictForm.getDictCode() != null) {
sseService.sendDictChange(dictForm.getDictCode());
diff --git a/src/main/java/com/youlai/boot/system/controller/HealthController.java b/src/main/java/com/youlai/boot/system/controller/HealthController.java
new file mode 100644
index 00000000..09b0652d
--- /dev/null
+++ b/src/main/java/com/youlai/boot/system/controller/HealthController.java
@@ -0,0 +1,26 @@
+package com.youlai.boot.system.controller;
+
+import com.youlai.boot.common.result.Result;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 系统健康检查接口,返回 ok。
+ * 不标注 @RateLimit,仅受 IP 全局限流层约束,用作该层的验证端点。
+ */
+@Tag(name = "00.系统健康检查")
+@RestController
+@RequestMapping("/api/v1/health")
+@Slf4j
+public class HealthController {
+
+ @Operation(summary = "健康检查", description = "返回 ok,供限流测试与探针使用")
+ @GetMapping
+ public Result health() {
+ return Result.success("ok");
+ }
+}
diff --git a/src/main/java/com/youlai/boot/system/controller/LogController.java b/src/main/java/com/youlai/boot/system/controller/LogController.java
index c717b80e..34d70307 100644
--- a/src/main/java/com/youlai/boot/system/controller/LogController.java
+++ b/src/main/java/com/youlai/boot/system/controller/LogController.java
@@ -1,7 +1,7 @@
package com.youlai.boot.system.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.PageResult;
diff --git a/src/main/java/com/youlai/boot/system/controller/MenuController.java b/src/main/java/com/youlai/boot/system/controller/MenuController.java
index 1050803c..5000344f 100644
--- a/src/main/java/com/youlai/boot/system/controller/MenuController.java
+++ b/src/main/java/com/youlai/boot/system/controller/MenuController.java
@@ -1,7 +1,7 @@
package com.youlai.boot.system.controller;
-import com.youlai.boot.common.annotation.Log;
-import com.youlai.boot.common.annotation.RepeatSubmit;
+import com.youlai.boot.framework.annotation.Log;
+import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.model.Option;
@@ -62,12 +62,12 @@ public class MenuController {
}
@Operation(summary = "菜单表单数据")
- @GetMapping("/{id}/form")
+ @GetMapping("/{menuId}/form")
@PreAuthorize("@ss.hasPerm('sys:menu:update')")
public Result getMenuForm(
- @Parameter(description = "菜单ID") @PathVariable Long id
+ @Parameter(description = "菜单ID") @PathVariable Long menuId
) {
- MenuForm menu = menuService.getMenuForm(id);
+ MenuForm menu = menuService.getMenuForm(menuId);
return Result.success(menu);
}
@@ -82,10 +82,11 @@ public class MenuController {
}
@Operation(summary = "修改菜单")
- @PutMapping(value = "/{id}")
+ @PutMapping(value = "/{menuId}")
@PreAuthorize("@ss.hasPerm('sys:menu:update')")
@Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.UPDATE)
public Result> updateMenu(
+ @Parameter(description = "菜单ID") @PathVariable Long menuId,
@RequestBody MenuForm menuForm
) {
boolean result = menuService.saveMenu(menuForm);
@@ -93,13 +94,13 @@ public class MenuController {
}
@Operation(summary = "删除菜单")
- @DeleteMapping("/{id}")
+ @DeleteMapping("/{menuId}")
@PreAuthorize("@ss.hasPerm('sys:menu:delete')")
@Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.DELETE)
public Result> deleteMenu(
- @Parameter(description = "菜单ID,多个以英文(,)分割") @PathVariable("id") Long id
+ @Parameter(description = "菜单ID,多个以英文(,)分割") @PathVariable Long menuId
) {
- boolean result = menuService.deleteMenu(id);
+ boolean result = menuService.deleteMenu(menuId);
return Result.judge(result);
}
diff --git a/src/main/java/com/youlai/boot/system/controller/NoticeController.java b/src/main/java/com/youlai/boot/system/controller/NoticeController.java
index eafcad68..95e38a98 100644
--- a/src/main/java/com/youlai/boot/system/controller/NoticeController.java
+++ b/src/main/java/com/youlai/boot/system/controller/NoticeController.java
@@ -1,7 +1,7 @@
package com.youlai.boot.system.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.PageResult;
@@ -57,55 +57,55 @@ public class NoticeController {
}
@Operation(summary = "获取通知公告表单数据")
- @GetMapping("/{id}/form")
+ @GetMapping("/{noticeId}/form")
@PreAuthorize("@ss.hasPerm('sys:notice:update')")
public Result getNoticeForm(
- @Parameter(description = "通知公告ID") @PathVariable Long id
+ @Parameter(description = "通知公告ID") @PathVariable Long noticeId
) {
- NoticeForm formData = noticeService.getNoticeFormData(id);
+ NoticeForm formData = noticeService.getNoticeFormData(noticeId);
return Result.success(formData);
}
@Operation(summary = "阅读获取通知公告详情")
- @GetMapping("/{id}/detail")
+ @GetMapping("/{noticeId}/detail")
public Result getNoticeDetail(
- @Parameter(description = "通知公告ID") @PathVariable Long id
+ @Parameter(description = "通知公告ID") @PathVariable Long noticeId
) {
- NoticeDetailVO detailVo = noticeService.getNoticeDetail(id);
+ NoticeDetailVO detailVo = noticeService.getNoticeDetail(noticeId);
return Result.success(detailVo);
}
@Operation(summary = "修改通知公告")
- @PutMapping(value = "/{id}")
+ @PutMapping(value = "/{noticeId}")
@PreAuthorize("@ss.hasPerm('sys:notice:update')")
@Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE)
public Result updateNotice(
- @Parameter(description = "通知公告ID") @PathVariable Long id,
+ @Parameter(description = "通知公告ID") @PathVariable Long noticeId,
@RequestBody @Validated NoticeForm formData
) {
- boolean result = noticeService.updateNotice(id, formData);
+ boolean result = noticeService.updateNotice(noticeId, formData);
return Result.judge(result);
}
@Operation(summary = "发布通知公告")
- @PutMapping("/{id}/publish")
+ @PutMapping("/{noticeId}/publish")
@PreAuthorize("@ss.hasPerm('sys:notice:publish')")
@Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE)
public Result publishNotice(
- @Parameter(description = "通知公告ID") @PathVariable Long id
+ @Parameter(description = "通知公告ID") @PathVariable Long noticeId
) {
- boolean result = noticeService.publishNotice(id);
+ boolean result = noticeService.publishNotice(noticeId);
return Result.judge(result);
}
@Operation(summary = "撤回通知公告")
- @PutMapping("/{id}/revoke")
+ @PutMapping("/{noticeId}/revoke")
@PreAuthorize("@ss.hasPerm('sys:notice:revoke')")
@Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE)
public Result revokeNotice(
- @Parameter(description = "通知公告ID") @PathVariable Long id
+ @Parameter(description = "通知公告ID") @PathVariable Long noticeId
) {
- boolean result = noticeService.revokeNotice(id);
+ boolean result = noticeService.revokeNotice(noticeId);
return Result.judge(result);
}
diff --git a/src/main/java/com/youlai/boot/system/controller/RoleController.java b/src/main/java/com/youlai/boot/system/controller/RoleController.java
index edc7665a..be215f53 100644
--- a/src/main/java/com/youlai/boot/system/controller/RoleController.java
+++ b/src/main/java/com/youlai/boot/system/controller/RoleController.java
@@ -3,14 +3,14 @@ package com.youlai.boot.system.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
-import com.youlai.boot.common.annotation.RepeatSubmit;
+import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.common.result.PageResult;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.system.model.form.RoleForm;
import com.youlai.boot.system.model.query.RoleQuery;
import com.youlai.boot.system.model.vo.RolePageVO;
-import com.youlai.boot.common.annotation.Log;
+import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.system.service.RoleService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -75,10 +75,12 @@ public class RoleController {
}
@Operation(summary = "修改角色")
- @PutMapping(value = "/{id}")
+ @PutMapping(value = "/{roleId}")
@PreAuthorize("@ss.hasPerm('sys:role:update')")
@Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.UPDATE)
- public Result> updateRole(@Valid @RequestBody RoleForm roleForm) {
+ public Result> updateRole(
+ @Parameter(description = "角色ID") @PathVariable Long roleId,
+ @Valid @RequestBody RoleForm roleForm) {
boolean result = roleService.saveRole(roleForm);
return Result.judge(result);
}
diff --git a/src/main/java/com/youlai/boot/system/controller/UserController.java b/src/main/java/com/youlai/boot/system/controller/UserController.java
index ce9a926c..f9deca1b 100644
--- a/src/main/java/com/youlai/boot/system/controller/UserController.java
+++ b/src/main/java/com/youlai/boot/system/controller/UserController.java
@@ -2,8 +2,8 @@ package com.youlai.boot.system.controller;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.ExcelWriter;
-import com.youlai.boot.common.annotation.Log;
-import com.youlai.boot.common.annotation.RepeatSubmit;
+import com.youlai.boot.framework.annotation.Log;
+import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.enums.StatusEnum;
diff --git a/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java b/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java
index e00f27bf..f92e6ae6 100644
--- a/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java
+++ b/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java
@@ -3,7 +3,7 @@ package com.youlai.boot.system.mapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
-import com.youlai.boot.common.annotation.DataPermission;
+import com.youlai.boot.framework.annotation.DataPermission;
import com.youlai.boot.system.model.entity.Dept;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
diff --git a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java
index d07fdcad..1da5402d 100644
--- a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java
+++ b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java
@@ -5,8 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.model.query.UserQuery;
import com.youlai.boot.system.model.form.UserForm;
-import com.youlai.boot.common.annotation.DataPermission;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
+import com.youlai.boot.framework.annotation.DataPermission;
+import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.vo.UserExportVO;
import com.youlai.boot.system.model.vo.UserPageVO;
import com.youlai.boot.system.model.vo.UserProfileVO;
@@ -48,11 +48,7 @@ public interface UserMapper extends BaseMapper {
* @param username 用户名
* @return 认证信息
*/
- UserAuthInfo getAuthInfoByUsername(String username);
-
- default UserAuthInfo getAuthCredentialsByUsername(String username) {
- return getAuthInfoByUsername(username);
- }
+ SecurityUser getAuthInfoByUsername(String username);
/**
* 根据手机号获取用户认证信息
@@ -60,11 +56,7 @@ public interface UserMapper extends BaseMapper {
* @param mobile 手机号
* @return 认证信息
*/
- UserAuthInfo getAuthInfoByMobile(String mobile);
-
- default UserAuthInfo getAuthCredentialsByMobile(String mobile) {
- return getAuthInfoByMobile(mobile);
- }
+ SecurityUser getAuthInfoByMobile(String mobile);
/**
* 获取导出用户列表
diff --git a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java
index 430075d3..0f50ccbb 100644
--- a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java
+++ b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java
@@ -1,7 +1,7 @@
package com.youlai.boot.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
+import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.entity.UserSocial;
import org.apache.ibatis.annotations.Mapper;
@@ -17,6 +17,6 @@ public interface UserSocialMapper extends BaseMapper {
* @param userId 用户ID
* @return 认证信息
*/
- UserAuthInfo getAuthInfoByUserId(Long userId);
+ SecurityUser getAuthInfoByUserId(Long userId);
}
diff --git a/src/main/java/com/youlai/boot/system/model/entity/Config.java b/src/main/java/com/youlai/boot/system/model/entity/Config.java
index c2b26317..586901a7 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/Config.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/Config.java
@@ -1,7 +1,7 @@
package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.*;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.EqualsAndHashCode;
@@ -36,16 +36,19 @@ public class Config extends BaseEntity {
/**
* 描述、备注
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String remark;
/**
* 创建人ID
*/
+ @TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 更新人ID
*/
+ @TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
diff --git a/src/main/java/com/youlai/boot/system/model/entity/Dept.java b/src/main/java/com/youlai/boot/system/model/entity/Dept.java
index 9c95c6a3..44517148 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/Dept.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/Dept.java
@@ -1,9 +1,9 @@
package com.youlai.boot.system.model.entity;
+import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -51,11 +51,13 @@ public class Dept extends BaseEntity {
/**
* 创建人 ID
*/
+ @TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 更新人 ID
*/
+ @TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
diff --git a/src/main/java/com/youlai/boot/system/model/entity/Dict.java b/src/main/java/com/youlai/boot/system/model/entity/Dict.java
index b011aca1..42b47bce 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/Dict.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/Dict.java
@@ -1,7 +1,7 @@
package com.youlai.boot.system.model.entity;
-import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.baomidou.mybatisplus.annotation.*;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -35,6 +35,7 @@ public class Dict extends BaseEntity {
/**
* 备注
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String remark;
/**
diff --git a/src/main/java/com/youlai/boot/system/model/entity/DictItem.java b/src/main/java/com/youlai/boot/system/model/entity/DictItem.java
index afaafb59..fe066941 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/DictItem.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/DictItem.java
@@ -1,7 +1,7 @@
package com.youlai.boot.system.model.entity;
-import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.baomidou.mybatisplus.annotation.*;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -44,20 +44,24 @@ public class DictItem extends BaseEntity {
/**
* 备注
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String remark;
/**
* 标签类型
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String tagType;
/**
* 创建人 ID
*/
+ @TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 更新人 ID
*/
+ @TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
}
\ No newline at end of file
diff --git a/src/main/java/com/youlai/boot/system/model/entity/Menu.java b/src/main/java/com/youlai/boot/system/model/entity/Menu.java
index f7c0fab4..4f3fdac5 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/Menu.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/Menu.java
@@ -2,7 +2,7 @@ package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -36,6 +36,7 @@ public class Menu extends BaseEntity {
/**
* 路由名称(Vue Router 中定义的路由名称)
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String routeName;
/**
@@ -46,16 +47,19 @@ public class Menu extends BaseEntity {
/**
* 组件路径(vue页面完整路径,省略.vue后缀)
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String component;
/**
* 外链地址
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String externalUrl;
/**
* 权限标识
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String perm;
/**
@@ -71,11 +75,13 @@ public class Menu extends BaseEntity {
/**
* 菜单图标
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String icon;
/**
* 跳转路径
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String redirect;
/**
diff --git a/src/main/java/com/youlai/boot/system/model/entity/Notice.java b/src/main/java/com/youlai/boot/system/model/entity/Notice.java
index 9234808d..345bde11 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/Notice.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/Notice.java
@@ -1,9 +1,7 @@
package com.youlai.boot.system.model.entity;
-import com.baomidou.mybatisplus.annotation.TableField;
-import com.baomidou.mybatisplus.annotation.TableName;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.youlai.boot.common.base.BaseEntity;
+import com.baomidou.mybatisplus.annotation.*;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -55,6 +53,7 @@ public class Notice extends BaseEntity {
/**
* 目标用户ID集合
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String targetUserIds;
/**
@@ -75,11 +74,13 @@ public class Notice extends BaseEntity {
/**
* 创建人ID
*/
+ @TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 更新人ID
*/
+ @TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
diff --git a/src/main/java/com/youlai/boot/system/model/entity/Role.java b/src/main/java/com/youlai/boot/system/model/entity/Role.java
index d4f17ee8..130c96f3 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/Role.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/Role.java
@@ -1,9 +1,9 @@
package com.youlai.boot.system.model.entity;
+import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -46,11 +46,13 @@ public class Role extends BaseEntity {
/**
* 创建人 ID
*/
+ @TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 更新人 ID
*/
+ @TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
diff --git a/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java b/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java
index 38109693..e16825d6 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java
@@ -2,7 +2,6 @@ package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
diff --git a/src/main/java/com/youlai/boot/system/model/entity/SysUser.java b/src/main/java/com/youlai/boot/system/model/entity/SysUser.java
index 916042d6..c1457e7d 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/SysUser.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/SysUser.java
@@ -1,7 +1,7 @@
package com.youlai.boot.system.model.entity;
-import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
+import com.baomidou.mybatisplus.annotation.*;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -41,11 +41,13 @@ public class SysUser extends BaseEntity {
/**
* 用户头像
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String avatar;
/**
* 联系方式
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String mobile;
/**
@@ -56,16 +58,19 @@ public class SysUser extends BaseEntity {
/**
* 用户邮箱
*/
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String email;
/**
* 创建人 ID
*/
+ @TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 更新人 ID
*/
+ @TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
diff --git a/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java b/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java
index e3032595..d6ebbd73 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java
@@ -3,8 +3,7 @@ package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.youlai.boot.common.base.BaseEntity;
+import com.youlai.boot.framework.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
diff --git a/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java b/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java
index f8ddff8c..c88b2717 100644
--- a/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java
+++ b/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java
@@ -1,8 +1,8 @@
package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.youlai.boot.common.base.BaseEntity;
-import com.youlai.boot.system.enums.SocialPlatformEnum;
+import com.youlai.boot.framework.base.BaseEntity;
+import com.youlai.boot.common.enums.SocialPlatformEnum;
import lombok.Getter;
import lombok.Setter;
diff --git a/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java b/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java
index 6cded325..7da69882 100644
--- a/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java
+++ b/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java
@@ -1,8 +1,8 @@
package com.youlai.boot.system.model.vo;
-import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
+import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
import java.util.List;
diff --git a/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java b/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java
index cd8fd342..5d9670ab 100644
--- a/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java
+++ b/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java
@@ -1,6 +1,5 @@
package com.youlai.boot.system.model.vo;
-import com.fasterxml.jackson.annotation.JsonFormat;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -70,7 +69,6 @@ public class LogPageVO implements Serializable {
@Schema(description = "错误信息")
private String errorMsg;
- @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
}
diff --git a/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java b/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java
index 92a975bb..27b96916 100644
--- a/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java
+++ b/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java
@@ -1,6 +1,5 @@
package com.youlai.boot.system.model.vo;
-import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -31,9 +30,7 @@ public class RolePageVO {
@Schema(description="数据权限名称")
private String dataScopeLabel;
- @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
- @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}
\ No newline at end of file
diff --git a/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java b/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java
index d911182e..a74d1118 100644
--- a/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java
+++ b/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java
@@ -1,8 +1,8 @@
package com.youlai.boot.system.model.vo;
-import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
+import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
diff --git a/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java b/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java
index 39ebd9a7..5caa4dda 100644
--- a/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java
+++ b/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java
@@ -1,8 +1,8 @@
package com.youlai.boot.system.model.vo;
-import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
+import com.fasterxml.jackson.annotation.JsonFormat;
import java.util.Date;
diff --git a/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java b/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java
new file mode 100644
index 00000000..71cc6422
--- /dev/null
+++ b/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java
@@ -0,0 +1,27 @@
+package com.youlai.boot.system.security.adapter;
+
+import com.youlai.boot.framework.security.port.PermissionPort;
+import com.youlai.boot.system.service.RoleMenuService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Set;
+
+/**
+ * 权限查询适配器。
+ *
+ * 实现 framework 层的 {@link PermissionPort},委托 {@link RoleMenuService} 查询权限集合。
+ *
+ * @see PermissionPort
+ */
+@Component
+@RequiredArgsConstructor
+public class PermissionAdapter implements PermissionPort {
+
+ private final RoleMenuService roleMenuService;
+
+ @Override
+ public Set getRolePerms(Set roleCodes) {
+ return roleMenuService.getRolePermsByRoleCodes(roleCodes);
+ }
+}
diff --git a/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java b/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java
new file mode 100644
index 00000000..e12535e9
--- /dev/null
+++ b/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java
@@ -0,0 +1,39 @@
+package com.youlai.boot.system.security.adapter;
+
+import com.youlai.boot.common.enums.SocialPlatformEnum;
+import com.youlai.boot.framework.security.model.SecurityUser;
+import com.youlai.boot.framework.security.port.UserAuthenticationPort;
+import com.youlai.boot.system.service.UserService;
+import com.youlai.boot.system.service.UserSocialService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 用户认证信息查询适配器。
+ *
+ * 实现 framework 层的 {@link UserAuthenticationPort},委托 system 层服务完成查询。
+ *
+ * @see UserAuthenticationPort
+ */
+@Component
+@RequiredArgsConstructor
+public class UserAuthenticationAdapter implements UserAuthenticationPort {
+
+ private final UserService userService;
+ private final UserSocialService userSocialService;
+
+ @Override
+ public SecurityUser getAuthInfoByUsername(String username) {
+ return userService.getAuthInfoByUsername(username);
+ }
+
+ @Override
+ public SecurityUser getAuthInfoByMobile(String mobile) {
+ return userService.getAuthInfoByMobile(mobile);
+ }
+
+ @Override
+ public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) {
+ return userSocialService.getAuthInfoByOpenid(platform, openid);
+ }
+}
diff --git a/src/main/java/com/youlai/boot/system/service/UserService.java b/src/main/java/com/youlai/boot/system/service/UserService.java
index 48c5f58f..0505cf3f 100644
--- a/src/main/java/com/youlai/boot/system/service/UserService.java
+++ b/src/main/java/com/youlai/boot/system/service/UserService.java
@@ -3,7 +3,7 @@ package com.youlai.boot.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.common.model.Option;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
+import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.vo.CurrentUserVO;
import com.youlai.boot.system.model.vo.UserExportVO;
import com.youlai.boot.system.model.entity.SysUser;
@@ -78,14 +78,9 @@ public interface UserService extends IService {
* 根据用户名获取认证信息
*
* @param username 用户名
- * @return {@link UserAuthInfo}
+ * @return {@link SecurityUser}
*/
- UserAuthInfo getAuthInfoByUsername(String username);
-
- default UserAuthInfo getAuthCredentialsByUsername(String username) {
- return getAuthInfoByUsername(username);
- }
-
+ SecurityUser getAuthInfoByUsername(String username);
/**
* 获取导出用户列表
@@ -194,12 +189,8 @@ public interface UserService extends IService {
* 根据手机号获取用户认证信息
*
* @param mobile 手机号
- * @return {@link UserAuthInfo}
+ * @return {@link SecurityUser}
*/
- UserAuthInfo getAuthInfoByMobile(String mobile);
-
- default UserAuthInfo getAuthCredentialsByMobile(String mobile) {
- return getAuthInfoByMobile(mobile);
- }
+ SecurityUser getAuthInfoByMobile(String mobile);
}
diff --git a/src/main/java/com/youlai/boot/system/service/UserSocialService.java b/src/main/java/com/youlai/boot/system/service/UserSocialService.java
index 943792dc..6d088626 100644
--- a/src/main/java/com/youlai/boot/system/service/UserSocialService.java
+++ b/src/main/java/com/youlai/boot/system/service/UserSocialService.java
@@ -1,8 +1,8 @@
package com.youlai.boot.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
-import com.youlai.boot.system.enums.SocialPlatformEnum;
+import com.youlai.boot.framework.security.model.SecurityUser;
+import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.UserSocial;
/**
@@ -57,7 +57,15 @@ public interface UserSocialService extends IService {
* @param openid openid
* @return 用户认证信息
*/
- UserAuthInfo getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
+ SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
+
+ /**
+ * 根据用户ID获取用户认证信息
+ *
+ * @param userId 用户ID
+ * @return 用户认证信息
+ */
+ SecurityUser getAuthInfoByUserId(Long userId);
/**
* 更新session_key
diff --git a/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java
index 21962565..1702bfc7 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java
@@ -12,7 +12,6 @@ import com.youlai.boot.system.model.form.ConfigForm;
import com.youlai.boot.system.model.query.ConfigQuery;
import com.youlai.boot.system.model.vo.ConfigVO;
import com.youlai.boot.system.service.ConfigService;
-import com.youlai.boot.framework.security.util.SecurityUtils;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
@@ -78,7 +77,6 @@ public class ConfigServiceImpl extends ServiceImpl impleme
super.count(new LambdaQueryWrapper().eq(Config::getConfigKey, configForm.getConfigKey())) == 0,
"配置键已存在");
Config config = configConverter.toEntity(configForm);
- config.setCreateBy(SecurityUtils.getUserId());
return this.save(config);
}
@@ -107,7 +105,6 @@ public class ConfigServiceImpl extends ServiceImpl impleme
super.count(new LambdaQueryWrapper().eq(Config::getConfigKey, configForm.getConfigKey()).ne(Config::getId, id)) == 0,
"配置键已存在");
Config config = configConverter.toEntity(configForm);
- config.setUpdateBy(SecurityUtils.getUserId());
return this.updateById(config);
}
diff --git a/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java
index b2495bfe..c003d5ed 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java
@@ -84,7 +84,7 @@ public class DeptServiceImpl extends ServiceImpl implements De
* @param deptList 部门列表
* @return 部门树形列表
*/
- public List recurDeptList(Long parentId, List deptList) {
+ private List recurDeptList(Long parentId, List deptList) {
return deptList.stream()
.filter(dept -> dept.getParentId().equals(parentId))
.map(dept -> {
@@ -150,7 +150,6 @@ public class DeptServiceImpl extends ServiceImpl implements De
String treePath = generateDeptTreePath(formData.getParentId());
entity.setTreePath(treePath);
- entity.setCreateBy(SecurityUtils.getUserId());
// 保存部门并返回部门ID
boolean result = this.save(entity);
Assert.isTrue(result, "部门保存失败");
@@ -236,7 +235,7 @@ public class DeptServiceImpl extends ServiceImpl implements De
* @param deptList 部门列表
* @return 部门表格层级列表
*/
- public static List> recurDeptTreeOptions(long parentId, List deptList) {
+ private static List> recurDeptTreeOptions(long parentId, List deptList) {
return CollectionUtil.emptyIfNull(deptList).stream()
.filter(dept -> dept.getParentId().equals(parentId))
.map(dept -> {
diff --git a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java
index 5052c03c..d4e23147 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java
@@ -95,7 +95,6 @@ public class NoticeServiceImpl extends ServiceImpl impleme
}
}
Notice entity = noticeConverter.toEntity(formData);
- entity.setCreateBy(SecurityUtils.getUserId());
return this.save(entity);
}
@@ -250,7 +249,6 @@ public class NoticeServiceImpl extends ServiceImpl impleme
notice.setPublishStatus(NoticePublishStatusEnum.REVOKED.getValue());
notice.setRevokeTime(LocalDateTime.now());
- notice.setUpdateBy(SecurityUtils.getUserId());
boolean revokeResult = this.updateById(notice);
diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java
index 8ffef8d4..84b80775 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java
@@ -16,7 +16,7 @@ import com.youlai.boot.framework.integration.mail.service.MailService;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
import com.youlai.boot.framework.security.model.RoleDataScope;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
+import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.system.converter.UserConverter;
@@ -133,7 +133,6 @@ public class UserServiceImpl extends ServiceImpl implements
// 设置默认加密密码
String defaultEncryptPwd = passwordEncoder.encode(SystemConstants.DEFAULT_PASSWORD);
entity.setPassword(defaultEncryptPwd);
- entity.setCreateBy(SecurityUtils.getUserId());
// 新增用户
boolean result = this.save(entity);
@@ -171,7 +170,6 @@ public class UserServiceImpl extends ServiceImpl implements
// form -> entity
SysUser entity = userConverter.toEntity(userForm);
- entity.setUpdateBy(SecurityUtils.getUserId());
// 修改用户
boolean result = this.updateById(entity);
@@ -206,39 +204,34 @@ public class UserServiceImpl extends ServiceImpl implements
* 根据用户名获取认证凭证信息
*
* @param username 用户名
- * @return 用户认证凭证信息 {@link UserAuthInfo}
+ * @return 用户认证凭证信息 {@link SecurityUser}
*/
@Override
- public UserAuthInfo getAuthInfoByUsername(String username) {
- UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username);
- if (userAuthInfo != null) {
- Set roles = userAuthInfo.getRoles();
- // 获取数据权限列表(用于并集策略)
+ public SecurityUser getAuthInfoByUsername(String username) {
+ SecurityUser securityUser = this.baseMapper.getAuthInfoByUsername(username);
+ if (securityUser != null) {
+ Set roles = securityUser.getRoles();
List dataScopes = roleService.getRoleDataScopes(roles);
- userAuthInfo.setDataScopes(dataScopes);
+ securityUser.setDataScopes(dataScopes);
}
- return userAuthInfo;
+ return securityUser;
}
/**
* 根据手机号获取用户认证信息
- *
- * @param mobile 手机号
- * @return 用户认证信息
*/
@Override
- public UserAuthInfo getAuthInfoByMobile(String mobile) {
+ public SecurityUser getAuthInfoByMobile(String mobile) {
if (StrUtil.isBlank(mobile)) {
return null;
}
- UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile);
- if (userAuthInfo != null) {
- Set roles = userAuthInfo.getRoles();
- // 获取数据权限列表(用于并集策略)
+ SecurityUser securityUser = this.baseMapper.getAuthInfoByMobile(mobile);
+ if (securityUser != null) {
+ Set roles = securityUser.getRoles();
List dataScopes = roleService.getRoleDataScopes(roles);
- userAuthInfo.setDataScopes(dataScopes);
+ securityUser.setDataScopes(dataScopes);
}
- return userAuthInfo;
+ return securityUser;
}
/**
diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java
index f9a37c59..d0891975 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java
@@ -3,8 +3,8 @@ package com.youlai.boot.system.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
-import com.youlai.boot.framework.security.model.UserAuthInfo;
-import com.youlai.boot.system.enums.SocialPlatformEnum;
+import com.youlai.boot.framework.security.model.SecurityUser;
+import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.mapper.UserSocialMapper;
import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService;
@@ -86,7 +86,7 @@ public class UserSocialServiceImpl extends ServiceImpl",
+ "description": "允许的文件扩展名白名单(置空表示不限制)"
+ },
+ {
+ "name": "file-storage.minio.endpoint",
"type": "java.lang.String",
"description": "MinIO 服务 Endpoint"
},
{
- "name": "oss.minio.access-key",
+ "name": "file-storage.minio.access-key",
"type": "java.lang.String",
"description": "MinIO 访问凭据"
},
{
- "name": "oss.minio.secret-key",
+ "name": "file-storage.minio.secret-key",
"type": "java.lang.String",
"description": "MinIO 凭据密钥"
},
{
- "name": "oss.minio.bucket-name",
+ "name": "file-storage.minio.bucket",
"type": "java.lang.String",
"description": "MinIO 存储桶名称"
},
{
- "name": "oss.minio.custom-domain",
+ "name": "file-storage.minio.domain",
"type": "java.lang.String",
- "description": "MinIO 自定义域名"
+ "description": "MinIO 自定义域名(文件 URL 域名)"
},
{
- "name": "oss.aliyun.endpoint",
+ "name": "file-storage.aliyun.endpoint",
"type": "java.lang.String",
"description": "阿里云 OSS 服务 Endpoint"
},
{
- "name": "oss.aliyun.access-key-id",
+ "name": "file-storage.aliyun.access-key-id",
"type": "java.lang.String",
"description": "阿里云 OSS 访问凭据 ID"
},
{
- "name": "oss.aliyun.access-key-secret",
+ "name": "file-storage.aliyun.access-key-secret",
"type": "java.lang.String",
"description": "阿里云 OSS 凭据密钥"
},
{
- "name": "oss.aliyun.bucket-name",
+ "name": "file-storage.aliyun.bucket",
"type": "java.lang.String",
"description": "阿里云 OSS 存储桶名称"
},
+ {
+ "name": "file-storage.local.path",
+ "type": "java.lang.String",
+ "description": "本地存储根目录(相对路径基于工作目录)"
+ },
{
"name": "xxl.job.enabled",
"type": "java.lang.Boolean",
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index b1346bbe..84b2b9d0 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -15,7 +15,7 @@ spring:
data:
redis:
- database: 12
+ database: 0
host: 175.178.213.60
port: 26379
password: fanhuitong
@@ -83,9 +83,11 @@ mybatis-plus:
# 接口限流
rate-limit:
default-limit: 60
- default-window: 60
+ default-window: 60s
ip:
- enabled: false # 开发环境关闭 IP 全局限流
+ enabled: true # 开发环境开启 IP 全局限流(供限流测试)
+ limit: 1000
+ window: 60s
# 安全配置
security:
@@ -105,6 +107,9 @@ security:
- /api/v1/auth/refresh-token # 刷新令牌接口
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
+ - /api/v1/auth/qr-code/generate # 扫码登录-生成票据
+ - /api/v1/auth/qr-code/status # 扫码登录-查询状态
+ - /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
@@ -116,36 +121,39 @@ security:
- /favicon.ico
- /error
-# 文件存储配置
-oss:
- # OSS 类型 (目前支持aliyun、minio)
+# 文件存储
+file-storage:
+ # 存储类型:minio | aliyun | local
type: minio
- # MinIO 对象存储服务
+ # 上传限制
+ upload:
+ # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
+ max-file-size: 50MB
+ # 允许的文件扩展名白名单(置空表示不限制)
+ allowed-extensions:
+ - jpg
+ - jpeg
+ - png
+ - gif
+ # MinIO 对象存储
minio:
- # 服务Endpoint
- endpoint: http://localhost:9000
- # 访问凭据
- access-key: minioadmin
- # 凭据密钥
- secret-key: minioadmin
- # 存储桶名称
- bucket-name: public
- # (可选)自定义域名,如果配置了域名,生成的文件URL是域名格式,未配置则URL则是IP格式 (eg: https://www.youlai.tech/storage)
- custom-domain:
- # 阿里云OSS对象存储服务
+ endpoint: http://111.229.83.153:9000
+ access-key: bybaddp7zyARpgNbEGKf
+ secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj
+ bucket: public
+ # 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式
+ domain:
+ # 阿里云 OSS
aliyun:
- # 服务Endpoint
endpoint: oss-cn-hangzhou.aliyuncs.com
- # 访问凭据
access-key-id: your-access-key-id
- # 凭据密钥
access-key-secret: your-access-key-secret
- # 存储桶名称
- bucket-name: default
+ bucket: default
# 本地存储
local:
- # 文件存储路径 请注意下,mac用户请使用 /Users/your-username/your-path/,否则会有权限问题,windows用户请使用 D:/your-path/
- storage-path: /Users/theo/home/
+ # 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)
+ path: ./uploads/
+
# 短信配置
sms:
# 阿里云短信
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 643e2bd6..19e1c4a7 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -10,7 +10,7 @@ spring:
password: fanhuitong
data:
redis:
- database: 11
+ database: 1
host: 175.178.213.60
port: 26379
password: fanhuitong
@@ -77,11 +77,11 @@ mybatis-plus:
# 接口限流
rate-limit:
default-limit: 10
- default-window: 1
+ default-window: 60s
ip:
enabled: true
limit: 1000 # IP 全局:1000 req / 60s
- window-seconds: 60
+ window: 60s
# 安全配置
security:
@@ -101,6 +101,9 @@ security:
- /api/v1/auth/refresh-token # 刷新令牌接口
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
+ - /api/v1/auth/qr-code/generate # 扫码登录-生成票据
+ - /api/v1/auth/qr-code/status # 扫码登录-查询状态
+ - /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
@@ -112,36 +115,38 @@ security:
- /favicon.ico
- /error
-# 文件存储配置
-oss:
- # OSS 类型 (目前支持aliyun、minio)
+# 文件存储
+file-storage:
+ # 存储类型:minio | aliyun | local
type: minio
- # MinIO 对象存储服务
+ # 上传限制
+ upload:
+ # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
+ max-file-size: 50MB
+ # 允许的文件扩展名白名单(置空表示不限制)
+ allowed-extensions:
+ - jpg
+ - jpeg
+ - png
+ - gif
+ # MinIO 对象存储
minio:
- # 服务Endpoint
endpoint: http://localhost:9000
- # 访问凭据
access-key: minioadmin
- # 凭据密钥
secret-key: minioadmin
- # 存储桶名称
- bucket-name: public
- # (可选)自定义域名,如果配置了域名,生成的文件URL是域名格式,未配置则URL则是IP格式 (eg: https://www.youlai.tech/storage)
- custom-domain:
- # 阿里云OSS对象存储服务
+ bucket: public
+ # 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式
+ domain:
+ # 阿里云 OSS
aliyun:
- # 服务Endpoint
endpoint: oss-cn-hangzhou.aliyuncs.com
- # 访问凭据
access-key-id: your-access-key-id
- # 凭据密钥
access-key-secret: your-access-key-secret
- # 存储桶名称
- bucket-name: default
+ bucket: default
# 本地存储
local:
- # 文件存储路径 请注意下,mac用户请使用 /Users/your-username/your-path/,否则会有权限问题,windows用户请使用 D:/your-path/
- storage-path: /Users/theo/home/
+ # 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)
+ path: ./uploads/
# 短信配置
sms:
# 阿里云短信
@@ -173,8 +178,9 @@ springdoc:
packages-to-scan: # 扫描的 Controller 包,限制只生成指定包的接口文档
- com.youlai.boot.auth.controller
- com.youlai.boot.system.controller
- - com.youlai.boot.module.file.controller
- - com.youlai.boot.module.codegen.controller
+ - com.youlai.boot.file.controller
+ - com.youlai.boot.codegen.controller
+ - com.youlai.boot.message.controller
default-flat-param-object: true # 将对象参数扁平化显示在文档中
# knife4j 配置
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index d1ce1eb8..d0e236f3 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -7,8 +7,9 @@ spring:
import: classpath:codegen.yml
servlet:
multipart:
- max-file-size: 50MB
- max-request-size: 50MB
+ # 单文件大小上限,取自 file-storage.upload.max-file-size(单一来源)
+ max-file-size: ${file-storage.upload.max-file-size:50MB}
+ max-request-size: ${file-storage.upload.max-file-size:50MB}
# 在 banner.txt 中显示项目版本,使用 @project.version@ 从 pom.xml 获取
project:
version: @project.version@
diff --git a/src/main/resources/mapper/codegen/GenTableColumnMapper.xml b/src/main/resources/mapper/codegen/GenTableColumnMapper.xml
index 8cb86c3d..6a09a381 100644
--- a/src/main/resources/mapper/codegen/GenTableColumnMapper.xml
+++ b/src/main/resources/mapper/codegen/GenTableColumnMapper.xml
@@ -2,6 +2,6 @@
-
+
diff --git a/src/main/resources/mapper/codegen/GenTableMapper.xml b/src/main/resources/mapper/codegen/GenTableMapper.xml
index 0271fa20..74e7ca3e 100644
--- a/src/main/resources/mapper/codegen/GenTableMapper.xml
+++ b/src/main/resources/mapper/codegen/GenTableMapper.xml
@@ -2,6 +2,6 @@
-
+
diff --git a/src/main/resources/mapper/system/ConfigMapper.xml b/src/main/resources/mapper/system/ConfigMapper.xml
deleted file mode 100644
index f291b895..00000000
--- a/src/main/resources/mapper/system/ConfigMapper.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/src/main/resources/mapper/system/DeptMapper.xml b/src/main/resources/mapper/system/DeptMapper.xml
deleted file mode 100644
index fadf8a06..00000000
--- a/src/main/resources/mapper/system/DeptMapper.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/src/main/resources/mapper/system/UserMapper.xml b/src/main/resources/mapper/system/UserMapper.xml
index b70f0ae6..c6ddbe80 100644
--- a/src/main/resources/mapper/system/UserMapper.xml
+++ b/src/main/resources/mapper/system/UserMapper.xml
@@ -118,7 +118,7 @@
-
+
diff --git a/src/main/resources/mapper/system/UserSocialMapper.xml b/src/main/resources/mapper/system/UserSocialMapper.xml
index abd7bd64..5856c87d 100644
--- a/src/main/resources/mapper/system/UserSocialMapper.xml
+++ b/src/main/resources/mapper/system/UserSocialMapper.xml
@@ -5,13 +5,14 @@
-
+
+
@@ -26,6 +27,7 @@
u.dept_id,
u.password,
u.status,
+ u.avatar,
r.code
FROM
sys_user u
diff --git a/src/main/resources/templates/codegen/backend/entity.java.vm b/src/main/resources/templates/codegen/backend/entity.java.vm
index 15a8dd43..7bb0d76e 100644
--- a/src/main/resources/templates/codegen/backend/entity.java.vm
+++ b/src/main/resources/templates/codegen/backend/entity.java.vm
@@ -9,7 +9,7 @@ import java.time.LocalDateTime;
import java.math.BigDecimal;
#end
import com.baomidou.mybatisplus.annotation.TableName;
-import ${packageName}.common.base.BaseEntity;
+import ${packageName}.framework.base.BaseEntity;
/**
* $!{businessName}实体对象
diff --git a/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java b/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java
index afd1e13d..14979f0d 100644
--- a/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java
+++ b/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java
@@ -3,7 +3,7 @@ package com.youlai.boot.security.token;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.RoleDataScope;
-import com.youlai.boot.framework.security.model.SysUserDetails;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.JwtTokenManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -16,7 +16,6 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.Collection;
import java.util.List;
@@ -92,7 +91,7 @@ class JwtTokenManagerTest {
assertThat(parsed).isNotNull();
assertThat(parsed.getName()).isEqualTo("testuser");
- SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal();
+ SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal();
assertThat(userDetails.getUserId()).isEqualTo(1L);
assertThat(userDetails.getDeptId()).isEqualTo(100L);
}
@@ -120,7 +119,7 @@ class JwtTokenManagerTest {
AuthenticationToken token = tokenManager.generateToken(authentication);
Authentication parsed = tokenManager.parseToken(token.getAccessToken());
- SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal();
+ SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal();
List dataScopes = userDetails.getDataScopes();
assertThat(dataScopes).hasSize(2);
@@ -270,9 +269,9 @@ class JwtTokenManagerTest {
Authentication parsed = tokenManager.parseToken(token.getAccessToken());
assertThat(parsed).isInstanceOf(UsernamePasswordAuthenticationToken.class);
- assertThat(parsed.getPrincipal()).isInstanceOf(SysUserDetails.class);
+ assertThat(parsed.getPrincipal()).isInstanceOf(SecurityUserDetails.class);
- SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal();
+ SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal();
assertThat(userDetails.getUserId()).isEqualTo(1L);
assertThat(userDetails.getDeptId()).isEqualTo(100L);
assertThat(userDetails.getUsername()).isEqualTo("testuser");
@@ -285,7 +284,7 @@ class JwtTokenManagerTest {
AuthenticationToken token = tokenManager.generateToken(authentication);
Authentication parsed = tokenManager.parseToken(token.getAccessToken());
- SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal();
+ SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal();
List dataScopes = userDetails.getDataScopes();
assertThat(dataScopes).hasSize(1);
@@ -313,7 +312,7 @@ class JwtTokenManagerTest {
}
private Authentication createTestAuthentication() {
- SysUserDetails userDetails = new SysUserDetails();
+ SecurityUserDetails userDetails = new SecurityUserDetails();
userDetails.setUserId(1L);
userDetails.setUsername("testuser");
userDetails.setDeptId(100L);
@@ -322,10 +321,7 @@ class JwtTokenManagerTest {
new RoleDataScope("ADMIN", 1, null), // 全部数据权限
new RoleDataScope("USER", 4, null) // 本人数据权限
));
- userDetails.setAuthorities(Set.of(
- new SimpleGrantedAuthority("ROLE_ADMIN"),
- new SimpleGrantedAuthority("ROLE_USER")
- ));
+ userDetails.setRoles(Set.of("ADMIN", "USER"));
return new UsernamePasswordAuthenticationToken(
userDetails,
@@ -335,7 +331,7 @@ class JwtTokenManagerTest {
}
private Authentication createTestAuthenticationWithCustomScope() {
- SysUserDetails userDetails = new SysUserDetails();
+ SecurityUserDetails userDetails = new SecurityUserDetails();
userDetails.setUserId(2L);
userDetails.setUsername("customuser");
userDetails.setDeptId(200L);
@@ -343,9 +339,7 @@ class JwtTokenManagerTest {
userDetails.setDataScopes(List.of(
new RoleDataScope("CUSTOM_ROLE", 5, List.of(10L, 20L, 30L)) // 自定义部门权限
));
- userDetails.setAuthorities(Set.of(
- new SimpleGrantedAuthority("ROLE_CUSTOM")
- ));
+ userDetails.setRoles(Set.of("CUSTOM"));
return new UsernamePasswordAuthenticationToken(
userDetails,