liuchengsen 1 ماه پیش
والد
کامیت
0ee999aa0e
21فایلهای تغییر یافته به همراه260 افزوده شده و 140 حذف شده
  1. 4 1
      .idea/dbnavigator.xml
  2. 2 2
      docs/前端对接变更说明-2026-06-30.md
  3. 16 13
      zhijiayun-gateway/src/main/java/com/xuekairui/gateway/filter/RequestLoggingFilter.java
  4. 2 2
      zhijiayun-gateway/src/main/resources/application-dev.yml
  5. 2 2
      zhijiayun-gateway/src/main/resources/application-prod.yml
  6. 0 17
      zhijiayun-gateway/src/main/resources/db/schema.sql
  7. 11 1
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/EndToEndBusinessFlowTest.java
  8. 71 3
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayBaseTest.java
  9. 29 11
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/TrialQuotaConfigTest.java
  10. 9 1
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/UserAuthFlowTest.java
  11. 4 2
      zhijiayun-gateway/src/test/resources/application-test.yml
  12. 0 14
      zhijiayun-gateway/src/test/resources/test-schema.sql
  13. 2 2
      zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/InviteController.java
  14. 1 3
      zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java
  15. 1 1
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchRecordResponse.java
  16. 1 1
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/SearchRecord.java
  17. 85 58
      zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java
  18. 7 0
      zhijiayun-user/src/main/java/com/xuekairui/user/security/TokenVersionCache.java
  19. 7 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java
  20. 4 4
      zhijiayun-user/src/main/java/com/xuekairui/user/service/SearchRecordService.java
  21. 2 2
      zhijiayun-user/src/main/java/com/xuekairui/user/util/JwtUtil.java

+ 4 - 1
.idea/dbnavigator.xml

@@ -45,6 +45,9 @@
   <component name="DBNavigator.Project.DatabaseSessionManager">
     <connection id="6e17216c-4396-42b7-a333-2d90a3c933e5" />
   </component>
+  <component name="DBNavigator.Project.ExecutionManager">
+    <retain-sticky-names value="false" />
+  </component>
   <component name="DBNavigator.Project.ObjectQuickFilterManager">
     <last-used-operator value="EQUAL" />
     <filters />
@@ -587,7 +590,7 @@
       <regional-settings>
         <date-format value="MEDIUM" />
         <number-format value="UNGROUPED" />
-        <locale value="en" />
+        <locale value="SYSTEM_DEFAULT" />
         <use-custom-formats value="false" />
       </regional-settings>
       <environment>

+ 2 - 2
docs/前端对接变更说明-2026-06-30.md

@@ -211,8 +211,8 @@ CREATE TABLE `t_payment_order_extra_payment` (...);
 
 需要放入:
 
-- `药易采-x64.exe`
-- `药易采-arm64.dmg`
+- `智价云-x64.exe`
+- `智价云-arm64.dmg`
 
 > 中文文件名已通过 `Content-Disposition: filename*=UTF-8''...` 编码,兼容主流浏览器。
 

+ 16 - 13
zhijiayun-gateway/src/main/java/com/xuekairui/gateway/filter/RequestLoggingFilter.java

@@ -33,6 +33,7 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
     private static final String REQUEST_ID_HEADER = "X-Request-Id";
     private static final String TRACE_ID_KEY = "traceId";
     private static final String START_TIME_ATTR = "requestStartTime";
