Эх сурвалжийг харах

feat: JWT角色区分过期时间 & TokenVersionCache迁移至Caffeine

- JWT Token 过期时间按角色区分:管理员 12h / 普通用户 1h
- TokenVersionCache 从 ConcurrentHashMap 迁移至 Caffeine (W-TinyLFU 淘汰策略)
- 清理 JwtUtil 中未使用的旧 expiration 字段
- AuthService.heartbeat 缓存 miss 时回退查 DB
- 修复: /api/auth/user/info 从 LENIENT_PATHS 移除
- 修复: EndToEndBusinessFlowTest stage7/8 用 SMS 登录获取 admin token
- 同步更新产品文档、测试文档、验收标准
liuchengsen 1 сар өмнө
parent
commit
58844fa382

+ 1 - 1
docs/01-产品文档.md

@@ -79,7 +79,7 @@
 | 密码登录 | 手机号/昵称 + 密码(BCrypt加密),支持设置/修改密码 |
 | 微信扫码登录 | 微信开放平台扫码,首次登录需绑定手机号 |
 | 修改手机号 | 已登录用户通过验证码验证后修改绑定手机号 |
-| Token 管理 | Access Token(24h)+ Refresh Token(7d) |
+| Token 管理 | Access Token(管理员12h / 普通用户1h)+ Refresh Token(7d),支持多设备登录控制 |
 | 用户信息 | 昵称、头像、手机号、会员等级、药店名称、地区信息 |
 | 登录来源追踪 | 记录登录设备、登录IP、登录来源(WINDOWS/MINIAPP/ANDROID/IOS/WECHAT) |
 

+ 14 - 1
docs/05-测试文档.md

@@ -171,7 +171,20 @@
 | CPN-03 | GET /api/coupon/my/available - 可用优惠券 | P2 |
 | CPN-04 | 爬虫消耗优先使用优惠券 | P1 |
 
-### 3.5 重复支付与退款测试
+### 3.5 JWT Token 角色区分测试
+
+| 编号 | 用例 | 优先级 |
+|------|------|--------|
+| JWT-01 | 管理员Token过期时间12小时 | P0 |
+| JWT-02 | 普通用户Token过期时间1小时 | P0 |
+| JWT-03 | SUPER_ADMIN生成Token过期时间为12小时 | P1 |
+| JWT-04 | getExpirationInSeconds("ADMIN") 返回 43200 秒 | P1 |
+| JWT-05 | getExpirationInSeconds("USER") 返回 3600 秒 | P1 |
+| JWT-06 | TokenVersionCache 超过10万上限时触发淘汰 | P2 |
+| JWT-07 | TokenVersionCache 定时统计日志输出 | P2 |
+| JWT-08 | heartbeat 缓存miss时回退查DB并回填缓存 | P1 |
+
+### 3.6 重复支付与退款测试
 
 | 编号 | 用例 | 优先级 |
 |------|------|--------|

+ 3 - 1
docs/06-验收流程与标准.md

@@ -42,7 +42,7 @@
 | FA-04 | 邀请码注册 | 携带邀请码注册,自动建立邀请关系并发放会员奖励 | P1 |
 | FA-05 | 微信扫码登录 | 扫码后获取授权码,登录成功 | P0 |
 | FA-06 | 微信绑定手机号 | 首次微信登录引导绑定手机号 | P1 |
-| FA-07 | Token刷新 | Refresh Token有效期内可刷新 | P1 |
+| FA-07 | Token刷新与过期 | Refresh Token有效期内可刷新;管理员Access Token 12小时过期,普通用户1小时过期 | P1 |
 | FA-08 | 验证码过期 | 5分钟后验证码失效 | P1 |
 | FA-09 | 密码登录(手机号) | 手机号+正确密码登录成功,返回Token | P0 |
 | FA-10 | 密码登录(昵称) | 昵称+正确密码登录成功,返回Token | P1 |
@@ -241,6 +241,8 @@
 | SA-12 | 支付回调验签 | 生产环境微信/支付宝回调验签通过 | 代码审查 |
 | SA-13 | 重复支付退款安全 | 退款仅触发一次(幂等),退款失败不影响主流程 | 代码审查 |
 | SA-14 | CORS配置 | 跨域配置正确,不暴露敏感头 | 配置检查 |
