liuchengsen 1 месяц назад
Родитель
Сommit
ac95077d42
31 измененных файлов с 861 добавлено и 668 удалено
  1. 1 0
      zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java
  2. 1 1
      zhijiayun-gateway/src/main/resources/application-prod.yml
  3. 12 12
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/BusinessLicenseAndCouponTest.java
  4. 9 2
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayBaseTest.java
  5. 13 13
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayFilterTest.java
  6. 4 4
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GlobalExceptionHandlerTest.java
  7. 18 17
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/SecurityAccessTest.java
  8. 0 122
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/UserLevelControllerTest.java
  9. 35 0
      zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteConfigRequest.java
  10. 46 0
      zhijiayun-invite/src/main/java/com/xuekairui/invite/entity/InviteConfig.java
  11. 34 0
      zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteConfigService.java
  12. 55 32
      zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java
  13. 82 15
      zhijiayun-test/src/main/resources/static/invite-test.html
  14. 3 6
      zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java
  15. 7 4
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminMembershipController.java
  16. 21 3
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminUserController.java
  17. 15 1
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AuthController.java
  18. 26 5
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/MembershipController.java
  19. 0 63
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/UserLevelController.java
  20. 6 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/LoginResponse.java
  21. 10 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/User.java
  22. 57 3
      zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java
  23. 39 0
      zhijiayun-user/src/main/java/com/xuekairui/user/security/TokenVersionCache.java
  24. 27 2
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminUserService.java
  25. 122 8
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java
  26. 2 2
      zhijiayun-user/src/main/java/com/xuekairui/user/service/CouponService.java
  27. 3 3
      zhijiayun-user/src/main/java/com/xuekairui/user/service/CrawlerService.java
  28. 174 13
      zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java
  29. 0 324
      zhijiayun-user/src/main/java/com/xuekairui/user/service/UserLevelService.java
  30. 2 2
      zhijiayun-user/src/main/java/com/xuekairui/user/service/WatchlistService.java
  31. 37 11
      zhijiayun-user/src/main/java/com/xuekairui/user/util/JwtUtil.java

+ 1 - 0
zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java