+    private static final String USER_ID_ATTR = "userId";
 
     @Override
     protected void doFilterInternal(
@@ -43,16 +44,22 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
 
         String path = request.getRequestURI();
 
-        // 排除不需要记录的路径
+        // 日志功能关闭或路径被排除:只保证 MDC 不泄漏,不做记录
         if (!properties.getLogging().isEnabled() || shouldExclude(path)) {
-            filterChain.doFilter(request, response);
+            MDC.clear();
+            try {
+                filterChain.doFilter(request, response);
+            } finally {
+                MDC.clear();
+            }
             return;
         }
 
-        // 生成Trace ID(用于链路追踪)
+        // ========== 1. 请求开始:初始化 MDC ==========
+        MDC.clear();
         String traceId = request.getHeader(REQUEST_ID_HEADER);
         if (traceId == null || traceId.isBlank()) {
-            traceId = generateTraceId();
+            traceId = TraceIdGenerator.generate();
         }
         
         // 设置到MDC中,所有日志自动携带
@@ -71,11 +78,14 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
 
         log.debug("{} {} | IP: {}", method, fullUrl, clientIp);
 
+        // ========== 2. 执行业务链 ==========
         try {
             filterChain.doFilter(request, response);
         } finally {
-            // userId 在 JWT 过滤器解析后才有,放到 finally 里补充到 MDC
-            Object userId = request.getAttribute("userId");
+            // ========== 3. 请求结束:记录耗时 ==========
+            // JwtAuthenticationFilter 将 userId 写入 request attribute,
+            // 此处重新取出注入 MDC,确保最终日志携带 uid
+            Object userId = request.getAttribute(USER_ID_ATTR);
             if (userId != null) {
                 MDC.put("uid", String.valueOf(userId));
             }
@@ -107,11 +117,4 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
         return properties.getLogging().getExcludePaths().stream()
                 .anyMatch(path::startsWith);
     }
-
-    /**
-     * 生成 Trace ID
-     */
-    private String generateTraceId() {
-        return TraceIdGenerator.generate();
-    }
 }

+ 2 - 2
zhijiayun-gateway/src/main/resources/application-dev.yml

@@ -50,8 +50,8 @@ invite:
   bind-window-days: 30
   # 客户端安装包本地存储目录(流式下载)
   download-base-path: ${INVITE_DOWNLOAD_BASE_PATH:/desktop-updates}
-  windows-filename: ${INVITE_WINDOWS_FILENAME:药易采-x64.exe}
-  mac-filename: ${INVITE_MAC_FILENAME:药易采-arm64.dmg}
+  windows-filename: ${INVITE_WINDOWS_FILENAME:智价云-x64.exe}
+  mac-filename: ${INVITE_MAC_FILENAME:智价云-arm64.dmg}
 
 # 支付模块(开发环境启用真实支付,手动确认按钮用于测试回调)
 payment:

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

@@ -104,8 +104,8 @@ invite:
   bind-window-days: ${INVITE_BIND_WINDOW_DAYS:30}
   # 客户端安装包本地存储目录(流式下载)
   download-base-path: ${INVITE_DOWNLOAD_BASE_PATH:/desktop-updates}
-  windows-filename: ${INVITE_WINDOWS_FILENAME:药易采-x64.exe}
-  mac-filename: ${INVITE_MAC_FILENAME:药易采-arm64.dmg}
+  windows-filename: ${INVITE_WINDOWS_FILENAME:智价云-x64.exe}
+  mac-filename: ${INVITE_MAC_FILENAME:智价云-arm64.dmg}
 
 # 支付模块(生产环境必须设为 false)
 payment:

+ 0 - 17
zhijiayun-gateway/src/main/resources/db/schema.sql