+| SA-15 | 多设备登录控制 | 管理员最多5台设备同时登录,超出则踢出最早设备 | 接口测试 |
+| SA-16 | TokenVersionCache | 缓存不超过10万上限,超限自动淘汰,定时监控输出 | 日志检查 |
 
 ---
 

+ 3 - 1
zhijiayun-gateway/src/main/resources/application-prod.yml

@@ -37,9 +37,11 @@ spring:
     init:
       mode: never
 
-# JWT 密钥:生产部署时通过 JWT_SECRET 环境变量覆盖
+# JWT 密钥:生产部署时通过环境变量覆盖
 jwt:
   secret: ${JWT_SECRET:YourSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!}
+  user-expiration: 3600000         # 1小时(毫秒)
+  admin-expiration: 43200000       # 12小时(毫秒)
   expiration: 300000
   refresh-expiration: 604800000
 

+ 5 - 3
zhijiayun-gateway/src/main/resources/application.yml

@@ -30,11 +30,13 @@ spring:
       encoding: utf-8
       continue-on-error: true  # 允许ALTER TABLE重复列报错,不影响启动
 
-# JWT 配置(公共,各环境可覆盖 secret
+# JWT 配置(公共,各环境可覆盖)
 jwt:
   secret: YourSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!
-  expiration: 86400000       # 24小时(毫秒)
-  refresh-expiration: 604800000  # 7天(毫秒)
+  user-expiration: 3600000         # 普通用户 1小时(毫秒)
+  admin-expiration: 43200000       # 管理员 12小时(毫秒)
+  expiration: 86400000             # 旧字段兼容
+  refresh-expiration: 604800000    # 7天(毫秒)
 
 # 微信开放平台配置(公共,各环境可覆盖 app-id/app-secret)
 wechat:

+ 4 - 8
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/EndToEndBusinessFlowTest.java