@@ -47,6 +47,7 @@ public enum ErrorCode {
     TOKEN_EXPIRED(1301, "Token已过期"),
     TOKEN_INVALID(1302, "Token无效"),
     REFRESH_TOKEN_INVALID(1303, "刷新Token无效"),
+    TOKEN_KICKED(1304, "您的账号已在其他设备登录,当前会话已失效,请重新登录"),
 
     // ========== 支付模块 2001-2099 ==========
     PAY_ORDER_NOT_FOUND(2001, "支付订单不存在"),

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

@@ -40,7 +40,7 @@ spring:
 # JWT 密钥:生产部署时通过 JWT_SECRET 环境变量覆盖
 jwt:
   secret: ${JWT_SECRET:YourSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!}
-  expiration: 86400000
+  expiration: 300000
   refresh-expiration: 604800000
 
 # 微信配置:生产部署时通过环境变量覆盖

+ 12 - 12
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/BusinessLicenseAndCouponTest.java

@@ -170,9 +170,9 @@ class BusinessLicenseAndCouponTest extends GatewayBaseTest {
 
     @Test
     @Order(20)
-    @DisplayName("GET /api/level/list - 获取等级列表(公开)")
+    @DisplayName("GET /api/membership/levels - 获取等级列表(公开)")
     void getLevelList_shouldReturnList() throws Exception {
-        mockMvc.perform(get("/api/level/list"))
+        mockMvc.perform(get("/api/membership/levels"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data").isArray());
@@ -180,9 +180,9 @@ class BusinessLicenseAndCouponTest extends GatewayBaseTest {
 
     @Test
     @Order(21)
-    @DisplayName("GET /api/level/list - 等级应包含必要字段")
+    @DisplayName("GET /api/membership/levels - 等级应包含必要字段")
     void getLevelList_shouldContainRequiredFields() throws Exception {
-        mockMvc.perform(get("/api/level/list"))
+        mockMvc.perform(get("/api/membership/levels"))
                 .andExpect(status().isOk())
                 .andExpect(result -> {
                     String content = result.getResponse().getContentAsString();
@@ -205,9 +205,9 @@ class BusinessLicenseAndCouponTest extends GatewayBaseTest {
 
     @Test
     @Order(22)
-    @DisplayName("GET /api/level/{id} - 获取等级详情")
+    @DisplayName("GET /api/membership/levels/1 - 获取等级详情")
     void getLevelDetail_shouldReturnDetail() throws Exception {
-        mockMvc.perform(get("/api/level/1")
+        mockMvc.perform(get("/api/membership/levels/1")
                         .header("Authorization", "Bearer " + userToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200));
@@ -215,9 +215,9 @@ class BusinessLicenseAndCouponTest extends GatewayBaseTest {
 
     @Test
     @Order(23)
-    @DisplayName("GET /api/level/{id} - 不存在的等级应返回错误码2001")
+    @DisplayName("GET /api/membership/levels/1 - 不存在的等级应返回错误码2001")
     void getLevelDetail_nonExistent_shouldReturn404() throws Exception {
-        mockMvc.perform(get("/api/level/999")
+        mockMvc.perform(get("/api/membership/levels/999")
                         .header("Authorization", "Bearer " + userToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(4001));
@@ -225,9 +225,9 @@ class BusinessLicenseAndCouponTest extends GatewayBaseTest {
 
     @Test
     @Order(24)
-    @DisplayName("GET /api/level/my - 获取当前用户等级")
+    @DisplayName("GET /api/membership/my - 获取当前用户等级")
     void getMyLevel_shouldReturnLevel() throws Exception {
-        mockMvc.perform(get("/api/level/my")
+        mockMvc.perform(get("/api/membership/my")
                         .header("Authorization", "Bearer " + userToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200));
@@ -235,9 +235,9 @@ class BusinessLicenseAndCouponTest extends GatewayBaseTest {
 
     @Test
     @Order(25)
-    @DisplayName("GET /api/level/my - 无Token应返回401")
+    @DisplayName("GET /api/membership/my - 无Token应返回401")
     void getMyLevel_withoutToken_shouldReturn401() throws Exception {
-        mockMvc.perform(get("/api/level/my"))
+        mockMvc.perform(get("/api/membership/my"))
                 .andExpect(status().isUnauthorized());
     }
 

+ 9 - 2
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayBaseTest.java

@@ -50,15 +50,22 @@ public abstract class GatewayBaseTest {
     }
 
     /**
-     * 生成测试用 Access Token
+     * 生成测试用 Access Token(含 tokenVersion=0 以兼容单设备登录校验)
      */
     protected String generateAccessToken(Long userId, String phone, String role) {
+        return generateAccessToken(userId, phone, role, 0L);
+    }
+
+    /**
+     * 生成测试用 Access Token(可指定 tokenVersion,用于单设备登录测试)
+     */
+    protected String generateAccessToken(Long userId, String phone, String role, Long tokenVersion) {
         SecretKey key = Keys.hmacShaKeyFor(TEST_JWT_SECRET.getBytes(StandardCharsets.UTF_8));
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
-        claims.put("phone", phone);
         claims.put("role", role != null ? role : "USER");
         claims.put("type", "access");
+        claims.put("tokenVersion", tokenVersion != null ? tokenVersion : 0L);
 
         return Jwts.builder()
                 .claims(claims)

+ 13 - 13
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayFilterTest.java

@@ -25,7 +25,7 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("请求应返回 X-Request-Id 响应头")
         void requestShouldReturnRequestIdHeader() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk())
                     .andExpect(header().exists("X-Request-Id"));
         }
@@ -33,7 +33,7 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("传入 X-Request-Id 应透传")
         void customRequestIdShouldBePassedThrough() throws Exception {
-            mockMvc.perform(get("/api/level/list")
+            mockMvc.perform(get("/api/membership/levels")
                             .header("X-Request-Id", "my-custom-id-123"))
                     .andExpect(status().isOk())
                     .andExpect(header().string("X-Request-Id", "my-custom-id-123"));
@@ -42,9 +42,9 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("不同请求应生成不同的 X-Request-Id")
         void differentRequests_shouldHaveDifferentRequestIds() throws Exception {
-            String id1 = mockMvc.perform(get("/api/level/list"))
+            String id1 = mockMvc.perform(get("/api/membership/levels"))
                     .andReturn().getResponse().getHeader("X-Request-Id");
-            String id2 = mockMvc.perform(get("/api/level/list"))
+            String id2 = mockMvc.perform(get("/api/membership/levels"))
                     .andReturn().getResponse().getHeader("X-Request-Id");
 
             Assertions.assertNotNull(id1);
@@ -83,14 +83,14 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("正常请求应正常响应(限流测试环境已禁用)")
         void normalRequestShouldReturnRateLimitHeaders() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk());
         }
 
         @Test
         @DisplayName("连续请求不会被限流(测试环境限流已禁用)")
         void rateLimitHeadersShouldHaveCorrectValues() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk());
         }
 
@@ -98,7 +98,7 @@ class GatewayFilterTest extends GatewayBaseTest {
         @DisplayName("多次请求正常处理")
         void mockRedisShouldNotTriggerRateLimit() throws Exception {
             for (int i = 0; i < 10; i++) {
-                mockMvc.perform(get("/api/level/list"))
+                mockMvc.perform(get("/api/membership/levels"))
                         .andExpect(status().isOk());
             }
         }
@@ -106,14 +106,14 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("响应状态码应为200")
         void rateLimitRemaining_shouldBeNonNegative() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk());
         }
 
         @Test
         @DisplayName("公开接口正常响应")
         void differentPaths_mayHaveDifferentLimits() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk());
         }
 
@@ -132,7 +132,7 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("成功响应应包含 code/message/timestamp")
         void successResponseShouldHaveStandardFormat() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk())
                     .andExpect(jsonPath("$.code").value(200))
                     .andExpect(jsonPath("$.message").exists())
@@ -163,7 +163,7 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("X-Forwarded-For 应被正确识别为客户端IP")
         void xForwardedFor_shouldBeUsed() throws Exception {
-            mockMvc.perform(get("/api/level/list")
+            mockMvc.perform(get("/api/membership/levels")
                             .header("X-Forwarded-For", "192.168.1.100"))
                     .andExpect(status().isOk());
         }
@@ -171,7 +171,7 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("X-Real-IP 应被正确识别为客户端IP")
         void xRealIp_shouldBeUsed() throws Exception {
-            mockMvc.perform(get("/api/level/list")
+            mockMvc.perform(get("/api/membership/levels")
                             .header("X-Real-IP", "10.0.0.1"))
                     .andExpect(status().isOk());
         }
@@ -179,7 +179,7 @@ class GatewayFilterTest extends GatewayBaseTest {
         @Test
         @DisplayName("X-Forwarded-For 多IP应取第一个")
         void xForwardedFor_multipleIps_shouldUseFirst() throws Exception {
-            mockMvc.perform(get("/api/level/list")
+            mockMvc.perform(get("/api/membership/levels")
                             .header("X-Forwarded-For", "192.168.1.1, 10.0.0.1, 172.16.0.1"))
                     .andExpect(status().isOk());
         }

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

@@ -124,7 +124,7 @@ class GlobalExceptionHandlerTest extends GatewayBaseTest {
         @Test
         @DisplayName("成功响应应包含code=200")
         void successResponse_shouldHaveCode200() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk())
                     .andExpect(jsonPath("$.code").value(200))
                     .andExpect(jsonPath("$.message").exists())
@@ -134,7 +134,7 @@ class GlobalExceptionHandlerTest extends GatewayBaseTest {
         @Test
         @DisplayName("成功响应应包含timestamp")
         void successResponse_shouldHaveTimestamp() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk())
                     .andExpect(jsonPath("$.timestamp").isNumber());
         }
@@ -152,7 +152,7 @@ class GlobalExceptionHandlerTest extends GatewayBaseTest {
         @Test
         @DisplayName("所有响应都应是JSON格式")
         void allResponses_shouldBeJson() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk())
                     .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
         }
@@ -183,7 +183,7 @@ class GlobalExceptionHandlerTest extends GatewayBaseTest {
         @DisplayName("并发请求同一接口应正常处理")
         void concurrentRequests_shouldHandle() throws Exception {
             for (int i = 0; i < 5; i++) {
-                mockMvc.perform(get("/api/level/list"))
+                mockMvc.perform(get("/api/membership/levels"))
                         .andExpect(status().isOk());
             }
         }

+ 18 - 17
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/SecurityAccessTest.java

@@ -20,17 +20,17 @@ class SecurityAccessTest extends GatewayBaseTest {
     class PublicEndpoints {
 
         @Test
-        @DisplayName("GET /api/level/list - 等级列表应公开访问")
+        @DisplayName("GET /api/membership/levels - 等级列表应公开访问")
         void levelListShouldBePublic() throws Exception {
-            mockMvc.perform(get("/api/level/list"))
+            mockMvc.perform(get("/api/membership/levels"))
                     .andExpect(status().isOk())
                     .andExpect(jsonPath("$.code").value(200));
         }
 
         @Test
-        @DisplayName("GET /api/level/{id} - 等级详情公开访问(/api/level/* 在白名单中)")
+        @DisplayName("GET /api/membership/levels/{id} - 等级详情公开访问(/api/membership/levels/* 在白名单中)")
         void levelDetailShouldRequireAuth() throws Exception {
-            mockMvc.perform(get("/api/level/1"))
+            mockMvc.perform(get("/api/membership/levels/1"))
                     .andExpect(status().isOk());
         }
 
@@ -109,9 +109,9 @@ class SecurityAccessTest extends GatewayBaseTest {
     class ProtectedEndpoints {
 
         @Test
-        @DisplayName("GET /api/level/my - 无Token应返回401")
+        @DisplayName("GET /api/membership/my - 无Token应返回401")
         void myLevelWithoutTokenShouldReturn401() throws Exception {
-            mockMvc.perform(get("/api/level/my"))
+            mockMvc.perform(get("/api/membership/my"))
                     .andExpect(status().isUnauthorized());
         }
 
@@ -241,10 +241,11 @@ class SecurityAccessTest extends GatewayBaseTest {
         }
 
         @Test
-        @DisplayName("PUT /api/level/user/{userId} - 无Token应返回401")
+        @DisplayName("POST /api/admin/membership/grant - 无Token应返回401")
         void setUserLevelWithoutTokenShouldReturn401() throws Exception {
-            mockMvc.perform(put("/api/level/user/1")
-                            .param("level", "PRO"))
+            mockMvc.perform(post("/api/admin/membership/grant")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content("{\"userId\":1,\"level\":\"PRO\"}"))
                     .andExpect(status().isUnauthorized());
         }
 
@@ -337,10 +338,10 @@ class SecurityAccessTest extends GatewayBaseTest {
     class TokenValidation {
 
         @Test
-        @DisplayName("GET /api/level/my - 携带有效Token应正常返回")
+        @DisplayName("GET /api/membership/my - 携带有效Token应正常返回")
         void myLevelWithValidTokenShouldSucceed() throws Exception {
             String token = generateAccessToken(1L, "13800138000");
-            mockMvc.perform(get("/api/level/my")
+            mockMvc.perform(get("/api/membership/my")
                             .header("Authorization", "Bearer " + token))
                     .andExpect(status().isOk())
                     .andExpect(jsonPath("$.code").value(200));
@@ -359,7 +360,7 @@ class SecurityAccessTest extends GatewayBaseTest {
         @Test
         @DisplayName("携带无效Token应返回401")
         void invalidTokenShouldReturn401() throws Exception {
-            mockMvc.perform(get("/api/level/my")
+            mockMvc.perform(get("/api/membership/my")
                             .header("Authorization", "Bearer invalid.token.here"))
                     .andExpect(status().isUnauthorized());
         }
@@ -367,7 +368,7 @@ class SecurityAccessTest extends GatewayBaseTest {
         @Test
         @DisplayName("携带空Token应返回401")
         void emptyTokenShouldReturn401() throws Exception {
-            mockMvc.perform(get("/api/level/my")
+            mockMvc.perform(get("/api/membership/my")
                             .header("Authorization", "Bearer "))
                     .andExpect(status().isUnauthorized());
         }
@@ -378,11 +379,11 @@ class SecurityAccessTest extends GatewayBaseTest {
             String token1 = generateAccessToken(1L, "13800138000");
             String token2 = generateAccessToken(2L, "13900139000");
 
-            mockMvc.perform(get("/api/level/my")
+            mockMvc.perform(get("/api/membership/my")
                             .header("Authorization", "Bearer " + token1))
                     .andExpect(status().isOk());
 
-            mockMvc.perform(get("/api/level/my")
+            mockMvc.perform(get("/api/membership/my")
                             .header("Authorization", "Bearer " + token2))
                     .andExpect(status().isOk());
         }
@@ -400,9 +401,9 @@ class SecurityAccessTest extends GatewayBaseTest {
         }
 
         @Test
-        @DisplayName("DELETE /api/level/list - 不支持的方法")
+        @DisplayName("DELETE /api/membership/levels - 不支持的方法")
         void levelList_deleteMethodShouldReturn405() throws Exception {
-            mockMvc.perform(delete("/api/level/list"))
+            mockMvc.perform(delete("/api/membership/levels"))
                     .andExpect(status().is5xxServerError());
         }
     }

+ 0 - 122
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/UserLevelControllerTest.java

@@ -1,122 +0,0 @@
-package com.xuekairui.gateway;
-
-import org.junit.jupiter.api.*;
-
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-
-/**
- * 用户等级接口测试
- */
-@DisplayName("用户等级接口测试")
-@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
-class UserLevelControllerTest extends GatewayBaseTest {
-
-    @Test
-    @Order(1)
-    @DisplayName("GET /api/level/list - 应返回等级列表(公开)")
-    void listLevels_shouldReturnList() throws Exception {
-        mockMvc.perform(get("/api/level/list"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray())
-                .andExpect(jsonPath("$.data.length()").value(3));
-    }
-
-    @Test
-    @Order(2)
-    @DisplayName("GET /api/level/list - 等级列表应包含 PLUS/PRO/ULTRA")
-    void listLevels_shouldContainAllLevels() throws Exception {
-        mockMvc.perform(get("/api/level/list"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data[0].levelCode").value("PLUS"))
-                .andExpect(jsonPath("$.data[1].levelCode").value("PRO"))
-                .andExpect(jsonPath("$.data[2].levelCode").value("ULTRA"));
-    }
-
-    @Test
-    @Order(3)
-    @DisplayName("GET /api/level/list - 等级应包含爬虫配额信息")
-    void listLevels_shouldContainCrawlerQuota() throws Exception {
-        mockMvc.perform(get("/api/level/list"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data[0].crawlerQuota").value(10))
-                .andExpect(jsonPath("$.data[0].monthlyQuota").value(300))
-                .andExpect(jsonPath("$.data[0].yearlyQuota").value(3650))
-                .andExpect(jsonPath("$.data[0].maxConcurrent").value(1));
-    }
-
-    @Test
-    @Order(4)
-    @DisplayName("GET /api/level/my - 有效Token应返回用户等级")
-    void getMyLevel_withToken_shouldReturnLevel() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
-        mockMvc.perform(get("/api/level/my")
-                        .header("Authorization", "Bearer " + token))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data.levelCode").exists());
-    }
-
-    @Test
-    @Order(5)
-    @DisplayName("GET /api/level/my - 无Token应返回401")
-    void getMyLevel_withoutToken_shouldReturn401() throws Exception {
-        mockMvc.perform(get("/api/level/my"))
-                .andExpect(status().isUnauthorized());
-    }
-
-    // ==================== 等级详情(公开) ====================
-
-    @Test
-    @Order(6)
-    @DisplayName("GET /api/level/{id} - 存在的等级应返回详情")
-    void getLevel_existingId_shouldReturnDetail() throws Exception {
-        mockMvc.perform(get("/api/level/1"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data.levelCode").value("PLUS"))
-                .andExpect(jsonPath("$.data.levelName").exists())
-                .andExpect(jsonPath("$.data.crawlerQuota").exists());
-    }
-
-    @Test
-    @Order(7)
-    @DisplayName("GET /api/level/{id} - 不存在的等级应返回错误码4001")
-    void getLevel_nonexistentId_shouldReturn404() throws Exception {
-        mockMvc.perform(get("/api/level/99999"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(4001));
-    }
-
-    // ==================== 管理员设置用户等级 ====================
-
-    @Test
-    @Order(10)
-    @DisplayName("PUT /api/level/user/{userId} - 管理员设置用户等级为PRO")
-    void setUserLevel_asAdmin_shouldSucceed() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
-        mockMvc.perform(put("/api/level/user/1?level=PRO")
-                        .header("Authorization", "Bearer " + token))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200));
-    }
-
-    @Test
-    @Order(11)
-    @DisplayName("PUT /api/level/user/{userId} - 无Token应返回401")
-    void setUserLevel_withoutToken_shouldReturn401() throws Exception {
-        mockMvc.perform(put("/api/level/user/1?level=PRO"))
-                .andExpect(status().isUnauthorized());
-    }
-
-    @Test
-    @Order(12)
-    @DisplayName("PUT /api/level/user/{userId} - 普通用户应返回403")
-    void setUserLevel_asNormalUser_shouldReturn403() throws Exception {
-        String token = generateAccessToken(1L, "13800138000", "USER");
-        mockMvc.perform(put("/api/level/user/1?level=PRO")
-                        .header("Authorization", "Bearer " + token))
-                .andExpect(status().isForbidden());
-    }
-}

+ 35 - 0
zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteConfigRequest.java

@@ -77,6 +77,41 @@ public class InviteConfigRequest {
     @Max(1)
     private Integer status;
 
+    // ========== 文案模板配置 ==========
+
+    /** 分享标题模板(支持占位符:{inviter}、{appName}) */
+    private String shareTitleTemplate;
+
+    /** 一键复制文案模板(支持占位符:{inviter}、{appName}、{inviteLink}、{code}) */
+    private String copyTextTemplate;
+
+    /** 奖励说明文案(支持占位符:{rewardMonths}) */
+    private String rewardDescription;
+
+    /** 邀请统计摘要模板(支持占位符:{totalInvited}、{registeredCount}、{totalReward}) */
+    private String summaryTextTemplate;
+
+    /** Windows渠道提示文案(支持占位符:{code}) */
+    private String instructionWindows;
+
+    /** App渠道提示文案(支持占位符:{code}) */
+    private String instructionApp;
+
+    /** 小程序渠道提示文案(支持占位符:{code}) */
+    private String instructionMiniapp;
+
+    /** 微信公众号渠道提示文案(支持占位符:{code}) */
+    private String instructionWechat;
+
+    /** 钉钉渠道提示文案(支持占位符:{code}、{appName}) */
+    private String instructionDingtalk;
+
+    /** 飞书渠道提示文案(支持占位符:{code}、{appName}) */
+    private String instructionFeishu;
+
+    /** 默认/未知渠道提示文案(支持占位符:{code}) */
+    private String instructionDefault;
+
     /** 备注 */
     private String remark;
 }

+ 46 - 0
zhijiayun-invite/src/main/java/com/xuekairui/invite/entity/InviteConfig.java

@@ -93,6 +93,52 @@ public class InviteConfig {
     @Builder.Default
     private Integer status = 1;
 
+    // ========== 文案模板配置 ==========
+
+    /** 分享标题模板(支持占位符:{inviter}邀请人昵称、{appName}应用名称) */
+    @Builder.Default
+    private String shareTitleTemplate = "{inviter} 邀请你加入{appName}";
+
+    /** 一键复制文案模板(支持占位符:{inviter}、{appName}、{inviteLink}、{code}) */
+    @Builder.Default
+    private String copyTextTemplate = "我是{inviter},在这里发现了一个药店采购神器——{appName}!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接{inviteLink}填我的邀请码 {code} 完成注册,你也会获得会员权益!";
+
+    /** 奖励说明文案(支持占位符:{rewardMonths}奖励月数) */
+    @Builder.Default
+    private String rewardDescription = "邀请人每成功邀请1家药店注册,赠送1个月高级会员时长";
+
+    /** 邀请统计摘要模板(支持占位符:{totalInvited}、{registeredCount}、{totalReward}) */
+    @Builder.Default
+    private String summaryTextTemplate = "已邀请{totalInvited}家药店,{registeredCount}家注册,累计获得{totalReward}个月高级会员";
+
+    /** Windows渠道提示文案(支持占位符:{code}邀请码) */
+    @Builder.Default
+    private String instructionWindows = "下载安装Windows客户端后,在注册页面输入邀请码:{code} 或点击链接自动填入";
+
+    /** App渠道提示文案(支持占位符:{code}邀请码) */
+    @Builder.Default
+    private String instructionApp = "下载安装客户端后,在注册页面输入邀请码:{code} 或点击链接自动填入";
+
+    /** 小程序渠道提示文案(支持占位符:{code}邀请码) */
+    @Builder.Default
+    private String instructionMiniapp = "点击按钮打开小程序,邀请码已自动填入";
+
+    /** 微信公众号渠道提示文案(支持占位符:{code}邀请码) */
+    @Builder.Default
+    private String instructionWechat = "关注公众号后,在菜单中输入邀请码:{code} 完成注册";
+
+    /** 钉钉渠道提示文案(支持占位符:{code}邀请码、{appName}应用名称) */
+    @Builder.Default
+    private String instructionDingtalk = "打开钉钉应用,搜索\"{appName}\"并输入邀请码:{code}";
+
+    /** 飞书渠道提示文案(支持占位符:{code}邀请码、{appName}应用名称) */
+    @Builder.Default
+    private String instructionFeishu = "打开飞书应用,搜索\"{appName}\"并输入邀请码:{code}";
+
+    /** 默认/未知渠道提示文案(支持占位符:{code}邀请码) */
+    @Builder.Default
+    private String instructionDefault = "下载客户端后,在注册页面输入邀请码:{code} 即可享受奖励";
+
     /** 备注 */
     private String remark;
 

+ 34 - 0
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteConfigService.java

@@ -110,6 +110,40 @@ public class InviteConfigService {
         if (request.getRemark() != null) {
             config.setRemark(request.getRemark());
         }
+        // 文案模板配置
+        if (request.getShareTitleTemplate() != null) {
+            config.setShareTitleTemplate(request.getShareTitleTemplate());
+        }
+        if (request.getCopyTextTemplate() != null) {
+            config.setCopyTextTemplate(request.getCopyTextTemplate());
+        }
+        if (request.getRewardDescription() != null) {
+            config.setRewardDescription(request.getRewardDescription());
+        }
+        if (request.getSummaryTextTemplate() != null) {
+            config.setSummaryTextTemplate(request.getSummaryTextTemplate());
+        }
+        if (request.getInstructionWindows() != null) {
+            config.setInstructionWindows(request.getInstructionWindows());
+        }
+        if (request.getInstructionApp() != null) {
+            config.setInstructionApp(request.getInstructionApp());
+        }
+        if (request.getInstructionMiniapp() != null) {
+            config.setInstructionMiniapp(request.getInstructionMiniapp());
+        }
+        if (request.getInstructionWechat() != null) {
+            config.setInstructionWechat(request.getInstructionWechat());
+        }
+        if (request.getInstructionDingtalk() != null) {
+            config.setInstructionDingtalk(request.getInstructionDingtalk());
+        }
+        if (request.getInstructionFeishu() != null) {
+            config.setInstructionFeishu(request.getInstructionFeishu());
+        }
+        if (request.getInstructionDefault() != null) {
+            config.setInstructionDefault(request.getInstructionDefault());
+        }
 
         if (config.getId() != null) {
             inviteConfigMapper.updateById(config);

+ 55 - 32
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -230,22 +230,23 @@ public class InviteService {
     }
 
     /**
-     * 根据渠道生成操作提示文案
-     *
-     * @param code    邀请码
-     * @param channel 渠道
-     * @param config  邀请配置
+     * 根据渠道生成操作提示文案(从配置读取,支持占位符 {code}、{appName})
      */
     private String generateInstructionText(String code, String channel, InviteConfig config) {
-        return switch (channel) {
-            case "windows" -> "下载安装Windows客户端后,在注册页面输入邀请码:" + code + " 或点击链接自动填入";
-            case "app" -> "下载安装客户端后,在注册页面输入邀请码:" + code + " 或点击链接自动填入";
-            case "miniapp" -> "点击按钮打开小程序,邀请码已自动填入";
-            case "wechat" -> "关注公众号后,在菜单中输入邀请码:" + code + " 完成注册";
-            case "dingtalk" -> "打开钉钉应用,搜索\"" + config.getAppName() + "\"并输入邀请码:" + code;
-            case "feishu" -> "打开飞书应用,搜索\"" + config.getAppName() + "\"并输入邀请码:" + code;
-            default -> "下载客户端后,在注册页面输入邀请码:" + code + " 即可享受奖励";
+        String template = switch (channel) {
+            case "windows" -> config.getInstructionWindows();
+            case "app" -> config.getInstructionApp();
+            case "miniapp" -> config.getInstructionMiniapp();
+            case "wechat" -> config.getInstructionWechat();
+            case "dingtalk" -> config.getInstructionDingtalk();
+            case "feishu" -> config.getInstructionFeishu();
+            default -> config.getInstructionDefault();
         };
+        if (template == null || template.isBlank()) {
+            template = "下载客户端后,在注册页面输入邀请码:" + code + " 即可享受奖励";
+        }
+        return template.replace("{code}", code)
+                .replace("{appName}", config.getAppName() != null ? config.getAppName() : "药易采");
     }
 
     /**
@@ -789,9 +790,18 @@ public class InviteService {
             log.warn("获取爬虫配额状态失败: userId={}, error={}", userId, e.getMessage());
         }
 
-        // 生成摘要文本(每邀请1家注册 = 邀请人获1个月会员,超上限不发奖励)
-        String summaryText = String.format("已邀请%d家药店,%d家注册,累计获得%d个月高级会员",
-                totalInvited, registeredCount, totalReward);
+        // 生成摘要文本(从配置模板读取,支持占位符 {totalInvited}、{registeredCount}、{totalReward})
+        String summaryTemplate = config.getSummaryTextTemplate();
+        String summaryText;
+        if (summaryTemplate != null && !summaryTemplate.isBlank()) {
+            summaryText = summaryTemplate
+                    .replace("{totalInvited}", String.valueOf(totalInvited))
+                    .replace("{registeredCount}", String.valueOf(registeredCount))
+                    .replace("{totalReward}", String.valueOf(totalReward));
+        } else {
+            summaryText = String.format("已邀请%d家药店,%d家注册,累计获得%d个月高级会员",
+                    totalInvited, registeredCount, totalReward);
+        }
 
         return InviteStatsResponse.builder()
                 .myInviteCode(codeResponse.getCode())
@@ -966,7 +976,7 @@ public class InviteService {
     }
 
     /**
-     * 实体转响应(含邀请链接)
+     * 实体转响应(含邀请链接,文案从配置读取
      */
     private InviteCodeResponse toCodeResponse(InviteCode code) {
         // 获取配置与邀请人信息
@@ -974,24 +984,37 @@ public class InviteService {
         User inviter = userMapper.selectById(code.getUserId());
         String inviterNickname = getDisplayName(inviter);
         String inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
+        String appName = config.getAppName() != null ? config.getAppName() : "药易采";
+        int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
+
+        // 分享标题:从配置模板读取,支持占位符 {inviter}、{appName}
+        String shareTitleTemplate = config.getShareTitleTemplate();
+        String shareTitle = (shareTitleTemplate != null && !shareTitleTemplate.isBlank())
+                ? shareTitleTemplate.replace("{inviter}", inviterNickname).replace("{appName}", appName)
+                : inviterNickname + " 邀请你加入" + appName;
 
-        // 生成分享文案(邀请人 + 产品价值 + 操作指引)
-        // 分享标题:让接收方一眼知道是谁邀请的
-        String shareTitle = inviterNickname + " 邀请你加入" + config.getAppName();
-        // 分享描述:说明产品核心价值(绑定B2B平台→聚合查价)
+        // 分享描述:使用落地页描述
         String shareDescription = config.getLandingDesc() != null
                 && !config.getLandingDesc().isBlank()
-                ? config.getLandingDesc() : "注册登录" + config.getAppName() + "平台,聚合比价查低价";
-        String rewardDesc = "邀请人每成功邀请1家药店注册,赠送1个月高级会员时长";
-
-        // 复制文本:与示例文案保持一致,便于用户一键转发
-        String copyText = String.format(
-            "我是%s,在这里发现了一个药店采购神器——%s!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接%s填我的邀请码 %s 完成注册,你也会获得会员权益!",
-            inviterNickname,
-            config.getAppName(),
-                inviteLink,
-            code.getCode()
-        );
+                ? config.getLandingDesc() : "注册登录" + appName + "平台,聚合比价查低价";
+
+        // 奖励说明:从配置读取,支持占位符 {rewardMonths}
+        String rewardDescTemplate = config.getRewardDescription();
+        String rewardDesc = (rewardDescTemplate != null && !rewardDescTemplate.isBlank())
+                ? rewardDescTemplate.replace("{rewardMonths}", String.valueOf(rewardMonths))
+                : "邀请人每成功邀请1家药店注册,赠送" + rewardMonths + "个月高级会员时长";
+
+        // 一键复制文本:从配置模板读取,支持占位符 {inviter}、{appName}、{inviteLink}、{code}
+        String copyTextTemplate = config.getCopyTextTemplate();
+        String copyText = (copyTextTemplate != null && !copyTextTemplate.isBlank())
+                ? copyTextTemplate
+                    .replace("{inviter}", inviterNickname)
+                    .replace("{appName}", appName)
+                    .replace("{inviteLink}", inviteLink)
+                    .replace("{code}", code.getCode())
+                : String.format(
+                    "我是%s,在这里发现了一个药店采购神器——%s!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接%s填我的邀请码 %s 完成注册,你也会获得会员权益!",
+                    inviterNickname, appName, inviteLink, code.getCode());
 
         return InviteCodeResponse.builder()
                 .code(code.getCode())

+ 82 - 15
zhijiayun-test/src/main/resources/static/invite-test.html

@@ -234,7 +234,7 @@
         <div class="module-tab" data-module="crawler">2.爬虫配额</div>
         <div class="module-tab" data-module="invite">3.邀请系统</div>
         <div class="module-tab" data-module="coupon">4.优惠券</div>
-        <div class="module-tab" data-module="membership">5.会员体系</div>
+        <div class="module-tab" data-module="membership">5.会员等级</div>
         <div class="module-tab" data-module="payment">6.支付模块</div>
         <div class="module-tab" data-module="upload">7.文件上传</div>
         <div class="module-tab" data-module="search">8.搜索查询</div>
@@ -625,6 +625,8 @@ function renderModule(module) {
     case 'coupon': html = renderCouponModule(); break;
     case 'membership': html = renderMembershipModule(); break;
     case 'payment': html = renderPaymentModule(); break;
+    case 'watchlist': html = renderWatchlistModule(); break;
+    case 'trial-quota': html = renderTrialQuotaModule(); break;
     case 'upload': html = renderUploadModule(); break;
     case 'search': html = renderSearchModule(); break;
     case 'platform-account': html = renderPlatformAccountModule(); break;
@@ -729,11 +731,14 @@ const FIELD_DESC = {
         ['levelInfo.pricingPlans[].originalPrice', 'BigDecimal', '原价(元,如19.90)'],
         ['levelInfo.pricingPlans[].durationDays', 'Integer', '会员有效天数(30/365)'],
         ['levelInfo.pricingPlans[].description', 'String', '方案描述'],
-        ['membershipLevel', 'String', '会员等级(PLUS/PRO/ULTRA,可能为null)'],
-        ['membershipLevelName', 'String', '会员等级中文名(普通会员/高级会员/超级会员)'],
+        ['membershipLevel', 'String', '会员等级(PLUS/PRO/ULTRA)'],
+        ['membershipLevelName', 'String', '会员等级中文名'],
+        ['inviterId', 'Long', '邀请人ID(null=未被邀请)'],
+        ['inviterNickname', 'String', '邀请人昵称(null=未被邀请)'],
+        ['hasBoundInviteCode', 'Boolean', '是否已绑定邀请码'],
         ['createTime', 'DateTime', '注册时间'],
-        ['loginSource', 'String', '登录来源(SMS/PASSWORD/WECHAT,可能为null)'],
-        ['levelCode', 'String', '等级编码(冗余字段,来自levelInfo,如:PLUS)']
+        ['loginSource', 'String', '登录来源(SMS/PASSWORD/WECHAT)'],
+        ['levelCode', 'String', '等级编码(来自levelInfo)']
     ],
     'crawlerStatus': [
         ['levelName', 'String', '等级名称(如:普通用户)'],
@@ -803,7 +808,9 @@ const FIELD_DESC = {
         ['summaryText', 'String', '摘要文本'],
         ['todayInvited', 'Integer', '今日已邀请人数'],
         ['todayRemaining', 'Integer', '今日剩余可邀次数'],
-        ['rewardPerInvite', 'Integer', '每次邀请奖励会员月数']
+        ['rewardPerInvite', 'Integer', '每次邀请奖励会员月数'],
+        ['crawlerTodayRemaining', 'Integer', '今日爬虫剩余配额(与爬虫状态页一致)'],
+        ['crawlerQuotaFromInvites', 'Integer', '邀请贡献的额外爬虫配额']
     ],
     'inviteReward': [
         ['inviterId', 'Long', '邀请人ID'],
@@ -981,9 +988,20 @@ const FIELD_DESC = {
         ['wechatRedirectUrl', 'String', '微信跳转URL'],
         ['dingtalkAppId', 'String', '钉钉AppID'],
         ['feishuAppId', 'String', '飞书AppID'],
-        ['landingTitle', 'String', '落地页标题'],
+        ['landingTitle', 'String', '落地页标题(支持{inviter}{pharmacy}占位符)'],
         ['landingDesc', 'String', '落地页描述'],
         ['appName', 'String', '应用名称'],
+        ['shareTitleTemplate', 'String', '分享标题模板(支持{inviter}{appName})'],
+        ['copyTextTemplate', 'String', '一键复制文案模板(支持{inviter}{appName}{inviteLink}{code})'],
+        ['rewardDescription', 'String', '奖励说明文案(支持{rewardMonths})'],
+        ['summaryTextTemplate', 'String', '统计摘要模板(支持{totalInvited}{registeredCount}{totalReward})'],
+        ['instructionWindows', 'String', 'Windows渠道提示文案(支持{code})'],
+        ['instructionApp', 'String', 'App渠道提示文案(支持{code})'],
+        ['instructionMiniapp', 'String', '小程序渠道提示文案(支持{code})'],
+        ['instructionWechat', 'String', '微信渠道提示文案(支持{code})'],
+        ['instructionDingtalk', 'String', '钉钉渠道提示文案(支持{code}{appName})'],
+        ['instructionFeishu', 'String', '飞书渠道提示文案(支持{code}{appName})'],
+        ['instructionDefault', 'String', '默认渠道提示文案(支持{code})'],
         ['status', 'String', '状态(ACTIVE/INACTIVE)'],
         ['remark', 'String', '备注'],
         ['createTime', 'DateTime', '创建时间'],
@@ -1119,7 +1137,7 @@ function renderFieldDesc(descKey) {
 function testSection(id, title, method, methodClass, path, authTag, authClass, fields, hasValidator, descKey, desc) {
     const authLabel = authTag === '需认证' ? '需认证' : '公开';
     const ac = authTag === '需认证' ? 'auth-required' : 'auth-public';
-    const side = (path.includes('/api/admin') || path === '/api/coupon/issue' || path.includes('/api/level/user/')) ? 'admin' : 'user';
+    const side = (path.includes('/api/admin') || path === '/api/coupon/issue') ? 'admin' : 'user';
     const sideClass = side === 'admin' ? 'side-admin' : 'side-user';
     const sideLabel = side === 'admin' ? '运营侧' : '用户侧';
     let fieldsHtml = '';
@@ -1267,6 +1285,7 @@ function renderInviteModule() {
     ${testSection('invite-stats', '邀请统计', 'GET', 'get', '/api/invite/stats', '需认证', 'auth-required', null, true, 'inviteStats', '获取邀请人数、注册人数、累计奖励等统计')}
     ${testSection('invite-rewards', '邀请记录', 'GET', 'get', '/api/invite/rewards', '需认证', 'auth-required', null, true, 'inviteReward', '获取邀请记录列表,含被邀请人注册状态')}
     ${testSection('invite-extra-quota', '额外配额', 'GET', 'get', '/api/invite/extra-quota', '需认证', 'auth-required', null, false, null, '获取可用额外爬虫次数(邀请奖励+运营发放),返回Integer数值')}
+    ${testSection('invite-my-inviter', '我的邀请人', 'GET', 'get', '/api/invite/my-inviter', '需认证', 'auth-required', null, true, 'myInviter', '查询是谁邀请了我(被邀请人视角),未被邀请返回invited=false')}
     ${testSection('invite-bind', '补填邀请码', 'POST', 'post', '/api/invite/bind', '需认证', 'auth-required',
         [{label:'邀请码/链接(inviteCode)', name:'inviteCode', placeholder:'A3K7M9P2 或 https://app.zhijiayun.com/invite/A3K7M9P2', default:''}], true, null, '注册时未填邀请码,注册后30天内可补填。补填后邀请人获赠1个月高级会员时长')}
     ${testSection('invite-page', '落地页', 'GET', 'get', '/api/invite/page/{code}?channel=windows', '公开', 'auth-public',
@@ -1292,11 +1311,37 @@ function renderCouponModule() {
 function renderMembershipModule() {
     return `
     <div class="batch-buttons">
-        <button class="btn btn-warning btn-sm" onclick="runMembershipTests()">运行全部会员体系测试</button>
+        <button class="btn btn-warning btn-sm" onclick="runMembershipTests()">运行全部会员等级测试</button>
     </div>
-    ${testSection('membership-my', '我的会员', 'GET', 'get', '/api/membership/my', '需认证', 'auth-required', null, true, 'membership', '获取当前登录用户的会员等级与有效期')}
+    ${testSection('membership-my', '我的会员', 'GET', 'get', '/api/membership/my', '需认证', 'auth-required', null, true, 'membership', '获取当前登录用户的会员等级、有效期与配额信息')}
     ${testSection('membership-benefits', '会员权益', 'GET', 'get', '/api/membership/benefits?level=PLUS', '需认证', 'auth-required',
-        [{label:'等级(level)', name:'level', placeholder:'PLUS/PRO/ULTRA', default:'PLUS'}], true, 'membershipBenefit', '查询指定会员等级的权益列表')}`;
+        [{label:'等级(level)', name:'level', placeholder:'PLUS/PRO/ULTRA', default:'PLUS'}], true, 'membershipBenefit', '查询指定会员等级的权益列表')}
+    ${testSection('membership-levels', '等级列表(公开)', 'GET', 'get', '/api/membership/levels', '公开', 'auth-public', null, true, 'levelResponse', '获取所有会员等级列表(PLUS/PRO/ULTRA),含定价方案pricingPlans')}
+    ${testSection('membership-level-detail', '等级详情(公开)', 'GET', 'get', '/api/membership/levels/{id}', '公开', 'auth-public',
+        [{label:'等级ID(id)', name:'id', placeholder:'1', default:'1'}], true, 'levelResponse', '获取指定等级详情,含定价方案与配额')}`;
+}
+
+function renderWatchlistModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runWatchlistTests()">运行全部关注列表测试</button>
+    </div>
+    ${testSection('watchlist-add', '添加关注', 'POST', 'post', '/api/watchlist', '需认证', 'auth-required',
+        [{label:'药品名称(drugName)', name:'drugName', placeholder:'阿莫西林胶囊', default:'阿莫西林胶囊'},
+         {label:'规格(spec)', name:'spec', placeholder:'0.25g×24片', default:''},
+         {label:'厂家(manufacturer)', name:'manufacturer', placeholder:'石药集团', default:''}], true, null, '添加药品到关注列表')}
+    ${testSection('watchlist-list', '关注列表', 'GET', 'get', '/api/watchlist', '需认证', 'auth-required', null, true, null, '获取当前用户所有关注的药品列表')}
+    ${testSection('watchlist-delete', '取消关注', 'DELETE', 'delete', '/api/watchlist/{id}', '需认证', 'auth-required',
+        [{label:'关注ID(id)', name:'id', placeholder:'1', default:'1'}], false, null, '从关注列表中删除')}
+    ${testSection('watchlist-stats', '关注统计', 'GET', 'get', '/api/watchlist/stats', '需认证', 'auth-required', null, true, null, '获取关注统计信息')}`;
+}
+
+function renderTrialQuotaModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runTrialQuotaTests()">运行全部体验配额测试</button>
+    </div>
+    ${testSection('trial-quota-active', '生效中的体验配额', 'GET', 'get', '/api/trial-quota/active', '公开', 'auth-public', null, true, 'trialQuotaConfig', '查询当前生效的体验配额配置')}`;
 }
 
 
@@ -1487,13 +1532,34 @@ function renderAdminInviteModule() {
     ${testSection('admin-invite-config', '邀请配置查询', 'GET', 'get', '/api/admin/invite/config', '需认证', 'auth-required', null, true, 'inviteConfig', '获取当前激活的邀请配置')}
     ${testSection('admin-invite-configs', '全部邀请配置', 'GET', 'get', '/api/admin/invite/configs', '需认证', 'auth-required', null, true, 'inviteConfig', '获取所有邀请配置历史记录')}
     ${testSection('admin-invite-config-update', '更新邀请配置', 'PUT', 'put', '/api/admin/invite/config', '需认证', 'auth-required',
-        [{label:'奖励月数(rewardMonths)', name:'rewardMonths', placeholder:'1', default:'1'},
+        [
+         {label:'奖励月数(rewardMonths)', name:'rewardMonths', placeholder:'1', default:'1'},
          {label:'总邀请上限(maxTotalInvites)', name:'maxTotalInvites', placeholder:'30', default:'30'},
          {label:'日奖励上限(maxDailyReward)', name:'maxDailyReward', placeholder:'50', default:'50'},
          {label:'邀请码过期天(inviteCodeExpireDays)', name:'inviteCodeExpireDays', placeholder:'30', default:'30'},
          {label:'日邀请上限(maxInvitePerDay)', name:'maxInvitePerDay', placeholder:'10', default:'10'},
          {label:'APP下载URL(appDownloadUrl)', name:'appDownloadUrl', placeholder:'https://...', default:'https://download.example.com/app.exe'},
-         {label:'落地页标题(landingTitle)', name:'landingTitle', placeholder:'标题', default:'邀请你加入药易采药店'}], false, 'inviteConfig', '更新邀请配置参数,rewardMonths=邀请人每次获赠高级会员月数,maxTotalInvites=单个账号总邀请上限')}
+         {label:'小程序路径(miniappPath)', name:'miniappPath', placeholder:'/pages/download/index', default:''},
+         {label:'小程序AppID(miniappAppId)', name:'miniappAppId', placeholder:'wx...', default:''},
+         {label:'微信跳转URL(wechatRedirectUrl)', name:'wechatRedirectUrl', placeholder:'https://...', default:''},
+         {label:'钉钉AppID(dingtalkAppId)', name:'dingtalkAppId', placeholder:'', default:''},
+         {label:'飞书AppID(feishuAppId)', name:'feishuAppId', placeholder:'', default:''},
+         {label:'落地页标题(landingTitle)', name:'landingTitle', placeholder:'邀请你加入药易采', default:'邀请你加入药易采'},
+         {label:'落地页描述(landingDesc)', name:'landingDesc', placeholder:'登录药易采平台,聚合比价查低价', default:''},
+         {label:'应用名称(appName)', name:'appName', placeholder:'药易采', default:'药易采'},
+         {label:'分享标题模板(shareTitleTemplate)', name:'shareTitleTemplate', placeholder:'{inviter} 邀请你加入{appName}', default:'{inviter} 邀请你加入{appName}'},
+         {label:'复制文案模板(copyTextTemplate)', name:'copyTextTemplate', placeholder:'我是{inviter},在这里发现了一个药店采购神器...', default:'我是{inviter},在这里发现了一个药店采购神器——{appName}!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接{inviteLink}填我的邀请码 {code} 完成注册,你也会获得会员权益!'},
+         {label:'奖励说明(rewardDescription)', name:'rewardDescription', placeholder:'邀请人每成功邀请1家药店注册...', default:'邀请人每成功邀请1家药店注册,赠送1个月高级会员时长'},
+         {label:'统计摘要模板(summaryTextTemplate)', name:'summaryTextTemplate', placeholder:'已邀请{totalInvited}家药店...', default:'已邀请{totalInvited}家药店,{registeredCount}家注册,累计获得{totalReward}个月高级会员'},
+         {label:'Windows提示文案(instructionWindows)', name:'instructionWindows', placeholder:'下载安装Windows客户端后...', default:'下载安装Windows客户端后,在注册页面输入邀请码:{code} 或点击链接自动填入'},
+         {label:'App提示文案(instructionApp)', name:'instructionApp', placeholder:'下载安装客户端后...', default:'下载安装客户端后,在注册页面输入邀请码:{code} 或点击链接自动填入'},
+         {label:'小程序提示文案(instructionMiniapp)', name:'instructionMiniapp', placeholder:'点击按钮打开小程序...', default:'点击按钮打开小程序,邀请码已自动填入'},
+         {label:'微信提示文案(instructionWechat)', name:'instructionWechat', placeholder:'关注公众号后...', default:'关注公众号后,在菜单中输入邀请码:{code} 完成注册'},
+         {label:'钉钉提示文案(instructionDingtalk)', name:'instructionDingtalk', placeholder:'打开钉钉应用...', default:'打开钉钉应用,搜索"{appName}"并输入邀请码:{code}'},
+         {label:'飞书提示文案(instructionFeishu)', name:'instructionFeishu', placeholder:'打开飞书应用...', default:'打开飞书应用,搜索"{appName}"并输入邀请码:{code}'},
+         {label:'默认提示文案(instructionDefault)', name:'instructionDefault', placeholder:'下载客户端后...', default:'下载客户端后,在注册页面输入邀请码:{code} 即可享受奖励'},
+         {label:'状态(status)', name:'status', placeholder:'0停用/1启用', default:'1'}
+        ], false, 'inviteConfig', '更新邀请配置参数。文案模板支持占位符:{inviter}邀请人昵称、{appName}应用名称、{inviteLink}邀请链接、{code}邀请码、{rewardMonths}奖励月数、{totalInvited}总邀请数、{registeredCount}注册数、{totalReward}总奖励')}
     ${testSection('admin-invite-rewards', '邀请记录查询', 'GET', 'get', '/api/admin/invite/rewards?userId=1', '需认证', 'auth-required', null, true, 'inviteReward', '查看指定用户的邀请奖励记录')}
     ${testSection('admin-invite-conversion', '邀请转化统计', 'GET', 'get', '/api/admin/invite/conversion-stats?userId=1', '需认证', 'auth-required', null, true, 'conversionStats', '查看用户邀请点击-注册转化率')}`;
 }
@@ -1621,7 +1687,7 @@ function getSectionsInModule(modulePrefix) {
     });
 }
 
-const USER_MODULES = ['auth','crawler','invite','coupon','membership','payment','upload','search','platform-account','business-license','platform-config'];
+const USER_MODULES = ['auth','crawler','invite','coupon','membership','payment','upload','search','platform-account','business-license','platform-config','search-record','platform-session','watchlist','trial-quota'];
 const ADMIN_MODULES = ['admin-membership','admin-invite','admin-crawler','admin-coupon','admin-license','admin-payment','admin-anxin','admin-trial-quota','admin-user','admin-audit','upload'];
 
 function countTestsInCurrentPanel() {
@@ -1797,7 +1863,8 @@ function runPlatformAccountTests() { runBatchTests('pa'); }
 function runBusinessLicenseTests() { runBatchTests('bl'); }
 function runPlatformConfigTests() { runBatchTests('pc'); }
 function runSearchRecordTests() { runBatchTests('sr'); }
-function runPlatformSessionTests() { runBatchTests('ps'); }
+function runPlatformSessionTests() { runBatchTests('ps'); }function runWatchlistTests() { runBatchTests('watchlist'); }
+function runTrialQuotaTests() { runBatchTests('trial-quota'); }
 // 运营侧快捷批量运行
 function runAdminMembershipTests() { runBatchTests('admin-membership'); }
 function runAdminInviteTests() { runBatchTests('admin-invite'); }

+ 3 - 6
zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java

@@ -51,8 +51,8 @@ public class SecurityConfig {
             "/api/payment/agreement/notify/**",  // 签约回调通知(公开,供微信调用)
             "/api/payment/alipay/**",  // 支付宝开放能力回调(公开)
             "/api/payment/plans",        // 支付方案列表(公开)
-            "/api/level/list",           // 等级列表(公开)
-            "/api/level/*",              // 等级详情(公开,/my和/user/**已由前置规则匹配
+            "/api/membership/levels",    // 会员等级列表(公开)
+            "/api/membership/levels/*",  // 会员等级详情(公开)
             "/error",                    // 错误页面
             "/actuator/health"           // 健康检查
     };
@@ -72,15 +72,12 @@ public class SecurityConfig {
                         .requestMatchers("/api/admin/users/**").hasRole("SUPER_ADMIN")
                         // 运营后台接口
                         .requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
-                        // 散落在用户路径中的管理员操作
+                        // 散落在用户路径中的管理员操作(已统一到 /api/admin/** 下)
                         .requestMatchers("/api/coupon/issue").hasAnyRole("ADMIN", "SUPER_ADMIN")
-                        .requestMatchers("/api/level/user/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
                         // 用户端邀请接口需登录(必须在 /api/invite/{code} 公开匹配之前)
                         .requestMatchers("/api/invite/code", "/api/invite/stats",
                                 "/api/invite/rewards", "/api/invite/extra-quota",
                                 "/api/invite/bind").authenticated()
-                        // 普通用户接口
-                        .requestMatchers("/api/level/my").authenticated()
                         .requestMatchers(PUBLIC_URLS).permitAll()
                         .anyRequest().authenticated()
                 )

+ 7 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminMembershipController.java

@@ -36,16 +36,19 @@ public class AdminMembershipController {
     }
 
     /**
-     * 分页查询会员记录
-     * GET /api/admin/memberships?page=1&size=20&userId=1&level=PLUS
+     * 分页查询会员记录(支持按用户/等级/来源/手机号/昵称筛选)
+     * GET /api/admin/membership/memberships?page=1&size=20&userId=1&level=PRO&source=INVITE&phone=138&nickname=测试
      */
     @GetMapping("/memberships")
     public Result<Page<MembershipResponse>> listMemberships(
             @RequestParam(defaultValue = "1") Integer page,
             @RequestParam(defaultValue = "20") Integer size,
             @RequestParam(required = false) Long userId,
-            @RequestParam(required = false) String level) {
-        return Result.success(membershipService.listMemberships(page, size, userId, level));
+            @RequestParam(required = false) String level,
+            @RequestParam(required = false) String source,
+            @RequestParam(required = false) String phone,
+            @RequestParam(required = false) String nickname) {
+        return Result.success(membershipService.listMemberships(page, size, userId, level, source, phone, nickname));
     }
 
     private String getOperatorRole(HttpServletRequest request) {

+ 21 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminUserController.java

@@ -27,16 +27,34 @@ public class AdminUserController {
     private final OperationAuditLogService auditLogService;
 
     /**
-     * 分页查询用户列表
-     * GET /api/admin/users?page=1&size=20&phone=138&role=ADMIN
+     * 分页查询用户列表(不支持删除操作)
+     * GET /api/admin/users?page=1&size=20&userId=1&phone=138&nickname=测试&role=ADMIN
      */
     @GetMapping
     public Result<Page<AdminUserResponse>> listUsers(
             @RequestParam(defaultValue = "1") Integer page,
             @RequestParam(defaultValue = "20") Integer size,
+            @RequestParam(required = false) Long userId,
             @RequestParam(required = false) String phone,
+            @RequestParam(required = false) String nickname,
             @RequestParam(required = false) String role) {
-        return Result.success(adminUserService.listUsers(page, size, phone, role));
+        return Result.success(adminUserService.listUsers(page, size, userId, phone, nickname, role));
+    }
+
+    /**
+     * 强制踢出用户(使所有Token失效,强制重新登录)
+     * POST /api/admin/users/{userId}/kick
+     */
+    @PostMapping("/{userId}/kick")
+    public Result<String> kickUser(
+            @PathVariable Long userId,
+            HttpServletRequest httpRequest) {
+        Long operatorId = (Long) httpRequest.getAttribute("userId");
+        String operatorRole = getOperatorRole(httpRequest);
+        adminUserService.kickUser(userId, operatorId);
+        auditLogService.logSuccess(operatorId, "管理员", operatorRole,
+                OperationType.USER_UPDATE, "USER_KICK:" + userId, userId);
+        return Result.success("用户已被强制退出,下次请求将返回1304");
     }
 
     /**

+ 15 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AuthController.java

@@ -96,7 +96,7 @@ public class AuthController {
     }
 
     /**
-     * 刷新Token
+     * 刷新Token(通过 Refresh Token)
      * POST /api/auth/refresh
      */
     @PostMapping("/refresh")
@@ -105,6 +105,20 @@ public class AuthController {
         return Result.success(response);
     }
 
+    /**
+     * 心跳保活(零DB查询,仅一次blind UPDATE)
+     * <p>
+     * 客户端定时调用(建议间隔 = accessToken有效期的 80%)。
+     * 使用 JWT 中已有的信息签发新 Token,不查 User 表。
+     * POST /api/auth/heartbeat
+     */
+    @PostMapping("/heartbeat")
+    public Result<LoginResponse> heartbeat(HttpServletRequest httpRequest) {
+        Long userId = (Long) httpRequest.getAttribute("userId");
+        String role = (String) httpRequest.getAttribute("role");
+        return Result.success(authService.heartbeat(userId, role));
+    }
+
     /**
      * 获取当前用户信息
      * GET /api/auth/user/info

+ 26 - 5
zhijiayun-user/src/main/java/com/xuekairui/user/controller/MembershipController.java

@@ -3,18 +3,19 @@ package com.xuekairui.user.controller;
 import com.xuekairui.common.Result;
 import com.xuekairui.user.dto.MembershipBenefitResponse;
 import com.xuekairui.user.dto.MembershipResponse;
+import com.xuekairui.user.dto.UserLevelResponse;
 import com.xuekairui.user.service.MembershipService;
 import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
 
 /**
  * 会员体系接口(用户侧)
+ * <p>
+ * 统一会员等级管理,已合并原 /api/level/* 的用户等级接口。
+ * 所有等级查询以本控制器为准。
  */
 @RestController
 @RequestMapping("/api/membership")
@@ -24,7 +25,7 @@ public class MembershipController {
     private final MembershipService membershipService;
 
     /**
-     * 查询我的会员信息
+     * 查询我的会员信息(含等级、配额、定价方案)
      * GET /api/membership/my
      */
     @GetMapping("/my")
@@ -42,4 +43,24 @@ public class MembershipController {
             @RequestParam(defaultValue = "PLUS") String level) {
         return Result.success(membershipService.getBenefits(level));
     }
+
+    // ==================== 等级查询(原 /api/level/* 已合并至此) ====================
+
+    /**
+     * 获取所有会员等级列表(公开)
+     * GET /api/membership/levels
+     */
+    @GetMapping("/levels")
+    public Result<List<UserLevelResponse>> listLevels() {
+        return Result.success(membershipService.listLevels());
+    }
+
+    /**
+     * 获取会员等级详情(公开)
+     * GET /api/membership/levels/{id}
+     */
+    @GetMapping("/levels/{id}")
+    public Result<UserLevelResponse> getLevel(@PathVariable Long id) {
+        return Result.success(membershipService.getLevel(id));
+    }
 }

+ 0 - 63
zhijiayun-user/src/main/java/com/xuekairui/user/controller/UserLevelController.java

@@ -1,63 +0,0 @@
-package com.xuekairui.user.controller;
-
-import com.xuekairui.common.Result;
-import com.xuekairui.user.dto.UserLevelResponse;
-import com.xuekairui.user.service.UserLevelService;
-import jakarta.servlet.http.HttpServletRequest;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.web.bind.annotation.*;
-
-import java.util.List;
-
-/**
- * 用户等级控制器(内部兼容层,已由会员体系 /api/membership 接管)
- */
-@Slf4j
-@RestController
-@RequestMapping("/api/level")
-@RequiredArgsConstructor
-public class UserLevelController {
-
-    private final UserLevelService userLevelService;
-
-    /**
-     * 获取所有等级列表(公开)
-     * GET /api/level/list
-     */
-    @GetMapping("/list")
-    public Result<List<UserLevelResponse>> listLevels() {
-        return Result.success(userLevelService.listLevels());
-    }
-
-    /**
-     * 获取等级详情(公开)
-     * GET /api/level/{id}
-     */
-    @GetMapping("/{id}")
-    public Result<UserLevelResponse> getLevel(@PathVariable Long id) {
-        return Result.success(userLevelService.getLevel(id));
-    }
-
-    /**
-     * 获取当前用户等级信息(供 /api/auth/user/info 的 levelInfo 字段使用)
-     * GET /api/level/my
-     */
-    @GetMapping("/my")
-    public Result<UserLevelResponse> getMyLevel(HttpServletRequest request) {
-        Long userId = (Long) request.getAttribute("userId");
-        return Result.success(userLevelService.getUserLevel(userId));
-    }
-
-    /**
-     * 管理员设置用户等级(已映射为发放会员)
-     * PUT /api/level/user/{userId}
-     */
-    @PutMapping("/user/{userId}")
-    public Result<Void> setUserLevel(
-            @PathVariable Long userId,
-            @RequestParam String level) {
-        userLevelService.setUserLevel(userId, level);
-        return Result.success();
-    }
-}

+ 6 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/LoginResponse.java

@@ -40,4 +40,10 @@ public class LoginResponse {
 
     /** 用户角色:USER/ADMIN/SUPER_ADMIN */
     private String role;
+
+    /** 是否踢出了旧设备会话(是否有旧会话被强制终止) */
+    private Boolean kickedPreviousSession;
+
+    /** 被踢出的旧设备信息(如 "Windows Chrome / 192.168.1.1") */
+    private String kickedDeviceInfo;
 }

+ 10 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/User.java

@@ -86,6 +86,16 @@ public class User {
     /** 最后登录IP */
     private String lastLoginIp;
 
+    /** Token版本号(每次登录递增,旧版本Token全部失效,实现单设备登录) */
+    @Builder.Default
+    private Long tokenVersion = 0L;
+
+    /** 当前有效Refresh Token的SHA-256哈希(用于旋转+重用检测) */
+    private String refreshTokenHash;
+
+    /** Refresh Token状态:ACTIVE-有效, USED-已被使用(重用=被盗) */
+    private String refreshTokenStatus;
+
     /** 创建时间 */
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createTime;

+ 57 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -1,5 +1,10 @@
 package com.xuekairui.user.security;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.common.Result;
+import com.xuekairui.user.entity.User;
+import com.xuekairui.user.mapper.UserMapper;
 import com.xuekairui.user.util.JwtUtil;
 import jakarta.servlet.FilterChain;
 import jakarta.servlet.ServletException;
@@ -7,6 +12,8 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
 import org.springframework.lang.NonNull;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.GrantedAuthority;
@@ -19,11 +26,16 @@ import org.springframework.util.StringUtils;
 import org.springframework.web.filter.OncePerRequestFilter;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.Collections;
 import java.util.List;
 
 /**
- * JWT 认证过滤器
+ * JWT 认证过滤器(含单设备登录校验)
+ * <p>
+ * 通过 tokenVersion 机制实现单设备登录:
+ * 每次登录递增版本号,JWT 中携带该版本号,过滤器校验版本号一致性。
+ * 旧设备 JWT 版本号不匹配时自动拒绝,返回 401。
  */
 @Slf4j
 @Component
@@ -31,6 +43,8 @@ import java.util.List;
 public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
     private final JwtUtil jwtUtil;
+    private final UserMapper userMapper;
+    private final TokenVersionCache tokenVersionCache;
 
     private static final String AUTH_HEADER = "Authorization";
     private static final String TOKEN_PREFIX = "Bearer ";
@@ -55,7 +69,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
                 String tokenType = jwtUtil.getTokenType(token);
                 if ("access".equals(tokenType)) {
                     Long userId = jwtUtil.getUserId(token);
-                    String phone = jwtUtil.getPhone(token);
+
+                    // 单设备登录校验:验证 token 中的版本号与当前最新版本一致
+                    if (!isTokenVersionValid(userId, jwtUtil.getTokenVersion(token))) {
+                        log.info("用户 {} 的Token版本号不匹配(已在其他设备登录),拒绝访问", userId);
+                        writeKickedResponse(response);
+                        return;
+                    }
+
                     String role = jwtUtil.getRole(token);
 
                     List<GrantedAuthority> authorities = Collections.singletonList(
@@ -76,7 +97,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
                     // 设置请求属性,供控制器直接使用
                     request.setAttribute("userId", userId);
-                    request.setAttribute("phone", phone);
                     request.setAttribute("role", role);
 
                     // 尽早注入用户ID到MDC,使链路中后续日志都能带上uid
@@ -96,6 +116,40 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
         }
     }
 
+    /**
+     * 校验 Token 版本号是否有效(单设备登录控制)
+     * <p>
+     * 优先从内存缓存读取,缓存未命中时查 DB 并回填缓存。
+     */
+    private boolean isTokenVersionValid(Long userId, Long tokenVersion) {
+        if (userId == null) {
+            return false;
+        }
+        // 从缓存获取当前版本号
+        Long currentVersion = tokenVersionCache.getVersion(userId);
+        if (currentVersion == null) {
+            // 缓存未命中,查 DB
+            User user = userMapper.selectById(userId);
+            if (user == null) {
+                return false;
+            }
+            currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
+            tokenVersionCache.putVersion(userId, currentVersion);
+        }
+        return currentVersion.equals(tokenVersion != null ? tokenVersion : 0L);
+    }
+
+    /**
+     * 写入"已被踢出"错误响应(JSON格式,前端可直接解析)
+     */
+    private void writeKickedResponse(HttpServletResponse response) throws IOException {
+        response.setStatus(HttpStatus.UNAUTHORIZED.value());
+        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        Result<Void> result = Result.error(ErrorCode.TOKEN_KICKED);
+        response.getWriter().write(new ObjectMapper().writeValueAsString(result));
+    }
+
     private String extractToken(HttpServletRequest request) {
         String header = request.getHeader(AUTH_HEADER);
         if (StringUtils.hasText(header) && header.startsWith(TOKEN_PREFIX)) {

+ 39 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/security/TokenVersionCache.java

@@ -0,0 +1,39 @@
+package com.xuekairui.user.security;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Token 版本号内存缓存(避免每次请求都查 DB)
+ * <p>
+ * 用于单设备登录校验:缓存 userId → tokenVersion 的映射,
+ * 登录时更新缓存,过滤器直接从缓存读取版本号与 JWT 中的版本号比对。
+ * 缓存未命中时回退到 DB 查询。
+ */
+@Slf4j
+@Component
+public class TokenVersionCache {
+
+    /** 简单 ConcurrentHashMap 缓存,无 TTL(登录时主动更新,用户量不大时无风险) */
+    private final ConcurrentMap<Long, Long> cache = new ConcurrentHashMap<>();
+
+    /**
+     * 获取用户当前 Token 版本号
+     * @return 缓存的版本号,未命中返回 null(调用方需回退 DB 查询)
+     */
+    public Long getVersion(Long userId) {
+        return cache.get(userId);
+    }
+
+    /**
+     * 更新用户 Token 版本号(登录时调用)
+     */
+    public void putVersion(Long userId, Long version) {
+        if (userId != null && version != null) {
+            cache.put(userId, version);
+        }
+    }
+}

+ 27 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminUserService.java

@@ -27,14 +27,22 @@ public class AdminUserService {
     private final UserMapper userMapper;
 
     /**
-     * 分页查询用户列表
+     * 分页查询用户列表(支持按ID精确/手机号模糊/昵称模糊/角色精确查询)
+     * 无删除接口,用户数据不可删除。
      */
-    public Page<AdminUserResponse> listUsers(Integer pageNum, Integer pageSize, String phone, String role) {
+    public Page<AdminUserResponse> listUsers(Integer pageNum, Integer pageSize,
+                                              Long userId, String phone, String nickname, String role) {
         Page<User> page = new Page<>(pageNum, pageSize);
         LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
+        if (userId != null) {
+            wrapper.eq(User::getId, userId);
+        }
         if (phone != null && !phone.isBlank()) {
             wrapper.like(User::getPhone, phone);
         }
+        if (nickname != null && !nickname.isBlank()) {
+            wrapper.like(User::getNickname, nickname);
+        }
         if (role != null && !role.isBlank()) {
             wrapper.eq(User::getRole, UserRole.fromCode(role));
         }
@@ -85,6 +93,23 @@ public class AdminUserService {
         return toResponse(user);
     }
 
+    /**
+     * 强制踢出用户(递增 tokenVersion,所有 Token 立即失效)
+     */
+    public void kickUser(Long userId, Long operatorId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+        long newVersion = (user.getTokenVersion() != null ? user.getTokenVersion() : 0L) + 1;
+        user.setTokenVersion(newVersion);
+        // 同时清空 Refresh Token,防止旧 Refresh Token 还能刷新
+        user.setRefreshTokenHash(null);
+        user.setRefreshTokenStatus(null);
+        userMapper.updateById(user);
+        log.info("管理员{}强制踢出用户{},新tokenVersion={}", operatorId, userId, newVersion);
+    }
+
     private AdminUserResponse toResponse(User user) {
         return AdminUserResponse.builder()
                 .id(user.getId())

+ 122 - 8
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -36,7 +36,8 @@ public class AuthService {
     private final ApplicationEventPublisher eventPublisher;
     private final PasswordUtil passwordUtil;
     private final OperationAuditLogService auditLogService;
-    private final UserLevelService userLevelService;
+    private final MembershipService membershipService;
+    private final com.xuekairui.user.security.TokenVersionCache tokenVersionCache;
 
     /**
      * 发送验证码
@@ -216,6 +217,43 @@ public class AuthService {
         return buildLoginResponse(wechatUser);
     }
 
+    /**
+     * 心跳保活(零DB查询,仅一次blind UPDATE,高性能)
+     * <p>
+     * 不递增 tokenVersion(同一设备续期),使用 JWT 中已有信息签发新 Token。
+     * 过滤层已校验 tokenVersion 和用户存在性,这里直接签发。
+     */
+    public LoginResponse heartbeat(Long userId, String role) {
+        String roleCode = role != null ? role : "USER";
+        // 从缓存获取 tokenVersion(过滤器已缓存),不查DB
+        Long currentVersion = tokenVersionCache.getVersion(userId);
+        if (currentVersion == null) {
+            currentVersion = 0L;
+        }
+
+        // 签发新 Token(无需查DB,JWT中已有所有必要信息)
+        String newAccessToken = jwtUtil.generateAccessToken(
+                userId, roleCode, currentVersion);
+        String newRefreshToken = jwtUtil.generateRefreshToken(userId);
+
+        // 仅一次 blind UPDATE 更新 Refresh Token 哈希
+        User updateUser = new User();
+        updateUser.setId(userId);
+        updateUser.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
+        updateUser.setRefreshTokenStatus("ACTIVE");
+        userMapper.updateById(updateUser);
+
+        log.debug("用户 {} 心跳续期成功(零查询)", userId);
+        return LoginResponse.builder()
+                .userId(userId)
+                .accessToken(newAccessToken)
+                .refreshToken(newRefreshToken)
+                .expiresIn(jwtUtil.getExpirationInSeconds())
+                .needBindPhone(false)
+                .role(roleCode)
+                .build();
+    }
+
     /**
      * 获取用户信息
      */
@@ -228,32 +266,106 @@ public class AuthService {
     }
 
     /**
-     * 刷新Token
+     * Refresh Token 旋转 + 重用检测
+     * <p>
+     * 每次刷新:旧 Refresh Token 标记为 USED,签发新的一对 Token。
+     * 如果检测到已 USED 的 Refresh Token 被再次使用 → 说明被盗 → 踢出所有设备。
      */
+    @Transactional
     public LoginResponse refreshToken(String refreshToken) {
-        // 验证刷新Token
+        // 1. 验证JWT格式
         String tokenType = jwtUtil.getTokenType(refreshToken);
         if (!"refresh".equals(tokenType)) {
             throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
         }
 
         Long userId = jwtUtil.getUserId(refreshToken);
-        User user = userMapper.selectById(userId);
+        // 只查 Refresh Token 校验所需的字段,减少DB开销
+        User user = userMapper.selectOne(
+                new LambdaQueryWrapper<User>()
+                        .select(User::getId, User::getPhone, User::getRole,
+                                User::getTokenVersion, User::getRefreshTokenHash,
+                                User::getRefreshTokenStatus, User::getStatus)
+                        .eq(User::getId, userId));
         if (user == null) {
             throw new BusinessException(ErrorCode.USER_NOT_FOUND);
         }
 
-        return buildLoginResponse(user);
+        // 2. 计算哈希,比对DB中的记录
+        String tokenHash = JwtUtil.hashToken(refreshToken);
+        String storedHash = user.getRefreshTokenHash();
+        String storedStatus = user.getRefreshTokenStatus();
+
+        // 3. 重用检测:Refresh Token 已被使用过 → 被盗!
+        if (tokenHash.equals(storedHash) && "USED".equals(storedStatus)) {
+            log.warn("用户 {} 的Refresh Token被重用(疑似被盗),踢出所有设备", userId);
+            // 递增 tokenVersion,使所有旧 access token 立即失效
+            long newVersion = (user.getTokenVersion() != null ? user.getTokenVersion() : 0L) + 1;
+            user.setTokenVersion(newVersion);
+            user.setRefreshTokenHash(null);
+            user.setRefreshTokenStatus(null);
+            userMapper.updateById(user);
+            tokenVersionCache.putVersion(userId, newVersion);
+            throw new BusinessException(ErrorCode.TOKEN_KICKED);
+        }
+
+        // 4. 校验:哈希不匹配 → 无效或过期的 Refresh Token
+        if (!tokenHash.equals(storedHash) || !"ACTIVE".equals(storedStatus)) {
+            throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
+        }
+
+        // 5. 旋转:标记旧 Refresh Token 为 USED,签发新的一对
+        user.setRefreshTokenStatus("USED");
+        String roleCode = user.getRole() != null ? user.getRole().getCode() : "USER";
+        long currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
+
+        String newAccessToken = jwtUtil.generateAccessToken(
+                user.getId(), roleCode, currentVersion);
+        String newRefreshToken = jwtUtil.generateRefreshToken(user.getId());
+
+        // 存储新 Refresh Token 哈希
+        user.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
+        user.setRefreshTokenStatus("ACTIVE");
+        userMapper.updateById(user);
+
+        log.debug("用户 {} Refresh Token 旋转成功", userId);
+        return LoginResponse.builder()
+                .userId(user.getId())
+                .accessToken(newAccessToken)
+                .refreshToken(newRefreshToken)
+                .expiresIn(jwtUtil.getExpirationInSeconds())
+                .userInfo(convertToUserInfoResponse(user))
+                .needBindPhone(false)
+                .newUser(false)
+                .role(roleCode)
+                .build();
     }
 
     /**
-     * 构建登录响应
+     * 构建登录响应(递增 tokenVersion + 存储 Refresh Token 哈希用于旋转检测)
      */
     private LoginResponse buildLoginResponse(User user) {
         String roleCode = user.getRole() != null ? user.getRole().getCode() : "USER";
-        String accessToken = jwtUtil.generateAccessToken(user.getId(), user.getPhone(), roleCode);
+
+        // 检测是否存在旧会话
+        long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
+        boolean hadPreviousSession = oldVersion > 0;
+
+        // 递增 tokenVersion,使所有旧 Token 立即失效
+        long newVersion = oldVersion + 1;
+        user.setTokenVersion(newVersion);
+
+        // 生成 Token
+        String accessToken = jwtUtil.generateAccessToken(
+                user.getId(), roleCode, newVersion);
         String refreshToken = jwtUtil.generateRefreshToken(user.getId());
 
+        // 存储 Refresh Token 哈希(用于旋转+重用检测)
+        user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
+        user.setRefreshTokenStatus("ACTIVE");
+        userMapper.updateById(user);
+        tokenVersionCache.putVersion(user.getId(), newVersion);
+
         return LoginResponse.builder()
                 .userId(user.getId())
                 .accessToken(accessToken)
@@ -263,6 +375,8 @@ public class AuthService {
                 .needBindPhone(false)
                 .newUser(false)
                 .role(roleCode)
+                .kickedPreviousSession(hadPreviousSession)
+                .kickedDeviceInfo(hadPreviousSession ? user.getLastLoginDevice() : null)
                 .build();
     }
 
@@ -273,7 +387,7 @@ public class AuthService {
         UserLevelResponse levelInfo = null;
         if (user != null && user.getId() != null) {
             try {
-                levelInfo = userLevelService.getUserLevel(user.getId());
+                levelInfo = membershipService.getUserLevel(user.getId());
             } catch (Exception e) {
                 log.warn("获取用户等级信息失败: userId={}, error={}", user.getId(), e.getMessage());
             }

+ 2 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/service/CouponService.java

@@ -34,7 +34,7 @@ public class CouponService {
     private final CouponMapper couponMapper;
     private final UserCouponMapper userCouponMapper;
     private final UserMapper userMapper;
-    private final UserLevelService userLevelService;
+    private final MembershipService membershipService;
 
     /**
      * 发放优惠券给用户(管理员操作)
@@ -68,7 +68,7 @@ public class CouponService {
 
             // 检查等级要求
             if (coupon.getMinLevelCode() != null) {
-                UserLevel userLevel = userLevelService.getUserLevelEntity(userId);
+                UserLevel userLevel = membershipService.getUserLevelEntity(userId);
                 if (userLevel.getLevelWeight() < getLevelWeight(coupon.getMinLevelCode())) {
                     log.warn("发放优惠券跳过:用户 {} 等级不满足要求", userId);
                     continue;

+ 3 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/service/CrawlerService.java

@@ -39,7 +39,7 @@ public class CrawlerService {
     private final CrawlerUsageLogMapper crawlerUsageLogMapper;
     private final UserCouponMapper userCouponMapper;
     private final CouponMapper couponMapper;
-    private final UserLevelService userLevelService;
+    private final MembershipService membershipService;
     private final CouponService couponService;
     private final CrawlerQuotaGrantService crawlerQuotaGrantService;
 
@@ -47,7 +47,7 @@ public class CrawlerService {
      * 获取用户爬虫权益状态(统一展示等级配额 + 额外配额来源明细)
      */
     public CrawlerStatusResponse getCrawlerStatus(Long userId) {
-        UserLevel level = userLevelService.getUserLevelEntity(userId);
+        UserLevel level = membershipService.getUserLevelEntity(userId);
 
         int todayUsed = crawlerUsageLogMapper.sumTodayUsage(userId);
         int monthUsed = crawlerUsageLogMapper.sumMonthlyUsage(userId);
@@ -119,7 +119,7 @@ public class CrawlerService {
      */
     @Transactional
     public boolean consumeQuota(Long userId, int count, boolean useCouponFirst, String platform) {
-        UserLevel level = userLevelService.getUserLevelEntity(userId);
+        UserLevel level = membershipService.getUserLevelEntity(userId);
 
         int baseDaily = level.getCrawlerQuota();
         int baseMonthly = level.getMonthlyQuota();

+ 174 - 13
zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java

@@ -5,10 +5,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
 import com.xuekairui.user.dto.GrantMembershipRequest;
+import com.xuekairui.user.dto.LevelPricingPlan;
 import com.xuekairui.user.dto.MembershipBenefitResponse;
 import com.xuekairui.user.dto.MembershipResponse;
+import com.xuekairui.user.dto.UserLevelResponse;
 import com.xuekairui.user.entity.MembershipBenefit;
 import com.xuekairui.user.entity.User;
+import com.xuekairui.user.entity.UserLevel;
 import com.xuekairui.user.entity.UserMembership;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.OperationType;
@@ -21,7 +24,11 @@ import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -64,16 +71,6 @@ public class MembershipService {
                 .build();
     }
 
-    /**
-     * 查询用户会员历史记录
-     */
-    public List<MembershipResponse> getUserMembershipHistory(Long userId) {
-        List<UserMembership> list = userMembershipMapper.selectListByUserId(userId);
-        return list.stream()
-                .map(this::toResponse)
-                .collect(Collectors.toList());
-    }
-
     /**
      * 查询指定会员等级的权益列表
      */
@@ -270,9 +267,11 @@ public class MembershipService {
     }
 
     /**
-     * 分页查询会员记录
+     * 分页查询会员记录(支持按用户/等级/来源/手机号/昵称筛选)
      */
-    public Page<MembershipResponse> listMemberships(Integer pageNum, Integer pageSize, Long userId, String level) {
+    public Page<MembershipResponse> listMemberships(Integer pageNum, Integer pageSize,
+                                                     Long userId, String level,
+                                                     String source, String phone, String nickname) {
         Page<UserMembership> page = new Page<>(pageNum, pageSize);
         LambdaQueryWrapper<UserMembership> wrapper = new LambdaQueryWrapper<>();
         if (userId != null) {
@@ -284,6 +283,33 @@ public class MembershipService {
                 wrapper.eq(UserMembership::getLevel, membershipLevel);
             }
         }
+        if (source != null && !source.isBlank()) {
+            wrapper.eq(UserMembership::getSource, source);
+        }
+        // 昵称筛选:先按昵称模糊查用户ID列表,再过滤会员记录
+        if (nickname != null && !nickname.isBlank()) {
+            List<Long> matchedUserIds = userMapper.selectList(
+                    new LambdaQueryWrapper<User>()
+                            .select(User::getId)
+                            .like(User::getNickname, nickname))
+                    .stream().map(User::getId).collect(Collectors.toList());
+            if (matchedUserIds.isEmpty()) {
+                return new Page<>(pageNum, pageSize, 0);
+            }
+            wrapper.in(UserMembership::getUserId, matchedUserIds);
+        }
+        // 手机号筛选:先按手机号模糊查用户ID列表,再过滤会员记录
+        if (phone != null && !phone.isBlank()) {
+            List<Long> matchedUserIds = userMapper.selectList(
+                    new LambdaQueryWrapper<User>()
+                            .select(User::getId)
+                            .like(User::getPhone, phone))
+                    .stream().map(User::getId).collect(Collectors.toList());
+            if (matchedUserIds.isEmpty()) {
+                return new Page<>(pageNum, pageSize, 0);
+            }
+            wrapper.in(UserMembership::getUserId, matchedUserIds);
+        }
         wrapper.orderByDesc(UserMembership::getCreateTime);
 
         Page<UserMembership> result = userMembershipMapper.selectPage(page, wrapper);
@@ -295,7 +321,7 @@ public class MembershipService {
                 .distinct()
                 .collect(Collectors.toList());
         Map<Long, User> userMap = userIds.isEmpty() ? new HashMap<>()
-                : userMapper.selectBatchIds(userIds).stream()
+                : userMapper.selectByIds(userIds).stream()
                         .collect(Collectors.toMap(User::getId, u -> u));
 
         responsePage.setRecords(result.getRecords().stream()
@@ -409,4 +435,139 @@ public class MembershipService {
 
         log.info("处理过期会员记录: {}条,涉及用户: {}个", expired.size(), affectedUserIds.size());
     }
+
+    // ==================== 等级查询(原 UserLevelService,已合并) ====================
+
+    /** 会员等级与兼容 UserLevel 对象的映射 */
+    private static final Map<MembershipLevel, UserLevel> LEVEL_MAP = Map.of(
+            MembershipLevel.PLUS, UserLevel.builder()
+                    .id(1L).levelName("普通会员").levelCode("PLUS").levelWeight(1)
+                    .crawlerQuota(10).monthlyQuota(300).yearlyQuota(3650).maxConcurrent(1)
+                    .description("普通会员:每日有效查询10次").status(1).build(),
+            MembershipLevel.PRO, UserLevel.builder()
+                    .id(2L).levelName("高级会员").levelCode("PRO").levelWeight(2)
+                    .crawlerQuota(999).monthlyQuota(29970).yearlyQuota(364635).maxConcurrent(5)
+                    .description("高级会员:每日不限量").status(1).build(),
+            MembershipLevel.ULTRA, UserLevel.builder()
+                    .id(3L).levelName("超级会员").levelCode("ULTRA").levelWeight(3)
+                    .crawlerQuota(999).monthlyQuota(29970).yearlyQuota(364635).maxConcurrent(10)
+                    .description("超级会员:连锁专用").status(1).build());
+
+    public List<UserLevelResponse> listLevels() {
+        return LEVEL_MAP.keySet().stream()
+                .sorted(Comparator.comparingInt(l -> LEVEL_MAP.get(l).getLevelWeight()))
+                .map(this::buildUserLevelWithBenefits)
+                .map(this::toLevelResponse)
+                .collect(Collectors.toList());
+    }
+
+    public UserLevelResponse getLevel(Long levelId) {
+        return LEVEL_MAP.values().stream()
+                .filter(l -> l.getId().equals(levelId)).findFirst()
+                .map(l -> buildUserLevelWithBenefits(l.getLevelCode() != null
+                        ? MembershipLevel.fromCode(l.getLevelCode()) : MembershipLevel.PLUS))
+                .map(this::toLevelResponse)
+                .orElseThrow(() -> new BusinessException(ErrorCode.LEVEL_NOT_FOUND));
+    }
+
+    public UserLevelResponse getUserLevel(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+        UserLevelResponse resp = toLevelResponse(resolveUserLevel(user));
+        fillMembershipExpiration(resp, user);
+        return resp;
+    }
+
+    public UserLevel getUserLevelEntity(Long userId) {
+        return resolveUserLevel(userMapper.selectById(userId));
+    }
+
+    private UserLevel resolveUserLevel(User user) {
+        if (user == null) {
+            return buildUserLevelWithBenefits(MembershipLevel.PLUS);
+        }
+        if (user.getMembershipExpireAt() != null
+                && user.getMembershipExpireAt().isBefore(LocalDateTime.now())) {
+            return buildUserLevelWithBenefits(MembershipLevel.PLUS);
+        }
+        UserMembership active = userMembershipMapper.selectActiveByUserId(user.getId());
+        return buildUserLevelWithBenefits(active != null && active.getLevel() != null
+                ? active.getLevel() : MembershipLevel.PLUS);
+    }
+
+    private UserLevel buildUserLevelWithBenefits(MembershipLevel level) {
+        UserLevel base = LEVEL_MAP.getOrDefault(level,
+                LEVEL_MAP.get(MembershipLevel.PLUS));
+        var builder = UserLevel.builder()
+                .id(base.getId()).levelName(base.getLevelName()).levelCode(base.getLevelCode())
+                .levelWeight(base.getLevelWeight()).description(base.getDescription())
+                .status(base.getStatus()).crawlerQuota(base.getCrawlerQuota())
+                .monthlyQuota(base.getMonthlyQuota()).yearlyQuota(base.getYearlyQuota())
+                .maxConcurrent(base.getMaxConcurrent());
+        for (MembershipBenefit b : membershipBenefitMapper.selectEnabledByLevel(level)) {
+            if (b.getBenefitValue() == null) {
+                continue;
+            }
+            switch (b.getBenefitType()) {
+                case "DAILY_QUOTA" -> builder.crawlerQuota(b.getBenefitValue());
+                case "MONTHLY_QUOTA" -> builder.monthlyQuota(b.getBenefitValue());
+                case "YEARLY_QUOTA" -> builder.yearlyQuota(b.getBenefitValue());
+                case "MAX_CONCURRENT" -> builder.maxConcurrent(b.getBenefitValue());
+            }
+        }
+        return builder.build();
+    }
+
+    private UserLevelResponse toLevelResponse(
+            UserLevel level) {
+        return UserLevelResponse.builder()
+                .id(level.getId()).levelName(level.getLevelName())
+                .levelCode(level.getLevelCode()).levelWeight(level.getLevelWeight())
+                .crawlerQuota(level.getCrawlerQuota()).monthlyQuota(level.getMonthlyQuota())
+                .yearlyQuota(level.getYearlyQuota()).maxConcurrent(level.getMaxConcurrent())
+                .description(level.getDescription()).version(null)
+                .pricingPlans(buildPricingPlans(level.getLevelCode())).build();
+    }
+
+    private void fillMembershipExpiration(UserLevelResponse resp, User user) {
+        if (user == null || resp == null) {
+            return;
+        }
+        boolean premium = resp.getLevelCode() != null && !"PLUS".equals(resp.getLevelCode());
+        if (!premium) { resp.setMembershipExpireAt(null); resp.setMembershipDaysRemaining(-1); return; }
+        LocalDateTime expire = user.getMembershipExpireAt();
+        if (expire != null && expire.isAfter(LocalDateTime.now())) {
+            resp.setMembershipExpireAt(expire);
+            resp.setMembershipDaysRemaining((int) ChronoUnit.DAYS.between(LocalDateTime.now(), expire));
+        } else if (expire != null) {
+            resp.setMembershipExpireAt(expire); resp.setMembershipDaysRemaining(0);
+        } else {
+            log.warn("高级会员缺少过期时间: userId={}", user.getId());
+            resp.setMembershipExpireAt(null); resp.setMembershipDaysRemaining(0);
+        }
+    }
+
+    private List<LevelPricingPlan> buildPricingPlans(String levelCode) {
+        return switch (levelCode) {
+            case "PRO" -> List.of(
+                    LevelPricingPlan.builder()
+                            .planCode("MONTHLY_PRO").planName("高级会员·月卡")
+                            .price(new BigDecimal("29.90"))
+                            .originalPrice(new BigDecimal("59.90"))
+                            .durationDays(30).description("每天不限量· 1个月29.9").build(),
+                    LevelPricingPlan.builder()
+                            .planCode("YEARLY_PRO").planName("高级会员·年卡")
+                            .price(new BigDecimal("299.90"))
+                            .originalPrice(new BigDecimal("599.90"))
+                            .durationDays(365).description("每天不限量· 1年299.9").build());
+            case "ULTRA" -> List.of(
+                    LevelPricingPlan.builder()
+                            .planCode("ULTRA").planName("超级会员·连锁专享")
+                            .price(BigDecimal.ZERO).originalPrice(null)
+                            .durationDays(0).description("连锁专用,多店铺管理,敬请期待").build());
+            default -> Collections.emptyList();
+        };
+    }
 }

+ 0 - 324
zhijiayun-user/src/main/java/com/xuekairui/user/service/UserLevelService.java

@@ -1,324 +0,0 @@
-package com.xuekairui.user.service;
-
-import com.xuekairui.common.BusinessException;
-import com.xuekairui.common.ErrorCode;
-import com.xuekairui.user.dto.GrantMembershipRequest;
-import com.xuekairui.user.dto.LevelPricingPlan;
-import com.xuekairui.user.dto.UserLevelResponse;
-import com.xuekairui.user.entity.MembershipBenefit;
-import com.xuekairui.user.entity.User;
-import com.xuekairui.user.entity.UserLevel;
-import com.xuekairui.user.entity.UserMembership;
-import com.xuekairui.user.enums.MembershipLevel;
-import com.xuekairui.user.mapper.MembershipBenefitMapper;
-import com.xuekairui.user.mapper.UserMapper;
-import com.xuekairui.user.mapper.UserMembershipMapper;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
-import java.util.*;
-import java.util.stream.Collectors;
-
-/**
- * 用户等级服务(已切换为会员体系兼容层)
- * <p>
- * 原 NORMAL/VIP/SVIP 等级体系已下线,现由 PLUS/PRO/ULTRA 会员等级接管。
- * 本服务保留旧 {@link UserLevel}/{@link UserLevelResponse} 结构的兼容输出,
- * 内部实际根据 {@link User#getMembershipLevel()} 返回对应权益。
- */
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class UserLevelService {
-
-    private final UserMapper userMapper;
-    private final MembershipService membershipService;
-    private final MembershipBenefitMapper membershipBenefitMapper;
-    private final UserMembershipMapper userMembershipMapper;
-
-    /**
-     * 会员等级与兼容等级对象的映射(id 仅用于兼容旧接口)
-     */
-    private static final Map<MembershipLevel, UserLevel> LEVEL_MAP = Map.of(
-            MembershipLevel.PLUS, UserLevel.builder()
-                    .id(1L)
-                    .levelName("普通会员")
-                    .levelCode("PLUS")
-                    .levelWeight(1)
-                    .crawlerQuota(10)
-                    .monthlyQuota(300)
-                    .yearlyQuota(3650)
-                    .maxConcurrent(1)
-                    .description("普通会员:每日有效查询10次")
-                    .status(1)
-                    .build(),
-            MembershipLevel.PRO, UserLevel.builder()
-                    .id(2L)
-                    .levelName("高级会员")
-                    .levelCode("PRO")
-                    .levelWeight(2)
-                    .crawlerQuota(999)
-                    .monthlyQuota(29970)
-                    .yearlyQuota(364635)
-                    .maxConcurrent(5)
-                    .description("高级会员:每日不限量")
-                    .status(1)
-                    .build(),
-            MembershipLevel.ULTRA, UserLevel.builder()
-                    .id(3L)
-                    .levelName("超级会员")
-                    .levelCode("ULTRA")
-                    .levelWeight(3)
-                    .crawlerQuota(999)
-                    .monthlyQuota(29970)
-                    .yearlyQuota(364635)
-                    .maxConcurrent(10)
-                    .description("超级会员:连锁专用,多店铺管理敬请期待")
-                    .status(1)
-                    .build()
-    );
-
-    /**
-     * 获取所有等级列表(会员体系:PLUS/PRO/ULTRA)
-     * <p>
-     * 配额从 t_membership_benefit 实时读取,未配置时使用硬编码兜底。
-     */
-    public List<UserLevelResponse> listLevels() {
-        return LEVEL_MAP.keySet().stream()
-                .sorted(Comparator.comparingInt(level -> LEVEL_MAP.get(level).getLevelWeight()))
-                .map(this::buildUserLevelWithBenefits)
-                .map(this::toResponse)
-                .collect(Collectors.toList());
-    }
-
-    /**
-     * 获取等级详情(按兼容 id 查询)
-     */
-    public UserLevelResponse getLevel(Long levelId) {
-        return LEVEL_MAP.values().stream()
-                .filter(l -> l.getId().equals(levelId))
-                .findFirst()
-                .map(l -> buildUserLevelWithBenefits(l.getLevelCode() != null
-                        ? MembershipLevel.fromCode(l.getLevelCode())
-                        : MembershipLevel.PLUS))
-                .map(this::toResponse)
-                .orElseThrow(() -> new BusinessException(ErrorCode.LEVEL_NOT_FOUND));
-    }
-
-    /**
-     * 获取用户当前等级(基于会员等级)
-     */
-    public UserLevelResponse getUserLevel(Long userId) {
-        User user = userMapper.selectById(userId);
-        if (user == null) {
-            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
-        }
-        UserLevelResponse response = toResponse(resolveUserLevel(user));
-        fillMembershipExpiration(response, user);
-        return response;
-    }
-
-    /**
-     * 获取用户等级实体(内部使用,基于会员等级)
-     */
-    public UserLevel getUserLevelEntity(Long userId) {
-        User user = userMapper.selectById(userId);
-        return resolveUserLevel(user);
-    }
-
-    /**
-     * 设置用户等级(管理员操作,已映射为发放会员)
-     */
-    @Transactional
-    public void setUserLevel(Long userId, String levelCode) {
-        User user = userMapper.selectById(userId);
-        if (user == null) {
-            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
-        }
-        MembershipLevel level = MembershipLevel.fromCode(levelCode);
-        if (level == null) {
-            throw new BusinessException(ErrorCode.PARAM_ERROR, "会员等级无效");
-        }
-
-        GrantMembershipRequest request = new GrantMembershipRequest();
-        request.setUserId(userId);
-        request.setLevel(levelCode);
-        request.setSource("ADMIN_GRANT");
-        membershipService.grantMembership(request, 0L, "ADMIN");
-
-        log.info("用户 {} 等级已调整为 {}", userId, levelCode);
-    }
-
-    /**
-     * 新用户注册时获取默认等级ID(兼容旧调用,固定返回普通会员ID)
-     */
-    public Long getDefaultLevelId() {
-        return LEVEL_MAP.get(MembershipLevel.PLUS).getId();
-    }
-
-    /**
-     * 获取默认等级实体
-     */
-    private UserLevel getDefaultLevelEntity() {
-        return LEVEL_MAP.get(MembershipLevel.PLUS);
-    }
-
-    /**
-     * 解析用户对应的兼容等级对象
-     * <p>
-     * 优先从数据库查询用户当前有效的会员记录;
-     * 如果没有有效会员(含邀请、付费等记录),默认为普通会员(PLUS)。
-     * 普通会员免费开放,注册即拥有。
-     */
-    private UserLevel resolveUserLevel(User user) {
-        if (user == null) {
-            return buildUserLevelWithBenefits(MembershipLevel.PLUS);
-        }
-        // 实时过期检查:用户表 membership_expire_at 已过期,直接返回普通会员
-        if (user.getMembershipExpireAt() != null
-                && user.getMembershipExpireAt().isBefore(LocalDateTime.now())) {
-            return buildUserLevelWithBenefits(MembershipLevel.PLUS);
-        }
-        // 查询用户当前有效的会员记录(PRO/ULTRA 等付费或赠送的会员)
-        UserMembership activeMembership = userMembershipMapper.selectActiveByUserId(user.getId());
-        if (activeMembership != null && activeMembership.getLevel() != null) {
-            return buildUserLevelWithBenefits(activeMembership.getLevel());
-        }
-        // 无有效会员记录,默认为普通会员(PLUS)
-        return buildUserLevelWithBenefits(MembershipLevel.PLUS);
-    }
-
-    /**
-     * 根据会员等级与权益配置构建兼容等级对象
-     */
-    private UserLevel buildUserLevelWithBenefits(MembershipLevel level) {
-        UserLevel base = LEVEL_MAP.getOrDefault(level, getDefaultLevelEntity());
-
-        // 复制基础属性,避免修改静态映射
-        UserLevel.UserLevelBuilder builder = UserLevel.builder()
-                .id(base.getId())
-                .levelName(base.getLevelName())
-                .levelCode(base.getLevelCode())
-                .levelWeight(base.getLevelWeight())
-                .description(base.getDescription())
-                .status(base.getStatus())
-                .crawlerQuota(base.getCrawlerQuota())
-                .monthlyQuota(base.getMonthlyQuota())
-                .yearlyQuota(base.getYearlyQuota())
-                .maxConcurrent(base.getMaxConcurrent());
-
-        // 从 t_membership_benefit 读取权益并覆盖对应配额
-        List<MembershipBenefit> benefits = membershipBenefitMapper.selectEnabledByLevel(level);
-        for (MembershipBenefit benefit : benefits) {
-            if (benefit.getBenefitValue() == null) {
-                continue;
-            }
-            switch (benefit.getBenefitType()) {
-                case "DAILY_QUOTA" -> builder.crawlerQuota(benefit.getBenefitValue());
-                case "MONTHLY_QUOTA" -> builder.monthlyQuota(benefit.getBenefitValue());
-                case "YEARLY_QUOTA" -> builder.yearlyQuota(benefit.getBenefitValue());
-                case "MAX_CONCURRENT" -> builder.maxConcurrent(benefit.getBenefitValue());
-                default -> log.debug("未知会员权益类型: {}", benefit.getBenefitType());
-            }
-        }
-
-        return builder.build();
-    }
-
-    /**
-     * 实体转响应(包含会员定价方案)
-     */
-    private UserLevelResponse toResponse(UserLevel level) {
-        String levelCode = level.getLevelCode();
-        return UserLevelResponse.builder()
-                .id(level.getId())
-                .levelName(level.getLevelName())
-                .levelCode(levelCode)
-                .levelWeight(level.getLevelWeight())
-                .crawlerQuota(level.getCrawlerQuota())
-                .monthlyQuota(level.getMonthlyQuota())
-                .yearlyQuota(level.getYearlyQuota())
-                .maxConcurrent(level.getMaxConcurrent())
-                .description(level.getDescription())
-                .version(null)
-                .pricingPlans(buildPricingPlans(levelCode))
-                .build();
-    }
-
-    /**
-     * 填充会员过期信息到 UserLevelResponse
-     * <p>
-     * 根据已解析的等级结果(response.levelCode)来决定过期信息:
-     * - PLUS(普通会员):无过期时间概念,membershipExpireAt=null, daysRemaining=-1
-     * - PRO/ULTRA:从用户表 membershipExpireAt 读取,已过期则视为普通会员
-     */
-    private void fillMembershipExpiration(UserLevelResponse response, User user) {
-        if (user == null || response == null) {
-            return;
-        }
-        // 根据已解析的等级判断:resolveUserLevel 已做过期检查
-        boolean isPremium = response.getLevelCode() != null
-                && !"PLUS".equals(response.getLevelCode());
-        if (!isPremium) {
-            // 普通会员或已过期降级:无过期时间概念
-            response.setMembershipExpireAt(null);
-            response.setMembershipDaysRemaining(-1);
-            return;
-        }
-        // 高级会员(PRO/ULTRA):必须有到期时间,不存在"永久高级会员"
-        LocalDateTime expireAt = user.getMembershipExpireAt();
-        if (expireAt != null && expireAt.isAfter(LocalDateTime.now())) {
-            response.setMembershipExpireAt(expireAt);
-            long daysRemaining = ChronoUnit.DAYS.between(LocalDateTime.now(), expireAt);
-            response.setMembershipDaysRemaining((int) daysRemaining);
-        } else if (expireAt != null) {
-            // 已过期:resolveUserLevel 应该已降级为 PLUS,兜底处理
-            response.setMembershipExpireAt(expireAt);
-            response.setMembershipDaysRemaining(0);
-        } else {
-            // expireAt = null 但 level 是 PRO/ULTRA:数据异常(DDL未执行或回填遗漏)
-            log.warn("高级会员缺少过期时间: userId={}, level={}", user.getId(), response.getLevelCode());
-            response.setMembershipExpireAt(null);
-            response.setMembershipDaysRemaining(0);
-        }
-    }
-
-    /**
-     * 构建指定等级的定价方案列表
-     * <p>
-     * 普通会员(PLUS)免费开放,无付费套餐;
-     * 高级会员(PRO)提供月卡(30天)和年卡(365天)。
-     */
-    private List<LevelPricingPlan> buildPricingPlans(String levelCode) {
-        return switch (levelCode) {
-            case "PLUS" -> Collections.emptyList();
-            case "PRO" -> List.of(
-                    LevelPricingPlan.builder()
-                            .planCode("MONTHLY_PRO").planName("高级会员·月卡")
-                            .price(new BigDecimal("29.90")).originalPrice(new BigDecimal("59.90"))
-                            .durationDays(30).description("每天不限量· 1个月29.9(原价59.9)")
-                            .build(),
-                    LevelPricingPlan.builder()
-                            .planCode("YEARLY_PRO").planName("高级会员·年卡")
-                            .price(new BigDecimal("299.90")).originalPrice(new BigDecimal("599.90"))
-                            .durationDays(365).description("每天不限量· 1年299.9(原价599.9)")
-                            .build()
-            );
-            case "ULTRA" -> List.of(
-                    LevelPricingPlan.builder()
-                            .planCode("ULTRA").planName("超级会员·连锁专享")
-                            .price(BigDecimal.ZERO).originalPrice(null)
-                            .durationDays(0).description("连锁专用,多店铺管理,敬请期待")
-                            .build()
-            );
-            default -> Collections.emptyList();
-        };
-    }
-
-
-}

+ 2 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/service/WatchlistService.java

@@ -28,7 +28,7 @@ import java.util.stream.Collectors;
 public class WatchlistService {
 
     private final WatchlistMapper watchlistMapper;
-    private final UserLevelService userLevelService;
+    private final MembershipService membershipService;
 
     /** 根据等级编码获取关注上限 */
     private static final Map<String, Integer> WATCHLIST_LIMITS = Map.of(
@@ -119,7 +119,7 @@ public class WatchlistService {
     /** 根据用户等级获取关注上限 */
     private int getWatchlistLimit(Long userId) {
         try {
-            UserLevel level = userLevelService.getUserLevelEntity(userId);
+            UserLevel level = membershipService.getUserLevelEntity(userId);
             return WATCHLIST_LIMITS.getOrDefault(level.getLevelCode(), 50);
         } catch (Exception e) {
             log.debug("获取用户等级失败,使用默认关注上限: {}", e.getMessage());

+ 37 - 11
zhijiayun-user/src/main/java/com/xuekairui/user/util/JwtUtil.java

@@ -10,6 +10,8 @@ import org.springframework.stereotype.Component;
 
 import javax.crypto.SecretKey;
 import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
@@ -35,14 +37,14 @@ public class JwtUtil {
     }
 
     /**
-     * 生成访问令牌
+     * 生成访问令牌(含 tokenVersion 用于单设备登录控制)
      */
-    public String generateAccessToken(Long userId, String phone, String role) {
+    public String generateAccessToken(Long userId, String role, Long tokenVersion) {
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
-        claims.put("phone", phone);
         claims.put("role", role != null ? role : "USER");
         claims.put("type", "access");
+        claims.put("tokenVersion", tokenVersion != null ? tokenVersion : 0L);
 
         return Jwts.builder()
                 .claims(claims)
@@ -114,14 +116,6 @@ public class JwtUtil {
         return claims.get("userId", Long.class);
     }
 
-    /**
-     * 获取手机号
-     */
-    public String getPhone(String token) {
-        Claims claims = parseToken(token);
-        return claims.get("phone", String.class);
-    }
-
     /**
      * 获取令牌类型
      */
@@ -143,6 +137,38 @@ public class JwtUtil {
         }
     }
 
+    /**
+     * 获取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用于旋转+重用检测)
+     */
+    public static String hashToken(String token) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] digest = md.digest(token.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException("SHA-256 not available", e);
+        }
+    }
 
     /**
      * 获取过期时间(秒)