@@ -522,23 +522,6 @@ CREATE TABLE IF NOT EXISTS `t_operation_audit_log` (
     KEY `idx_result` (`result`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作审计日志表';
 
--- ============================================
--- 搜索历史表
--- ============================================
-CREATE TABLE IF NOT EXISTS `t_search_history` (
-    `id`            BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
-    `user_id`       BIGINT        NOT NULL COMMENT '用户ID',
-    `keyword`       VARCHAR(200)  NOT NULL COMMENT '搜索关键词(药品名称)',
-    `platform`      VARCHAR(32)   DEFAULT NULL COMMENT '筛选平台代码(NULL表示全部平台)',
-    `result_count`  INT           DEFAULT 0 COMMENT '返回结果数量',
-    `search_time`   DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '搜索时间',
-    `create_time`   DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-    PRIMARY KEY (`id`),
-    KEY `idx_user_id` (`user_id`),
-    KEY `idx_user_time` (`user_id`, `search_time`),
-    KEY `idx_keyword` (`keyword`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='搜索历史表';
-
 -- ============================================
 -- 搜索记录表(完整比价快照)
 -- ============================================

+ 11 - 1
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/EndToEndBusinessFlowTest.java

@@ -114,7 +114,7 @@ public class EndToEndBusinessFlowTest extends GatewayBaseTest {
         // 2.3 验证已绑定2个平台
         mockMvc.perform(get("/api/platform-account/list")
                 .header("Authorization", "Bearer " + userToken))
-                .andExpect(jsonPath("$.data.length()").value(3));
+                .andExpect(jsonPath("$.data.length()").value(2));
         
         log.info("✅ 阶段2完成: 已绑定2个B2B平台");
     }
@@ -248,6 +248,16 @@ public class EndToEndBusinessFlowTest extends GatewayBaseTest {
 
         adminToken = generateAccessToken(1L, "13800138000");
 
+        // generateAccessToken 将 DB token_version 重置为 0,导致 stage1 获取的 userToken(version=1)失效
+        // 重新 SMS 登录刷新 userToken,使 token_version 递增到 1,与 DB 保持一致
+        MvcResult refreshResult = mockMvc.perform(post("/api/auth/sms/login")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content("{\"phone\":\"13800138000\",\"code\":\"123456\"}"))
+                .andExpect(status().isOk())
+                .andReturn();
+        JsonNode refreshData = objectMapper.readTree(refreshResult.getResponse().getContentAsString()).get("data");
+        userToken = refreshData.get("accessToken").asText();
+
         // 7.1 查询待审核执照列表
         mockMvc.perform(get("/api/admin/license/review?reviewStatus=PENDING")
                 .header("Authorization", "Bearer " + adminToken))

+ 71 - 3
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayBaseTest.java

@@ -1,13 +1,19 @@
 package com.xuekairui.gateway;
 
+import com.xuekairui.user.security.TokenVersionCache;
 import io.jsonwebtoken.Jwts;
 import io.jsonwebtoken.security.Keys;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestInstance;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.context.annotation.Import;
+import org.springframework.core.io.ClassPathResource;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.init.ScriptUtils;
 import org.springframework.test.context.ActiveProfiles;
 import org.springframework.test.web.servlet.MockMvc;
 
@@ -27,6 +33,7 @@ import java.util.Map;
 @AutoConfigureMockMvc
 @ActiveProfiles("test")
 @Import(TestConfig.class)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
 public abstract class GatewayBaseTest {
 
     @Autowired
@@ -38,10 +45,63 @@ public abstract class GatewayBaseTest {
     @Autowired
     protected RedisTemplate<String, Object> redisTemplate;
 
+    @Autowired
+    protected TokenVersionCache tokenVersionCache;
+
     /** 测试用 JWT Secret(与 application-test.yml 一致) */
     private static final String TEST_JWT_SECRET =
             "TestSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!";
 
+    /**
+     * 每个测试类之前重置数据库,确保测试隔离
+     * <p>
+     * 重新执行 test-schema.sql(DROP + CREATE + INSERT),保证每个测试类从干净的数据库状态开始。
+     * TRUNCATE 所有业务表以重置 auto_increment 计数器。
+     */
+    @BeforeAll
+    void resetDatabase() {
+        try {
+            var resource = new ClassPathResource("test-schema.sql");
+            // 使用 try-with-resources 确保连接正确释放,避免连接池泄漏
+            try (var conn = jdbcTemplate.getDataSource().getConnection()) {
+                ScriptUtils.executeSqlScript(conn, resource);
+            }
+            // TRUNCATE 业务表,重置 auto_increment 计数器
+            String[] tables = {
+                "t_payment_order_extra_payment", "t_payment_order", "t_payment_plan",
+                "t_watchlist", "t_operation_audit_log", "t_trial_quota_config",
+                "t_business_license", "t_platform_account", "t_platform_config",
+                "t_crawler_quota_grant", "t_crawler_usage_log",
+                "t_invite_relation", "t_invite_code", "t_invite_config",
+                "t_search_record", "t_platform_session",
+                "t_user_coupon", "t_coupon",
+                "t_membership_benefit", "t_user_membership", "t_user"
+            };
+            for (String table : tables) {
+                try {
+                    jdbcTemplate.execute("TRUNCATE TABLE " + table);
+                } catch (Exception ignored) { }
+            }
+            // 重新插入初始数据
+            try (var conn = jdbcTemplate.getDataSource().getConnection()) {
+                ScriptUtils.executeSqlScript(conn, resource);
+            }
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to reset test database", e);
+        }
+    }
+
+    /**
+     * 每个测试前清理 TokenVersionCache,
+     * 确保缓存不会残留其他测试的版本信息。
+     * 注意:不重置 DB 的 token_version,避免与 SMS 登录返回的 token 版本冲突。
+     * generateAccessToken() 会将 DB 版本同步到 token 版本,保证一致性。
+     */
+    @BeforeEach
+    protected void clearTokenVersionCache() {
+        tokenVersionCache.clear();
+    }
+
     /**
      * 生成测试用 Access Token(默认 ADMIN 角色)
      */
@@ -53,19 +113,27 @@ public abstract class GatewayBaseTest {
      * 生成测试用 Access Token(含 tokenVersion=0 以兼容单设备登录校验)
      */
     protected String generateAccessToken(Long userId, String phone, String role) {
-        return generateAccessToken(userId, phone, role, 0L);
+        return generateAccessToken(userId, role, 0L);
     }
 
     /**
      * 生成测试用 Access Token(可指定 tokenVersion,用于单设备登录测试)
+     * <p>
+     * 同步 DB 的 token_version 到指定值,确保 JwtAuthenticationFilter 的版本校验通过。
      */
-    protected String generateAccessToken(Long userId, String phone, String role, Long tokenVersion) {
+    protected String generateAccessToken(Long userId, String role, Long tokenVersion) {
+        long ver = tokenVersion != null ? tokenVersion : 0L;
+        // 同步 DB 版本,避免缓存清理后 DB 版本与 token 版本不一致
+        try {
+            jdbcTemplate.update("UPDATE t_user SET token_version = ? WHERE id = ?", ver, userId);
+        } catch (Exception ignored) { }
+
         SecretKey key = Keys.hmacShaKeyFor(TEST_JWT_SECRET.getBytes(StandardCharsets.UTF_8));
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
         claims.put("role", role != null ? role : "USER");
         claims.put("type", "access");
-        claims.put("tokenVersion", tokenVersion != null ? tokenVersion : 0L);
+        claims.put("tokenVersion", ver);
 
         return Jwts.builder()
                 .claims(claims)

+ 29 - 11
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/TrialQuotaConfigTest.java

@@ -1,7 +1,10 @@
 package com.xuekairui.gateway;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.*;
 import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
 
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -14,6 +17,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
 class TrialQuotaConfigTest extends GatewayBaseTest {
 
+    private static final ObjectMapper mapper = new ObjectMapper();
+    /** 动态捕获的配置 ID,避免依赖 auto_increment 顺序 */
+    private static Long createdConfigId;
+    private static Long activityConfigId;
+    private static Long deleteConfigId;
+
     private String adminToken() {
         return generateAccessToken(1L, "13800138000");
     }
@@ -69,7 +78,7 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
                     "description": "自动化测试创建的配置"
                 }
                 """;
-        mockMvc.perform(post("/api/admin/trial-quota/config")
+        MvcResult result = mockMvc.perform(post("/api/admin/trial-quota/config")
                         .header("Authorization", "Bearer " + adminToken())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(body))
@@ -78,7 +87,10 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
                 .andExpect(jsonPath("$.data.id").isNumber())
                 .andExpect(jsonPath("$.data.configName").value("测试体验配置"))
                 .andExpect(jsonPath("$.data.trialDays").value(15))
-                .andExpect(jsonPath("$.data.dailyQueryLimit").value(30));
+                .andExpect(jsonPath("$.data.dailyQueryLimit").value(30))
+                .andReturn();
+        createdConfigId = mapper.readTree(result.getResponse().getContentAsString())
+                .get("data").get("id").asLong();
     }
 
     @Test
@@ -99,7 +111,7 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
                     "description": "618活动专属体验配置"
                 }
                 """;
-        mockMvc.perform(post("/api/admin/trial-quota/config")
+        MvcResult result = mockMvc.perform(post("/api/admin/trial-quota/config")
                         .header("Authorization", "Bearer " + adminToken())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(body))
@@ -107,7 +119,10 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data.configName").value("618活动体验配置"))
                 .andExpect(jsonPath("$.data.configType").value("ACTIVITY"))
-                .andExpect(jsonPath("$.data.maxUsers").value(1000));
+                .andExpect(jsonPath("$.data.maxUsers").value(1000))
+                .andReturn();
+        activityConfigId = mapper.readTree(result.getResponse().getContentAsString())
+                .get("data").get("id").asLong();
     }
 
     @Test
@@ -139,7 +154,7 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
                     "description": "更新后的配置"
                 }
                 """;
-        mockMvc.perform(put("/api/admin/trial-quota/config/1")
+        mockMvc.perform(put("/api/admin/trial-quota/config/" + createdConfigId)
                         .header("Authorization", "Bearer " + adminToken())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(body))
@@ -176,7 +191,7 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
     @Order(32)
     @DisplayName("PUT /api/admin/trial-quota/config/{id} - 无Token应返回401")
     void updateConfig_withoutToken_shouldReturn401() throws Exception {
-        mockMvc.perform(put("/api/admin/trial-quota/config/1")
+        mockMvc.perform(put("/api/admin/trial-quota/config/" + createdConfigId)
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("{}"))
                 .andExpect(status().isUnauthorized());
@@ -202,15 +217,18 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
                     "description": "此配置将被删除"
                 }
                 """;
-        mockMvc.perform(post("/api/admin/trial-quota/config")
+        MvcResult createResult = mockMvc.perform(post("/api/admin/trial-quota/config")
                         .header("Authorization", "Bearer " + adminToken())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(createBody))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.id").isNumber());
+                .andExpect(jsonPath("$.data.id").isNumber())
+                .andReturn();
+        deleteConfigId = mapper.readTree(createResult.getResponse().getContentAsString())
+                .get("data").get("id").asLong();
 
-        // 删除该配置(id=3,前两个分别在Order 20和21创建)
-        mockMvc.perform(delete("/api/admin/trial-quota/config/3")
+        // 删除该配置
+        mockMvc.perform(delete("/api/admin/trial-quota/config/" + deleteConfigId)
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200));
@@ -230,7 +248,7 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
     @Order(42)
     @DisplayName("DELETE /api/admin/trial-quota/config/{id} - 无Token应返回401")
     void deleteConfig_withoutToken_shouldReturn401() throws Exception {
-        mockMvc.perform(delete("/api/admin/trial-quota/config/1"))
+        mockMvc.perform(delete("/api/admin/trial-quota/config/" + createdConfigId))
                 .andExpect(status().isUnauthorized());
     }
 

+ 9 - 1
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/UserAuthFlowTest.java

@@ -220,7 +220,15 @@ public class UserAuthFlowTest extends GatewayBaseTest {
      * 断言用户会员有效期约为指定月数(仅适用于有到期时间的高级会员)
      */
     private void assertMembershipDuration(Long uid, int expectedMonths) throws Exception {
-        String token = generateAccessToken(uid, "tmp" + uid);
+        // 读取 DB 当前 token_version,避免用硬编码 0 覆盖导致登录 token 失效
+        Long currentVersion = 0L;
+        try {
+            currentVersion = jdbcTemplate.queryForObject(
+                    "SELECT token_version FROM t_user WHERE id = ?", Long.class, uid);
+            if (currentVersion == null) currentVersion = 0L;
+        } catch (Exception ignored) { }
+
+        String token = generateAccessToken(uid, "tmp" + uid, currentVersion);
         MvcResult result = mockMvc.perform(get("/api/membership/my")
                 .header("Authorization", "Bearer " + token))
                 .andExpect(status().isOk())

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

@@ -21,9 +21,10 @@ spring:
     # HikariCP 连接池配置
     hikari:
       minimum-idle: 2
-      maximum-pool-size: 5
-      connection-timeout: 10000
+      maximum-pool-size: 10
+      connection-timeout: 30000
       max-lifetime: 600000
+      idle-timeout: 60000
 
   # 禁用 SQL 初始化(测试用代码建表)
   sql:
@@ -85,6 +86,7 @@ gateway:
 logging:
   level:
     com.xuekairui: debug
+    org.springframework.context.support.DefaultLifecycleProcessor: error
 
 # 支付配置(测试环境启用模拟支付)
 payment:

+ 0 - 14
zhijiayun-gateway/src/test/resources/test-schema.sql

@@ -5,7 +5,6 @@
 DROP TABLE IF EXISTS t_payment_order;
 DROP TABLE IF EXISTS t_payment_plan;
 DROP TABLE IF EXISTS t_watchlist;
-DROP TABLE IF EXISTS t_search_history;
 DROP TABLE IF EXISTS t_operation_audit_log;
 DROP TABLE IF EXISTS t_trial_quota_config;
 DROP TABLE IF EXISTS t_business_license;
@@ -463,19 +462,6 @@ VALUES
     ('ULTRA', 'YEARLY_QUOTA', 364635, '超级会员:每年查询364635次(999*365)', 1),
     ('ULTRA', 'MAX_CONCURRENT', 10, '超级会员最大并发数', 1);
 
--- 搜索历史表
-CREATE TABLE IF NOT EXISTS t_search_history (
-    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
-    user_id         BIGINT        NOT NULL,
-    keyword         VARCHAR(200)  NOT NULL,
-    platform        VARCHAR(32)   DEFAULT NULL,
-    result_count    INT           DEFAULT 0,
-    search_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
-    create_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
-    INDEX idx_user_id (user_id),
-    INDEX idx_user_time (user_id, search_time)
-);
-
 -- 关注/收藏表
 CREATE TABLE IF NOT EXISTS t_watchlist (
     id                  BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 2 - 2
zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/InviteController.java

@@ -105,8 +105,8 @@ public class InviteController {
      * 浏览器直接访问邀请链接入口(公开,无需登录)
      * 自动计入点击数,并根据访问设备流式返回对应桌面客户端安装包
      * GET /api/invite/{code}
-     * Windows x64 -> /desktop-updates/药易采-x64.exe
-     * macOS ARM64 -> /desktop-updates/药易采-arm64.dmg
+     * Windows x64 -> /desktop-updates/智价云-x64.exe
+     * macOS ARM64 -> /desktop-updates/智价云-arm64.dmg
      */
     @GetMapping("/{code}")
     public void openInviteLink(

+ 1 - 3
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -265,9 +265,7 @@ public class InviteService {
 
     /**
      * 根据 User-Agent 解析本次应下载的安装包文件
-     * Windows x64 -> {downloadBasePath}/药易采-x64.exe
-     * macOS ARM64 -> {downloadBasePath}/药易采-arm64.dmg
-     * 其他默认返回 Windows 安装包
+      * 其他默认返回 Windows 安装包
      */
     public java.io.File resolveDownloadFile(String userAgent) {
         String filename = windowsFilename;

+ 1 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchRecordResponse.java

@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
 public class SearchRecordResponse {
 
     /** 记录ID */
-    private Long id;
+    private String id;
 
     /** 药品名称 */
     private String drugName;

+ 1 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/entity/SearchRecord.java

@@ -10,7 +10,7 @@ import java.time.LocalDateTime;
 
 /**
  * 搜索记录实体(完整保存一次比价的搜索快照)
- * 与 SearchHistory(轻量关键词日志)不同,此表存储完整的平台搜索结果
+ * 存储药品名称、搜索时间、状态文案及完整的平台搜索结果/日志
  */
 @Data
 @NoArgsConstructor

+ 85 - 58
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -5,6 +5,7 @@ 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;
@@ -12,6 +13,7 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.slf4j.MDC;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.lang.NonNull;
@@ -21,10 +23,11 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
 import org.springframework.stereotype.Component;
-import org.slf4j.MDC;
 import org.springframework.util.StringUtils;
 import org.springframework.web.filter.OncePerRequestFilter;
 
+import io.jsonwebtoken.Claims;
+
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.Collections;
@@ -70,69 +73,78 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
         String token = extractToken(request);
         String path = request.getRequestURI();
         boolean isHeartbeat = "/api/auth/heartbeat".equals(path);
-        if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
-            try {
-                boolean isLenientPath = isLenientPath(request);
-                String tokenType = isLenientPath
-                        ? jwtUtil.parseTokenLenient(token).get("type", String.class)
-                        : jwtUtil.getTokenType(token);
-                if ("access".equals(tokenType)) {
-                    Long userId = isLenientPath
-                            ? jwtUtil.parseTokenLenient(token).get("userId", Long.class)
-                            : jwtUtil.getUserId(token);
-                    Long tokenVer = isLenientPath
-                            ? getTokenVersionLenient(token)
-                            : jwtUtil.getTokenVersion(token);
-                    // 单设备登录校验:验证 token 中的版本号与当前最新版本一致
-                    if (!isTokenVersionValid(userId, tokenVer)) {
-                        log.info("用户 {} 的Token版本号不匹配(已在其他设备登录),拒绝访问", userId);
-                        writeKickedResponse(response);
-                        return;
-                    }
 
-                    String role = isLenientPath
-                            ? jwtUtil.parseTokenLenient(token).get("role", String.class)
-                            : jwtUtil.getRole(token);
-                    if (role == null) {
-                        role = "USER";
+        // 尽早从 token 中提取 userId 注入 MDC(宽容模式,过期 token 也能提取)
+        extractUidForMdc(token);
+
+        try {
+            if (token != null && !token.isEmpty() && SecurityContextHolder.getContext().getAuthentication() == null) {
+                try {
+                    boolean isLenientPath = isLenientPath(request);
+                    Claims claims;
+                    if (isLenientPath) {
+                        // 宽容模式:一次解析 JWT,避免重复调用 parseTokenLenient
+                        claims = jwtUtil.parseTokenLenient(token);
+                    } else {
+                        // 严格模式:一次解析 JWT,避免重复调用 parseToken
+                         claims = jwtUtil.parseToken(token);
+                    }
+                    String tokenType = claims.get("type", String.class);
+                    Long userId = claims.get("userId", Long.class);
+                    Long tokenVer = getTokenVersionFromClaims(claims);
+                    String role = claims.get("role", String.class);
+
+                    // 确保 MDC 中有 uid(extractUidForMdc 可能已设置,此处兜底)
+                    if (userId != null) {
+                        MDC.put("uid", String.valueOf(userId));
                     }
 
-                    List<GrantedAuthority> authorities = Collections.singletonList(
-                            new SimpleGrantedAuthority("ROLE_" + role)
-                    );
-
-                    UsernamePasswordAuthenticationToken authentication =
-                            new UsernamePasswordAuthenticationToken(
-                                    userId,
-                                    null,
-                                    authorities
-                            );
-                    authentication.setDetails(
-                            new WebAuthenticationDetailsSource().buildDetails(request)
-                    );
-
-                    SecurityContextHolder.getContext().setAuthentication(authentication);
-
-                    // 设置请求属性,供控制器直接使用
-                    request.setAttribute("userId", userId);
-                    request.setAttribute("role", role);
-
-                    // 尽早注入用户ID到MDC,使链路中后续日志都能带上uid
-                    MDC.put("uid", String.valueOf(userId));
-                } else if (isHeartbeat) {
-                    log.warn("[heartbeat] 401原因: token类型不是access,实际type={},请用accessToken调用heartbeat", tokenType);
+                    if ("access".equals(tokenType)) {
+                        // 尽早设置 userId 到 request attribute,供后续 RequestLoggingFilter 日志使用
+                        request.setAttribute("userId", userId);
+
+                        // 单设备登录校验:验证 token 中的版本号与当前最新版本一致
+                        if (!isTokenVersionValid(userId, tokenVer)) {
+                            log.info("用户 {} 的Token版本号不匹配(已在其他设备登录),拒绝访问", userId);
+                            writeKickedResponse(response);
+                            return;
+                        }
+                        if (role == null) {
+                            role = "USER";
+                        }
+
+                        List<GrantedAuthority> authorities = Collections.singletonList(
+                                new SimpleGrantedAuthority("ROLE_" + role)
+                        );
+
+                        UsernamePasswordAuthenticationToken authentication =
+                                new UsernamePasswordAuthenticationToken(
+                                        userId,
+                                        null,
+                                        authorities
+                                );
+                        authentication.setDetails(
+                                new WebAuthenticationDetailsSource().buildDetails(request)
+                        );
+
+                        SecurityContextHolder.getContext().setAuthentication(authentication);
+
+                        // 设置请求属性,供控制器使用
+                        request.setAttribute("role", role);
+                    } else if (isHeartbeat) {
+                        log.warn("[heartbeat] 401原因: token类型不是access,实际type={},请用accessToken调用heartbeat", tokenType);
+                    }
+                } catch (Exception e) {
+                    log.warn("JWT认证失败: {}", e.getMessage());
+                    SecurityContextHolder.clearContext();
                 }
-            } catch (Exception e) {
-                log.warn("JWT认证失败: {}", e.getMessage());
-                SecurityContextHolder.clearContext();
             }
-        }
 
-        try {
             filterChain.doFilter(request, response);
         } finally {
-            // 清理当前过滤器注入的uid,避免跨请求泄漏
+            // 清理 uid,RequestLoggingFilter 会从 request attribute 重新获取 uid 用于日志
             MDC.remove("uid");
+            SecurityContextHolder.clearContext();
         }
     }
 
@@ -189,10 +201,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
         return false;
     }
 
-    /** 从可能已过期的 token 中提取版本号 */
-    private Long getTokenVersionLenient(String token) {
+    /** 从已解析的 Claims 中提取版本号(避免重复解析 JWT) */
+    private Long getTokenVersionFromClaims(Claims claims) {
         try {
-            Object version = jwtUtil.parseTokenLenient(token).get("tokenVersion");
+            Object version = claims.get("tokenVersion");
             if (version instanceof Number) {
                 return ((Number) version).longValue();
             }
@@ -201,4 +213,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
             return 0L;
         }
     }
+
+    /** 从 token 中提取 userId 注入 MDC,便于整个请求链路的日志带上 uid */
+    private void extractUidForMdc(String token) {
+        if (token == null || token.isEmpty()) {
+            return;
+        }
+        try {
+            Long uid = jwtUtil.parseTokenLenient(token).get("userId", Long.class);
+            if (uid != null) {
+                MDC.put("uid", String.valueOf(uid));
+            }
+        } catch (Exception ignored) {
+            // token 完全无法解析,忽略
+        }
+    }
 }

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

@@ -36,4 +36,11 @@ public class TokenVersionCache {
             cache.put(userId, version);
         }
     }
+
+    /**
+     * 清空所有缓存(测试用)
+     */
+    public void clear() {
+        cache.clear();
+    }
 }

+ 7 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -15,6 +15,7 @@ import com.xuekairui.user.util.PasswordUtil;
 import io.jsonwebtoken.Claims;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.slf4j.MDC;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -153,6 +154,9 @@ public class AuthService {
         // 5. 生成临时Token,要求绑定手机号
         String tempToken = jwtUtil.generateTempToken(wechatInfo.getOpenId(), wechatInfo.getUnionId());
 
+        // 确保 MDC 中有 uid(微信登录临时Token场景不走 buildLoginResponse)
+        MDC.put("uid", String.valueOf(user.getId()));
+
         return LoginResponse.builder()
                 .needBindPhone(true)
                 .tempToken(tempToken)
@@ -345,6 +349,9 @@ public class AuthService {
      * 构建登录响应(递增 tokenVersion + 存储 Refresh Token 哈希用于旋转检测)
      */
     private LoginResponse buildLoginResponse(User user) {
+        // 登录/注册时注入 MDC uid,不依赖 token 解析
+        MDC.put("uid", String.valueOf(user.getId()));
+
         String roleCode = user.getRole() != null ? user.getRole().getCode() : "USER";
 
         // 检测是否存在旧会话

+ 4 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/service/SearchRecordService.java

@@ -17,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
 import java.time.ZoneId;
+import java.util.Map;
 
 /**
  * 搜索记录服务
@@ -103,7 +104,7 @@ public class SearchRecordService {
      */
     private SearchRecordResponse toResponse(SearchRecord record) {
         return SearchRecordResponse.builder()
-                .id(record.getId())
+                .id(String.valueOf(record.getId()))
                 .drugName(record.getDrugName())
                 .searchedAt(record.getSearchedAt())
                 .statusText(record.getStatusText())
@@ -119,7 +120,7 @@ public class SearchRecordService {
      */
     private SearchRecordResponse toListResponse(SearchRecord record) {
         return SearchRecordResponse.builder()
-                .id(record.getId())
+                .id(String.valueOf(record.getId()))
                 .drugName(record.getDrugName())
                 .searchedAt(record.getSearchedAt())
                 .statusText(record.getStatusText())
@@ -143,8 +144,7 @@ public class SearchRecordService {
         }
         int count = 0;
         for (Object item : (java.util.List<?>) parsedResults) {
-            if (item instanceof java.util.Map) {
-                java.util.Map<?, ?> platform = (java.util.Map<?, ?>) item;
+            if (item instanceof Map<?, ?> platform) {
                 Object response = platform.get("response");
                 if (response instanceof java.util.Map) {
                     Object products = ((java.util.Map<?, ?>) response).get("products");

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

@@ -103,7 +103,7 @@ public class JwtUtil {
         } catch (ExpiredJwtException e) {
             throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
         } catch (JwtException e) {
-            log.warn("JWT解析失败, 原因: {}", e.getMessage());
+            log.warn("JWT解析Token失败, 原因: {}", e.getMessage());
             throw new BusinessException(ErrorCode.TOKEN_INVALID);
         }
     }
@@ -124,7 +124,7 @@ public class JwtUtil {
             // 过期仍返回 claims,调用方自行判断
             return e.getClaims();
         } catch (JwtException e) {
-            log.warn("JWT解析失败, 原因: {}", e.getMessage());
+            log.warn("JWT解析TokenLenient失败, 原因: {}", e.getMessage());
             throw new BusinessException(ErrorCode.TOKEN_INVALID);
         }
     }