@@ -246,17 +246,13 @@ public class EndToEndBusinessFlowTest extends GatewayBaseTest {
     public void stage7_AdminReviewLicense() throws Exception {
         log.info("\n========== 阶段7:管理员审核 ==========");
 
-        adminToken = generateAccessToken(1L, "13800138000");
-
-        // generateAccessToken 将 DB token_version 重置为 0,导致 stage1 获取的 userToken(version=1)失效
-        // 重新 SMS 登录刷新 userToken,使 token_version 递增到 1,与 DB 保持一致
-        MvcResult refreshResult = mockMvc.perform(post("/api/auth/sms/login")
+        MvcResult adminLoginResult = mockMvc.perform(post("/api/auth/sms/login")
                 .contentType(MediaType.APPLICATION_JSON)
-                .content("{\"phone\":\"13800138000\",\"code\":\"123456\"}"))
+                .content("{\"phone\":\"13900000000\",\"code\":\"123456\"}"))
                 .andExpect(status().isOk())
                 .andReturn();
-        JsonNode refreshData = objectMapper.readTree(refreshResult.getResponse().getContentAsString()).get("data");
-        userToken = refreshData.get("accessToken").asText();
+        JsonNode adminData = objectMapper.readTree(adminLoginResult.getResponse().getContentAsString()).get("data");
+        adminToken = adminData.get("accessToken").asText();
 
         // 7.1 查询待审核执照列表
         mockMvc.perform(get("/api/admin/license/review?reviewStatus=PENDING")

+ 2 - 0
zhijiayun-gateway/src/test/resources/application-test.yml

@@ -40,6 +40,8 @@ spring:
 # JWT 配置(测试用)
 jwt:
   secret: TestSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!
+  user-expiration: 3600000         # 1小时(毫秒)
+  admin-expiration: 43200000       # 12小时(毫秒)
   expiration: 86400000
   refresh-expiration: 604800000
 

+ 6 - 0
zhijiayun-user/pom.xml

@@ -88,6 +88,12 @@
             <artifactId>hutool-json</artifactId>
         </dependency>
 
+        <!-- Caffeine 本地缓存(TokenVersionCache) -->
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+
         <!-- BouncyCastle: 覆盖传递依赖,修复 CVE-2026-0636 -->
         <dependency>
             <groupId>org.bouncycastle</groupId>

+ 1 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -53,8 +53,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
     private static final String TOKEN_PREFIX = "Bearer ";
     /** 允许使用过期 token 访问的路径(续期/心跳类接口) */
     private static final String[] LENIENT_PATHS = {
-            "/api/auth/heartbeat",
-            "/api/auth/user/info"
+            "/api/auth/heartbeat"
     };
 
     @Override

+ 29 - 7
zhijiayun-user/src/main/java/com/xuekairui/user/security/TokenVersionCache.java

@@ -1,31 +1,40 @@
 package com.xuekairui.user.security;
 
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.stats.CacheStats;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
-
 /**
  * Token 版本号内存缓存(避免每次请求都查 DB)
  * <p>
  * 用于单设备登录校验:缓存 userId → tokenVersion 的映射,
  * 登录时更新缓存,过滤器直接从缓存读取版本号与 JWT 中的版本号比对。
  * 缓存未命中时回退到 DB 查询。
+ * <p>
+ * 使用 Caffeine W-TinyLFU 淘汰策略,上限 10 万条,
+ * 超限自动淘汰低频条目,无需手动维护。
  */
 @Slf4j
 @Component
 public class TokenVersionCache {
 
-    /** 简单 ConcurrentHashMap 缓存,无 TTL(登录时主动更新,用户量不大时无风险) */
-    private final ConcurrentMap<Long, Long> cache = new ConcurrentHashMap<>();
+    /** 缓存上限(超过后 Caffeine 自动按 W-TinyLFU 淘汰低频条目) */
+    private static final int MAX_SIZE = 100_000;
+
+    private final Cache<Long, Long> cache = Caffeine.newBuilder()
+            .maximumSize(MAX_SIZE)
+            .recordStats()
+            .build();
 
     /**
      * 获取用户当前 Token 版本号
      * @return 缓存的版本号,未命中返回 null(调用方需回退 DB 查询)
      */
     public Long getVersion(Long userId) {
-        return cache.get(userId);
+        return cache.getIfPresent(userId);
     }
 
     /**
@@ -37,10 +46,23 @@ public class TokenVersionCache {
         }
     }
 
+    /**
+     * 每小时输出缓存命中率和大小,便于监控
+     */
+    @Scheduled(fixedRate = 3600000)
+    public void reportStats() {
+        CacheStats stats = cache.stats();
+        long size = cache.estimatedSize();
+        if (size > MAX_SIZE / 2 || stats.evictionCount() > 0) {
+            log.info("TokenVersionCache 大小: {}, 命中率: {}%, 淘汰数: {}",
+                    size, String.format("%.1f", stats.hitRate() * 100), stats.evictionCount());
+        }
+    }
+
     /**
      * 清空所有缓存(测试用)
      */
     public void clear() {
-        cache.clear();
+        cache.invalidateAll();
     }
 }

+ 10 - 5
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -229,10 +229,15 @@ public class AuthService {
      */
     public LoginResponse heartbeat(Long userId, String role) {
         String roleCode = role != null ? role : "USER";
-        // 从缓存获取 tokenVersion(过滤器已缓存),不查DB
+        // 从缓存获取 tokenVersion(过滤器大概率已缓存),缓存 miss 时回退 DB
         Long currentVersion = tokenVersionCache.getVersion(userId);
         if (currentVersion == null) {
-            currentVersion = 0L;
+            User user = userMapper.selectById(userId);
+            currentVersion = user != null && user.getTokenVersion() != null
+                    ? user.getTokenVersion() : 0L;
+            if (user != null) {
+                tokenVersionCache.putVersion(userId, currentVersion);
+            }
         }
 
         // 签发新 Token(无需查DB,JWT中已有所有必要信息)
@@ -252,7 +257,7 @@ public class AuthService {
                 .userId(userId)
                 .accessToken(newAccessToken)
                 .refreshToken(newRefreshToken)
-                .expiresIn(jwtUtil.getExpirationInSeconds())
+                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
                 .needBindPhone(false)
                 .role(roleCode)
                 .build();
@@ -337,7 +342,7 @@ public class AuthService {
                 .userId(user.getId())
                 .accessToken(newAccessToken)
                 .refreshToken(newRefreshToken)
-                .expiresIn(jwtUtil.getExpirationInSeconds())
+                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
                 .userInfo(convertToUserInfoResponse(user))
                 .needBindPhone(false)
                 .newUser(false)
@@ -383,7 +388,7 @@ public class AuthService {
                 .userId(user.getId())
                 .accessToken(accessToken)
                 .refreshToken(refreshToken)
-                .expiresIn(jwtUtil.getExpirationInSeconds())
+                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
                 .userInfo(convertToUserInfoResponse(user))
                 .needBindPhone(false)
                 .newUser(false)

+ 20 - 35
zhijiayun-user/src/main/java/com/xuekairui/user/util/JwtUtil.java

@@ -26,20 +26,33 @@ public class JwtUtil {
     @Value("${jwt.secret:}")
     private String secret;
 
-    @Value("${jwt.expiration:}")
-    private Long expiration;
+    @Value("${jwt.user-expiration:3600000}")
+    private Long userExpiration;       // 默认 1 小时
+    @Value("${jwt.admin-expiration:43200000}")
+    private Long adminExpiration;      // 默认 12 小时
 
     @Value("${jwt.refresh-expiration:}")
     private Long refreshExpiration;
 
+    private static final String ROLE_ADMIN = "ADMIN";
+    private static final String ROLE_SUPER_ADMIN = "SUPER_ADMIN";
+
     private SecretKey getSigningKey() {
         return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
     }
 
+    private boolean isAdminRole(String role) {
+        return ROLE_ADMIN.equals(role) || ROLE_SUPER_ADMIN.equals(role);
+    }
+
     /**
      * 生成访问令牌(含 tokenVersion 用于单设备登录控制)
+     * <p>
+     * 管理员 12 小时过期,普通用户 1 小时过期,共用同一密钥签名。
      */
     public String generateAccessToken(Long userId, String role, Long tokenVersion) {
+        long expirationMs = isAdminRole(role) ? adminExpiration : userExpiration;
+
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
         claims.put("role", role != null ? role : "USER");
@@ -50,7 +63,7 @@ public class JwtUtil {
                 .claims(claims)
                 .subject(String.valueOf(userId))
                 .issuedAt(new Date())
-                .expiration(new Date(System.currentTimeMillis() + expiration))
+                .expiration(new Date(System.currentTimeMillis() + expirationMs))
                 .signWith(getSigningKey())
                 .compact();
     }
@@ -145,35 +158,6 @@ public class JwtUtil {
         return claims.get("type", String.class);
     }
 
-    /**
-     * 获取用户角色(兼容旧 Token,默认 USER)
-     */
-    public String getRole(String token) {
-        try {
-            Claims claims = parseToken(token);
-            String role = claims.get("role", String.class);
-            return role != null ? role : "USER";
-        } catch (Exception e) {
-            return "USER";
-        }
-    }
-
-    /**
-     * 获取Token版本号(用于单设备登录校验)
-     */
-    public Long getTokenVersion(String token) {
-        try {
-            Claims claims = parseToken(token);
-            Object version = claims.get("tokenVersion");
-            if (version instanceof Number) {
-                return ((Number) version).longValue();
-            }
-            return 0L;
-        } catch (Exception e) {
-            return 0L;
-        }
-    }
-
     /**
      * 对 Refresh Token 取 SHA-256 哈希(存储到DB用于旋转+重用检测)
      */
@@ -192,9 +176,10 @@ public class JwtUtil {
     }
 
     /**
-     * 获取过期时间(秒)
+     * 获取 access token 过期时间(秒),按角色区分
      */
-    public Long getExpirationInSeconds() {
-        return expiration / 1000;
+    public Long getExpirationInSeconds(String role) {
+        long expirationMs = isAdminRole(role) ? adminExpiration : userExpiration;
+        return expirationMs / 1000;
     }
 }