liuchengsen 2 месяцев назад
Родитель
Сommit
4bfc57ebd1
33 измененных файлов с 1006 добавлено и 363 удалено
  1. 2 1
      deploy/.env.example
  2. 2 1
      deploy/docker-compose.yml
  3. 0 1
      zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java
  4. 2 1
      zhijiayun-gateway/src/main/resources/application-dev.yml
  5. 2 1
      zhijiayun-gateway/src/main/resources/application.yml
  6. 40 0
      zhijiayun-gateway/src/main/resources/db/migration-v2.sql
  7. 40 0
      zhijiayun-gateway/src/main/resources/db/schema.sql
  8. 3 10
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/DrugSearchControllerTest.java
  9. 0 65
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/SearchHistoryControllerTest.java
  10. 6 4
      zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentProperties.java
  11. 26 19
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java
  12. 64 8
      zhijiayun-test/src/main/resources/static/invite-test.html
  13. 1 1
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/DrugSearchController.java
  14. 68 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/PlatformSessionController.java
  15. 0 54
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/SearchHistoryController.java
  16. 88 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/SearchRecordController.java
  17. 43 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/PlatformSessionResponse.java
  18. 39 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/PlatformSessionSaveRequest.java
  19. 25 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/PlatformSessionUpdateResponse.java
  20. 0 30
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchHistoryResponse.java
  21. 7 2
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchQueryRequest.java
  22. 36 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchRecordResponse.java
  23. 29 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchRecordSaveRequest.java
  24. 64 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/PlatformSession.java
  25. 0 43
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/SearchHistory.java
  26. 46 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/SearchRecord.java
  27. 29 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/PlatformSessionMapper.java
  28. 6 6
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/SearchRecordMapper.java
  29. 16 15
      zhijiayun-user/src/main/java/com/xuekairui/user/service/DrugSearchService.java
  30. 6 6
      zhijiayun-user/src/main/java/com/xuekairui/user/service/MockDrugPriceService.java
  31. 184 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/PlatformSessionService.java
  32. 0 95
      zhijiayun-user/src/main/java/com/xuekairui/user/service/SearchHistoryService.java
  33. 132 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/SearchRecordService.java

+ 2 - 1
deploy/.env.example

@@ -35,9 +35,10 @@ WECHAT_REDIRECT_URI=https://your-domain.com/api/auth/wechat/callback
 # ---- 邀请链接基础地址(生产域名) ----
 INVITE_BASE_URL=https://your-domain.com
 
-# ---- 支付宝当面付(公钥模式优先) ----
+# ---- 支付宝电脑网站支付(公钥模式优先) ----
 ALIPAY_APP_ID=请填入支付宝应用ID
 ALIPAY_NOTIFY_URL=https://priceapi.kailin.com.cn/api/payment/callback/alipay
+ALIPAY_RETURN_URL=https://priceapi.kailin.com.cn/payment/success
 # 公钥模式(优先):填入私钥字符串和支付宝公钥
 ALIPAY_PRIVATE_KEY=请填入应用私钥
 ALIPAY_PUBLIC_KEY=请填入支付宝公钥

+ 2 - 1
deploy/docker-compose.yml

@@ -108,9 +108,10 @@ services:
       WECHAT_REDIRECT_URI: ${WECHAT_REDIRECT_URI}
       # 邀请链接
       INVITE_BASE_URL: ${INVITE_BASE_URL:-https://your-domain.com}
-      # 支付宝(证书模式
+      # 支付宝(电脑网站支付
       ALIPAY_APP_ID: ${ALIPAY_APP_ID:-}
       ALIPAY_NOTIFY_URL: ${ALIPAY_NOTIFY_URL:-https://priceapi.kailin.com.cn/api/payment/callback/alipay}
+      ALIPAY_RETURN_URL: ${ALIPAY_RETURN_URL:-https://priceapi.kailin.com.cn/payment/success}
       ALIPAY_PRIVATE_KEY_PATH: ${ALIPAY_PRIVATE_KEY_PATH:-}
       ALIPAY_APP_CERT_PATH: ${ALIPAY_APP_CERT_PATH:-}
       ALIPAY_CERT_PATH: ${ALIPAY_CERT_PATH:-}

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

@@ -94,7 +94,6 @@ public enum ErrorCode {
 
     // ========== 搜索模块 4301-4399 ==========
     SEARCH_QUOTA_EXCEEDED(4301, "搜索次数已用完"),
-    SEARCH_HISTORY_NOT_FOUND(4302, "搜索记录不存在"),
     SEARCH_KEYWORD_EMPTY(4303, "搜索关键词不能为空"),
 
     // ========== 关注模块 4401-4499 ==========

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

@@ -56,10 +56,11 @@ payment:
   qrcode:
     width: 200
     height: 200
-  # 支付宝(开发/测试环境):将沙箱证书文件复制到 certs/dev/alipay/ 目录下
+  # 支付宝(开发/测试环境):电脑网站支付,将沙箱证书文件复制到 certs/dev/alipay/ 目录下
   alipay:
     app-id: ${ALIPAY_APP_ID:2021006169624093}
     notify-url: ${ALIPAY_NOTIFY_URL:https://priceapi.kailin.com.cn/api/payment/callback/alipay}
+    return-url: ${ALIPAY_RETURN_URL:http://localhost:8001/payment/success}
     server-url: ${ALIPAY_SERVER_URL:https://openapi.alipay.com/gateway.do}
     # 公钥模式(优先):配置了 alipay-public-key 即走公钥模式
     private-key: ${ALIPAY_PRIVATE_KEY:MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCUw9VUpXoHxL6ycUd/3kaVBtZtkHFDyoCJVbS6QCaPA6Z+kTRGX7z81TWZbSUDZelI5TK6bIA+Duh4smr7vscdNadp65cQP+vIVRYyAbQKPWtwK4rfF07gMcW+CXI3KmJsKt8zZtqyD5cXs0IcGLEhF8EmUTVDSiWGSqHiXl7ZU94dFxDuY0h0iafxPghpDUCrCJTtkfQUsC82aWknRVvPqsyw/0G+V3xupkHbJlAlNwj0ITSt+SvgARZlHtcjVKgLDaO2aE/ydqGyp64nLb253mCc0Rt9tI75rgZiVK7JeMcVKUy8B1gpOZxkvfwxwVzpKi5a7Y3tlth+37vqhFXXAgMBAAECggEAKk11wFg91y+vOnbT6hx+3anQF9/XFXvkCSmB9uR/EnPrSFpqDwjyaMMINtS+jbTRHZVvU2u/G+aJXKj/mw6o+Dllnc0Ma+aBWbo4UYgCdlb40jDPkJjEKSR+Iof1HpLC4hde2PMKhU/Bm0+yIOM0m0ByAD9fcf8HpKRcXE6RkiLELaxMjEL14JHaKSa0l5F4sGbscChFVHgr0KVI0HYtWeV5bm3877QOD86DnlAkeKomXHeNhw+jVbHVzHkV9S0bhfizHloXIbFYaNAFpyBAIypyqhyPlfpvENRjn7y05X1QgobtOX7CBOMWiGbAd9F4RhtDpCvF3546iKMz6N3NQQKBgQDDPKpVii/OU6PD1miT4a3imWCRBY4N7sqQVZDxWge8TlJab7oPALVpK16+D/sH2o33cagsW60yFLwLwXUxbVBlsGznpZEBPGFmQ44jGFyiDwkHmiIh4Wck0PbeJcbQAg9AwSHKjJspZ8kUdOcBHMIainnf0qyOJu8mO7ls4fJiyQKBgQDDEI1XGsxPty7S3XIWGRCRv64aVmUUn5W5Icc/111pp+E4O1gNyvjCDWxIVEQipD5fuAUtC55PG+qa5kY2y8nlOxXB9Uu4fO5a/8U4UZtTHgXJQqcmMWblXHvAtsCwGTLTF4o7WiwCv2QtAYPja1kKxNegBuptygFlWfvhvfujnwKBgE3zDLb2YzvnnPz1v+87LafJQQrlwt/BNVMHNsWGAoymYK8G6KxQxh1lWYBc9QcftYrT8Cc1uyLBsIqpBNdlI7JWDEU4j3qE1wZzdm6ArYtuoVZNrc8FJZ9HZykuwAAQsnJSKeGq3dBcRaZLPmftSXo7S4mXnZlr5hPHyLK2S4YxAoGBALQ2X45AuM9Q2pZ/df5Ka89reirk9H/IK+gc99rYWnnZWq67zM75XTbSdd6g00351ZJTDZ0fJcPKc8VlxyqqvbzlCGXn/GvWJ9O0hv1QD6MLkqbsyEX10C1IcyCI9p36ucTh85h/5zcmoSMfSjzaGSpkzx0OJpkFzqJXLujQv2HbAoGAN6JTrmbZzdviJRBGysEzImUKfZh5MFmlc9de1dBAsGXu5n4DIvsESK4QivooSL7sAhoXj19VWxVAl5JLh2uIYVZyJXbWq6CgimYVtMNbUcIdyuhLM+LlUuAHI7z5erehBs7bxVh+4yKO53axXUSfU/hcxvz9g9wmz26HirzmVGo=}

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

@@ -95,12 +95,13 @@ payment:
     serial-no: ${WECHAT_PAY_SERIAL_NO:}
     notify-url: ${WECHAT_PAY_NOTIFY_URL:https://your-domain.com/api/payment/callback/wechat}
     platform-cert-path: ${WECHAT_PAY_PLATFORM_CERT_PATH:}
-  # 支付宝配置(当面付/扫码支付
+  # 支付宝配置(电脑网站支付 alipay.trade.page.pay
   alipay:
     app-id: ${ALIPAY_APP_ID:}
     private-key: ${ALIPAY_PRIVATE_KEY:}
     alipay-public-key: ${ALIPAY_PUBLIC_KEY:}
     notify-url: ${ALIPAY_NOTIFY_URL:https://your-domain.com/api/payment/callback/alipay}
+    return-url: ${ALIPAY_RETURN_URL:https://your-domain.com/payment/success}
     server-url: https://openapi.alipay.com/gateway.do
     format: json
     charset: UTF-8

+ 40 - 0
zhijiayun-gateway/src/main/resources/db/migration-v2.sql

@@ -45,3 +45,43 @@ ON DUPLICATE KEY UPDATE
     `reward_type` = 'MEMBERSHIP',
     `landing_desc` = '登录智价云(药店端)平台,聚合比价查低价',
     `max_total_invites` = VALUES(`max_total_invites`);
+
+-- ============================================
+-- 4. DDL: 搜索记录表(完整比价快照)
+-- ============================================
+CREATE TABLE IF NOT EXISTS `t_search_record` (
+    `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`           BIGINT        NOT NULL COMMENT '用户ID',
+    `drug_name`         VARCHAR(200)  NOT NULL COMMENT '药品名称',
+    `searched_at`       DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '搜索时间',
+    `status_text`       VARCHAR(500)  DEFAULT NULL COMMENT '状态文案',
+    `platform_results`  LONGTEXT      DEFAULT NULL COMMENT '平台搜索结果(JSON文本)',
+    `search_log`        LONGTEXT      DEFAULT NULL COMMENT '搜索日志(JSON文本,可为null)',
+    `create_time`       DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_user_searched` (`user_id`, `searched_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='搜索记录表(完整比价快照)';
+
+-- ============================================
+-- 5. DDL: 平台会话快照表
+-- ============================================
+CREATE TABLE IF NOT EXISTS `t_platform_session` (
+    `id`                  BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`             BIGINT        NOT NULL COMMENT '用户ID',
+    `platform_code`       VARCHAR(50)   NOT NULL COMMENT '平台代码',
+    `platform_name`       VARCHAR(100)  DEFAULT NULL COMMENT '平台展示名称',
+    `login_url`           VARCHAR(500)  DEFAULT NULL COMMENT '平台登录页地址',
+    `account_label`       VARCHAR(200)  DEFAULT NULL COMMENT '账号标签',
+    `captured_at`         DATETIME      DEFAULT NULL COMMENT 'Cookie捕获时间',
+    `updated_at`          DATETIME      DEFAULT NULL COMMENT '绑定元数据更新时间',
+    `cookies_encrypted`   LONGTEXT      DEFAULT NULL COMMENT '加密后的Cookie JSON数组',
+    `version`             INT           DEFAULT 1 COMMENT '数据版本号',
+    `expires_at`          DATETIME      DEFAULT NULL COMMENT '最早过期的Cookie时间',
+    `create_time`         DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`         DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_user_platform` (`user_id`, `platform_code`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_expires_at` (`expires_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台会话快照表';

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

@@ -464,6 +464,46 @@ CREATE TABLE IF NOT EXISTS `t_search_history` (
     KEY `idx_keyword` (`keyword`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='搜索历史表';
 
+-- ============================================
+-- 搜索记录表(完整比价快照)
+-- ============================================
+CREATE TABLE IF NOT EXISTS `t_search_record` (
+    `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`           BIGINT        NOT NULL COMMENT '用户ID',
+    `drug_name`         VARCHAR(200)  NOT NULL COMMENT '药品名称',
+    `searched_at`       DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '搜索时间',
+    `status_text`       VARCHAR(500)  DEFAULT NULL COMMENT '状态文案',
+    `platform_results`  LONGTEXT      DEFAULT NULL COMMENT '平台搜索结果(JSON文本)',
+    `search_log`        LONGTEXT      DEFAULT NULL COMMENT '搜索日志(JSON文本,可为null)',
+    `create_time`       DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_user_searched` (`user_id`, `searched_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='搜索记录表(完整比价快照)';
+
+-- ============================================
+-- 平台会话快照表
+-- ============================================
+CREATE TABLE IF NOT EXISTS `t_platform_session` (
+    `id`                  BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`             BIGINT        NOT NULL COMMENT '用户ID',
+    `platform_code`       VARCHAR(50)   NOT NULL COMMENT '平台代码',
+    `platform_name`       VARCHAR(100)  DEFAULT NULL COMMENT '平台展示名称',
+    `login_url`           VARCHAR(500)  DEFAULT NULL COMMENT '平台登录页地址',
+    `account_label`       VARCHAR(200)  DEFAULT NULL COMMENT '账号标签',
+    `captured_at`         DATETIME      DEFAULT NULL COMMENT 'Cookie捕获时间',
+    `updated_at`          DATETIME      DEFAULT NULL COMMENT '绑定元数据更新时间',
+    `cookies_encrypted`   LONGTEXT      DEFAULT NULL COMMENT '加密后的Cookie JSON数组',
+    `version`             INT           DEFAULT 1 COMMENT '数据版本号',
+    `expires_at`          DATETIME      DEFAULT NULL COMMENT '最早过期的Cookie时间',
+    `create_time`         DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`         DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_user_platform` (`user_id`, `platform_code`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_expires_at` (`expires_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台会话快照表';
+
 -- ============================================
 -- 关注/收藏表
 -- ============================================

+ 3 - 10
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/DrugSearchControllerTest.java

@@ -89,7 +89,7 @@ class DrugSearchControllerTest extends GatewayBaseTest {
         mockMvc.perform(post("/api/search/query")
                         .header("Authorization", "Bearer " + token)
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"keyword\":\"阿莫西林胶囊\",\"platform\":\"yaoshibang\"}"))
+                        .content("{\"keyword\":\"阿莫西林胶囊\",\"platforms\":[\"yaoshibang\"]}"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data.totalCount").value(1))
@@ -98,8 +98,8 @@ class DrugSearchControllerTest extends GatewayBaseTest {
 
     @Test
     @Order(5)
-    @DisplayName("POST /api/search/query - 模糊搜索并记录历史")
-    void search_fuzzyKeyword_shouldRecordHistory() throws Exception {
+    @DisplayName("POST /api/search/query - 模糊搜索返回匹配结果")
+    void search_fuzzyKeyword_shouldReturnResults() throws Exception {
         String token = generateAccessToken(UID, PHONE);
         mockMvc.perform(post("/api/search/query")
                         .header("Authorization", "Bearer " + token)
@@ -108,13 +108,6 @@ class DrugSearchControllerTest extends GatewayBaseTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data.totalCount").value(3));
-
-        mockMvc.perform(get("/api/search/history")
-                        .header("Authorization", "Bearer " + token))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray())
-                .andExpect(jsonPath("$.data.length()").isNumber());
     }
 
     // ==================== 校验/权限(不消耗配额,使用普通用户即可) ====================

+ 0 - 65
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/SearchHistoryControllerTest.java

@@ -1,65 +0,0 @@
-package com.xuekairui.gateway;
-
-import org.junit.jupiter.api.*;
-import org.springframework.http.MediaType;
-
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-
-/**
- * 搜索历史接口测试
- */
-@DisplayName("搜索历史接口测试")
-@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
-class SearchHistoryControllerTest extends GatewayBaseTest {
-
-    @Test
-    @Order(1)
-    @DisplayName("GET /api/search/history - 带Token应返回历史列表")
-    void getHistory_withToken_shouldReturnList() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
-        mockMvc.perform(get("/api/search/history")
-                        .header("Authorization", "Bearer " + token))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray());
-    }
-
-    @Test
-    @Order(2)
-    @DisplayName("GET /api/search/history - 无Token应返回401")
-    void getHistory_withoutToken_shouldReturn401() throws Exception {
-        mockMvc.perform(get("/api/search/history"))
-                .andExpect(status().isUnauthorized());
-    }
-
-    @Test
-    @Order(3)
-    @DisplayName("DELETE /api/search/history/{id} - 无Token应返回401")
-    void deleteHistory_withoutToken_shouldReturn401() throws Exception {
-        mockMvc.perform(delete("/api/search/history/999"))
-                .andExpect(status().isUnauthorized());
-    }
-
-    @Test
-    @Order(4)
-    @DisplayName("DELETE /api/search/history/{id} - 不存在的记录应返回历史不存在错误")
-    void deleteHistory_nonexistent_shouldReturnError() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
-        mockMvc.perform(delete("/api/search/history/999999")
-                        .header("Authorization", "Bearer " + token))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(4302));
-    }
-
-    @Test
-    @Order(5)
-    @DisplayName("DELETE /api/search/history - 清空历史应成功")
-    void clearAll_withToken_shouldSucceed() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
-        mockMvc.perform(delete("/api/search/history")
-                        .header("Authorization", "Bearer " + token))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200));
-    }
-}

+ 6 - 4
zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentProperties.java

@@ -6,7 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
 /**
  * 支付模块配置属性
  *
- * <p>支付宝当面付和微信Native支付均已接入SDK,生产环境切换步骤:
+ * <p>支付宝电脑网站支付和微信Native支付均已接入SDK,生产环境切换步骤:
  * <ol>
  *   <li>将 payment.mock 设为 false</li>
  *   <li>填写 payment.alipay 和 payment.wechat 对应配置参数</li>
@@ -71,11 +71,11 @@ public class PaymentProperties {
     }
 
     /**
-     * 支付宝配置(当面付/扫码支付
+     * 支付宝配置(电脑网站支付 alipay.trade.page.pay
      * 支持两种模式(公钥模式优先):
      * 1. 公钥模式(优先):配置 private-key + alipay-public-key
      * 2. 证书模式(备选):配置 private-key-path + app-cert-path + alipay-cert-path + alipay-root-cert-path
-     * 文档:https://opendocs.alipay.com/open/194/106078
+     * 文档:https://opendocs.alipay.com/open/028r8t
      */
     @Data
     public static class Alipay {
@@ -93,8 +93,10 @@ public class PaymentProperties {
         private String alipayCertPath;
         /** 支付宝根证书路径(证书模式,.crt 文件) */
         private String alipayRootCertPath;
-        /** 支付回调通知URL(需公网可访问) */
+        /** 支付回调通知URL(异步通知,需公网可访问) */
         private String notifyUrl;
+        /** 支付完成后同步跳转URL(用户付款后浏览器跳转回的页面) */
+        private String returnUrl;
         /** 支付宝网关地址(正式:https://openapi.alipay.com/gateway.do,沙箱:https://openapi-sandbox.dl.alipaydev.com/gateway.do) */
         private String serverUrl = "https://openapi.alipay.com/gateway.do";
         /** 数据格式(固定 json) */

+ 26 - 19
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java

@@ -2,9 +2,9 @@ package com.xuekairui.payment.service;
 
 import cn.hutool.core.util.IdUtil;
 import com.alipay.api.AlipayClient;
-import com.alipay.api.domain.AlipayTradePrecreateModel;
-import com.alipay.api.request.AlipayTradePrecreateRequest;
-import com.alipay.api.response.AlipayTradePrecreateResponse;
+import com.alipay.api.domain.AlipayTradePagePayModel;
+import com.alipay.api.request.AlipayTradePagePayRequest;
+import com.alipay.api.response.AlipayTradePagePayResponse;
 import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request;
 import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum;
 import com.github.binarywang.wxpay.service.WxPayService;
@@ -108,7 +108,7 @@ public class PaymentOrderService {
                 .qrCodeBase64(qrCodeBase64)
                 .payUrl(payUrl)
                 .expireTime(order.getExpireTime())
-                .expireHint("请在" + paymentProperties.getOrderExpireMinutes() + "分钟内扫码付款,超时订单将自动过期")
+                .expireHint("请在" + paymentProperties.getOrderExpireMinutes() + "分钟内完成付款,超时订单将自动过期")
                 .createTime(order.getCreateTime())
                 .build();
     }
@@ -275,7 +275,7 @@ public class PaymentOrderService {
      * <p>上线时替换为真实SDK调用:
      * <ul>
      *   <li>微信:使用 weixin-java-pay 的 WxPayService.createOrder() 获取 code_url</li>
-     *   <li>支付宝:使用 alipay-sdk-java 的 AlipayClient.pageExecute() 获取 qr_pay_mode</li>
+     *   <li>支付宝:使用 alipay-sdk-java 的 AlipayClient.pageExecute() 获取支付跳转链接</li>
      * </ul>
      *
      * @param channel  支付渠道
@@ -353,11 +353,13 @@ public class PaymentOrderService {
     }
 
     /**
-     * 支付宝扫码支付预下单(当面付
+     * 支付宝电脑网站支付预下单(alipay.trade.page.pay
      *
-     * <p>使用 AlipayTradePrecreateRequest 调用支付宝预下单接口,
-     * 返回二维码链接(qrCode),前端可直接用此链接生成二维码展示。
-     * 用户扫码后在支付宝App中完成付款,支付宝异步回调 notifyUrl。
+     * <p>使用 AlipayTradePagePayRequest 调用支付宝电脑网站支付接口,
+     * 通过 pageExecute() 生成支付跳转链接/HTML表单,前端引导用户跳转到支付宝收银台完成付款。
+     * 支付宝异步回调 notifyUrl,同步跳转 returnUrl。
+     *
+     * @see <a href="https://opendocs.alipay.com/open/028r8t">统一收单下单并支付页面接口</a>
      */
     private PrepayResult createAlipayPrepay(String orderNo, java.math.BigDecimal amount, String subject) {
         PaymentProperties.Alipay al = paymentProperties.getAlipay();
@@ -372,21 +374,25 @@ public class PaymentOrderService {
                     "AlipayClient未初始化,请确认 payment.mock=false 且已配置支付宝参数");
         }
 
-        AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest();
+        AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
         request.setNotifyUrl(al.getNotifyUrl());
+        if (al.getReturnUrl() != null && !al.getReturnUrl().isBlank()) {
+            request.setReturnUrl(al.getReturnUrl());
+        }
 
-        AlipayTradePrecreateModel model = new AlipayTradePrecreateModel();
+        AlipayTradePagePayModel model = new AlipayTradePagePayModel();
         model.setOutTradeNo(orderNo);
         model.setTotalAmount(amount.toPlainString());
         model.setSubject(subject);
         model.setTimeoutExpress(paymentProperties.getOrderExpireMinutes() + "m");
+        model.setProductCode("FAST_INSTANT_TRADE_PAY");
         request.setBizModel(model);
 
         try {
-            // 公钥模式优先:配置了支付宝公钥用 execute,否则用 certificateExecute
+            // 公钥模式优先:配置了支付宝公钥用 pageExecute,否则用 certificateExecute
             boolean publicKeyMode = al.getAlipayPublicKey() != null && !al.getAlipayPublicKey().isBlank();
-            AlipayTradePrecreateResponse response = publicKeyMode
-                    ? alipayClient.execute(request)
+            AlipayTradePagePayResponse response = publicKeyMode
+                    ? alipayClient.pageExecute(request)
                     : alipayClient.certificateExecute(request);
             if (!response.isSuccess()) {
                 log.error("支付宝预下单失败: orderNo={}, code={}, msg={}, subCode={}, subMsg={}",
@@ -396,12 +402,13 @@ public class PaymentOrderService {
                         "支付宝下单失败: " + response.getSubMsg());
             }
 
-            String qrCode = response.getQrCode();
-            log.info("支付宝预下单成功: orderNo={}, amount={}, qrCode={}", orderNo, amount, qrCode);
+            // pageExecute 返回的 body() 是支付表单HTML或跳转URL
+            String payForm = response.getBody();
+            log.info("支付宝预下单成功: orderNo={}, amount={}", orderNo, amount);
 
             return PrepayResult.builder()
-                    .qrContent(qrCode)
-                    .payUrl(qrCode)
+                    .qrContent(payForm)   // 表单内容可作为二维码跳转目标(备选)
+                    .payUrl(payForm)      // 前端直接跳转此URL/渲染此表单
                     .build();
         } catch (BusinessException e) {
             throw e;
@@ -413,7 +420,7 @@ public class PaymentOrderService {
 
     private OrderResponse toResponse(PaymentOrder order) {
         String hint = PaymentStatus.PENDING.name().equals(order.getStatus())
-                ? "请在" + paymentProperties.getOrderExpireMinutes() + "分钟内扫码付款,超时订单将自动过期" : null;
+                ? "请在" + paymentProperties.getOrderExpireMinutes() + "分钟内完成付款,超时订单将自动过期" : null;
         return OrderResponse.builder()
                 .orderNo(order.getOrderNo())
                 .planName(null) // 由调用方补充

+ 64 - 8
zhijiayun-test/src/main/resources/static/invite-test.html

@@ -114,7 +114,7 @@
     <!-- 全局工具栏 -->
     <div class="toolbar">
         <label>API 基础地址</label>
-        <label for="baseUrl"></label><input type="text" id="baseUrl" value="http://localhost:8002" style="flex:1;">
+        <label for="baseUrl"></label><input type="text" id="baseUrl" value="http://8.135.32.236:8001" style="flex:1;">
     </div>
 
     <!-- 用户侧登录区 -->
@@ -199,6 +199,8 @@
         <div class="module-tab" data-module="platform-account">9.平台账号</div>
         <div class="module-tab" data-module="business-license">10.营业执照</div>
         <div class="module-tab" data-module="platform-config">11.平台配置</div>
+        <div class="module-tab" data-module="search-record">12.搜索记录</div>
+        <div class="module-tab" data-module="platform-session">13.平台会话</div>
     </div>
 
     <!-- 运营侧模块标签栏 -->
@@ -296,6 +298,8 @@ async function runTest(sectionId, method, path, body, requireAuth, validatorName
 
     // 检测响应中的二维码并渲染图片预览
     const qrBase64 = result.data?.data?.qrCodeBase64;
+    const payUrl = result.data?.data?.payUrl;
+    const channel = result.data?.data?.channel;
     let qrBox = document.getElementById(sectionId + '-qrcode');
     if (qrBase64) {
         if (!qrBox) {
@@ -304,7 +308,14 @@ async function runTest(sectionId, method, path, body, requireAuth, validatorName
             qrBox.style.cssText = 'text-align:center;padding:12px;background:#fff;border-radius:4px;margin-top:8px;';
             rawEl.parentNode.insertBefore(qrBox, rawEl.nextSibling);
         }
-        qrBox.innerHTML = '<img src="' + qrBase64 + '" style="max-width:220px;border:1px solid #eee;border-radius:4px;"><div style="margin-top:6px;font-size:12px;color:#666;">支付二维码(扫码付款后回调自动激活,也可手动确认支付测试回调)</div>';
+        // 支付宝电脑网站支付:payUrl是跳转链接/表单,显示跳转按钮+二维码
+        if (channel === 'ALIPAY' && payUrl) {
+            qrBox.innerHTML = '<img src="' + qrBase64 + '" style="max-width:180px;border:1px solid #eee;border-radius:4px;opacity:0.5;">'
+                + '<div style="margin-top:8px;"><a href="' + payUrl + '" target="_blank" style="display:inline-block;padding:8px 20px;background:#1677ff;color:#fff;border-radius:4px;text-decoration:none;font-size:14px;">点击跳转支付宝收银台</a></div>'
+                + '<div style="margin-top:6px;font-size:12px;color:#999;">支付宝电脑网站支付(页面跳转模式,也可手动确认支付测试回调)</div>';
+        } else {
+            qrBox.innerHTML = '<img src="' + qrBase64 + '" style="max-width:220px;border:1px solid #eee;border-radius:4px;"><div style="margin-top:6px;font-size:12px;color:#666;">支付二维码(扫码付款后回调自动激活,也可手动确认支付测试回调)</div>';
+        }
 
         // 自动回填订单号到相关输入框
         const orderNo = result.data?.data?.orderNo;
@@ -553,6 +564,8 @@ function renderModule(module) {
     case 'platform-account': html = renderPlatformAccountModule(); break;
     case 'business-license': html = renderBusinessLicenseModule(); break;
     case 'platform-config': html = renderPlatformConfigModule(); break;
+    case 'search-record': html = renderSearchRecordModule(); break;
+    case 'platform-session': html = renderPlatformSessionModule(); break;
     // 运营侧模块
     case 'admin-membership': html = renderAdminMembershipModule(); break;
     case 'admin-invite': html = renderAdminInviteModule(); break;
@@ -1224,12 +1237,8 @@ function renderSearchModule() {
     </div>
     ${testSection('search-query', '药品搜索', 'POST', 'post', '/api/search/query', '需认证', 'auth-required',
         [{label:'关键词(keyword)', name:'keyword', placeholder:'阿莫西林胶囊', default:'阿莫西林胶囊'},
-         {label:'平台(platform,可选)', name:'platform', placeholder:'yaoshibang', default:''}], true, null, '搜索药品比价,消耗每日配额,返回各平台价格列表')}
-    ${testSection('search-suggestions', '搜索建议', 'GET', 'get', '/api/search/suggestions', '公开', 'auth-public', null, true, null, '获取热门药品搜索建议列表(无需认证)')}
-    ${testSection('search-history', '搜索历史', 'GET', 'get', '/api/search/history', '需认证', 'auth-required', null, true, null, '获取当前用户的搜索历史记录')}
-    ${testSection('search-history-delete', '删除历史', 'DELETE', 'delete', '/api/search/history/{id}', '需认证', 'auth-required',
-        [{label:'记录ID(id)', name:'id', placeholder:'1', default:'1'}], false, null, '删除指定搜索历史记录')}
-    ${testSection('search-history-clear', '清空历史', 'DELETE', 'delete', '/api/search/history', '需认证', 'auth-required', null, false, null, '清空当前用户的所有搜索历史')}`;
+         {label:'平台列表(platforms,可选)', name:'platforms', placeholder:'yaoshibang,yaobangmang,yiyaocheng', default:'', type:'array'}], true, null, '搜索药品比价,消耗每日配额,返回各平台价格列表。platforms传多个平台代码用逗号分隔,不传则查询全部平台')}
+    ${testSection('search-suggestions', '搜索建议', 'GET', 'get', '/api/search/suggestions', '公开', 'auth-public', null, true, null, '获取热门药品搜索建议列表(无需认证)')}`;
 }
 
 function renderPlatformAccountModule() {
@@ -1272,6 +1281,41 @@ function renderPlatformConfigModule() {
         [{label:'平台代码(platformCode)', name:'platformCode', placeholder:'yaoshibang', default:'yaoshibang'}], true, 'platformConfig', '获取指定平台的详细配置信息')}`;
 }
 
+function renderSearchRecordModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runSearchRecordTests()">运行全部搜索记录测试</button>
+    </div>
+    ${testSection('sr-save', '保存搜索记录', 'POST', 'post', '/api/search-record/history', '需认证', 'auth-required',
+        [{label:'药品名称(drugName)', name:'drugName', placeholder:'阿莫西林', default:'阿莫西林'},
+         {label:'状态文案(statusText)', name:'statusText', placeholder:'已返回 2 个平台的可比价结果。', default:'已返回 2 个平台的可比价结果。'},
+         {label:'平台结果(platformResults)', name:'platformResults', placeholder:'[{"platformId":"yiyaocheng","platformName":"壹药城"}]', default:'[{"platformId":"yiyaocheng","platformName":"壹药城","response":{"status":"ok","products":[]}}]', type:'json'},
+         {label:'搜索日志(searchLog)', name:'searchLog', placeholder:'null', default:'null', type:'json'}], true, null, '保存一次完整的比价搜索快照,包含平台响应数据。platformResults和searchLog支持任意JSON结构')}
+    ${testSection('sr-list', '搜索记录列表', 'GET', 'get', '/api/search-record/history?limit={limit}', '需认证', 'auth-required',
+        [{label:'条数(limit)', name:'limit', placeholder:'20', default:'20'}], true, null, '获取当前用户最近的搜索记录,默认20条,最多50条')}
+    ${testSection('sr-detail', '记录详情', 'GET', 'get', '/api/search-record/history/{id}', '需认证', 'auth-required',
+        [{label:'记录ID(id)', name:'id', placeholder:'1', default:'1'}], true, null, '读取单条搜索记录详情(包含完整的platformResults)')}
+    ${testSection('sr-delete', '删除记录', 'DELETE', 'delete', '/api/search-record/history/{id}', '需认证', 'auth-required',
+        [{label:'记录ID(id)', name:'id', placeholder:'1', default:'1'}], false, null, '删除单条搜索记录(幂等,不存在也返回成功)')}
+    ${testSection('sr-clear', '清空记录', 'DELETE', 'delete', '/api/search-record/history', '需认证', 'auth-required', null, false, null, '清空当前用户全部搜索记录')}`;
+}
+
+function renderPlatformSessionModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runPlatformSessionTests()">运行全部平台会话测试</button>
+    </div>
+    ${testSection('ps-upload', '上传平台会话', 'PUT', 'put', '/api/platform-session/{platformCode}', '需认证', 'auth-required',
+        [{label:'平台代码(platformCode)', name:'platformCode', placeholder:'yiyaocheng', default:'yiyaocheng'},
+         {label:'平台名称(platformName)', name:'platformName', placeholder:'壹药城', default:'壹药城'},
+         {label:'账号标签(accountLabel)', name:'accountLabel', placeholder:'深圳大方无限科技有限公司', default:'测试账号'},
+         {label:'登录URL(loginUrl)', name:'loginUrl', placeholder:'https://mall.yaoex.com/login', default:'https://mall.yaoex.com/login'},
+         {label:'cookies(JSON数组)', name:'cookies', placeholder:'[{"name":"session","value":"xxx"}]', default:'[{"name":"mall_session","value":"test-cookie-value","domain":".mall.yaoex.com","path":"/","secure":true,"httpOnly":true,"expirationDate":1800000000}]', type:'json'}], true, null, '上传或覆盖当前用户某个平台的Cookie会话快照,Cookie会加密存储。platformCode必须与路径中的一致')}
+    ${testSection('ps-list', '平台会话列表', 'GET', 'get', '/api/platform-session/list', '需认证', 'auth-required', null, true, null, '获取当前用户所有已保存的平台会话(Cookie已解密),用于启动时恢复登录态')}
+    ${testSection('ps-delete', '删除平台会话', 'DELETE', 'delete', '/api/platform-session/{platformCode}', '需认证', 'auth-required',
+        [{label:'平台代码(platformCode)', name:'platformCode', placeholder:'yiyaocheng', default:'yiyaocheng'}], false, null, '删除某个平台的远端会话(幂等,不存在也返回成功)')}`;
+}
+
 // ==================== 运营侧模块 ====================
 function renderAdminMembershipModule() {
     return `
@@ -1519,6 +1563,8 @@ function runSearchTests() { runBatchTests('search'); }
 function runPlatformAccountTests() { runBatchTests('pa'); }
 function runBusinessLicenseTests() { runBatchTests('bl'); }
 function runPlatformConfigTests() { runBatchTests('pc'); }
+function runSearchRecordTests() { runBatchTests('sr'); }
+function runPlatformSessionTests() { runBatchTests('ps'); }
 // 运营侧快捷批量运行
 function runAdminMembershipTests() { runBatchTests('admin-membership'); }
 function runAdminInviteTests() { runBatchTests('admin-invite'); }
@@ -1605,6 +1651,16 @@ function validate_paymentOrders(result) { return result.data?.code === 200 && Ar
 function validate_searchQuery(result) { return result.data?.code === 200 && result.data?.data?.prices !== undefined; }
 function validate_searchSuggestions(result) { return result.data?.code === 200 && Array.isArray(result.data?.data) && result.data?.data?.length > 0; }
 function validate_searchHistory(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+function validate_searchSave(result) { return result.data?.code === 200 && result.data?.data?.keyword; }
+function validate_searchRecords(result) { return result.data?.code === 200 && result.data?.data?.records !== undefined; }
+
+// 搜索记录模块验证器
+function validate_srSave(result) { return result.data?.code === 200 && result.data?.data?.id && result.data?.data?.drugName; }
+function validate_srList(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+function validate_srDetail(result) { return result.data?.code === 200 && result.data?.data?.id; }
+// 平台会话模块验证器
+function validate_psUpload(result) { return result.data?.code === 200 && result.data?.data?.platformCode; }
+function validate_psList(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
 
 // ===================== 初始化 =====================
 window.addEventListener('DOMContentLoaded', () => {

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

@@ -35,7 +35,7 @@ public class DrugSearchController {
             @Valid @RequestBody SearchQueryRequest body) {
         Long userId = (Long) request.getAttribute("userId");
         SearchResultResponse result = drugSearchService.search(
-                userId, body.getKeyword(), body.getPlatform());
+                userId, body.getKeyword(), body.getPlatforms());
         return Result.success(result);
     }
 

+ 68 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/PlatformSessionController.java

@@ -0,0 +1,68 @@
+package com.xuekairui.user.controller;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.user.dto.PlatformSessionResponse;
+import com.xuekairui.user.dto.PlatformSessionSaveRequest;
+import com.xuekairui.user.dto.PlatformSessionUpdateResponse;
+import com.xuekairui.user.service.PlatformSessionService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 平台会话同步控制器
+ * 用于跨设备恢复平台Cookie登录态
+ *
+ * 安全要求:
+ * - 所有接口需要 Bearer Token 鉴权
+ * - Cookie 加密存储,禁止日志输出 Cookie 值
+ * - 按用户隔离,禁止跨用户访问
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/platform-session")
+@RequiredArgsConstructor
+public class PlatformSessionController {
+
+    private final PlatformSessionService platformSessionService;
+
+    /**
+     * 上传或覆盖平台会话
+     * PUT /api/platform-session/{platformCode}
+     */
+    @PutMapping("/{platformCode}")
+    public Result<PlatformSessionUpdateResponse> saveSession(
+            HttpServletRequest request,
+            @PathVariable String platformCode,
+            @Valid @RequestBody PlatformSessionSaveRequest body) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(platformSessionService.saveSession(userId, platformCode, body));
+    }
+
+    /**
+     * 获取当前用户所有平台会话列表
+     * GET /api/platform-session/list
+     */
+    @GetMapping("/list")
+    public Result<List<PlatformSessionResponse>> listSessions(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(platformSessionService.listSessions(userId));
+    }
+
+    /**
+     * 删除某个平台的会话(幂等)
+     * DELETE /api/platform-session/{platformCode}
+     */
+    @DeleteMapping("/{platformCode}")
+    public Result<Void> deleteSession(
+            HttpServletRequest request,
+            @PathVariable String platformCode) {
+        Long userId = (Long) request.getAttribute("userId");
+        platformSessionService.deleteSession(userId, platformCode);
+        return Result.success();
+    }
+}

+ 0 - 54
zhijiayun-user/src/main/java/com/xuekairui/user/controller/SearchHistoryController.java

@@ -1,54 +0,0 @@
-package com.xuekairui.user.controller;
-
-import com.xuekairui.common.Result;
-import com.xuekairui.user.dto.SearchHistoryResponse;
-import com.xuekairui.user.service.SearchHistoryService;
-import jakarta.servlet.http.HttpServletRequest;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.web.bind.annotation.*;
-
-import java.util.List;
-
-/**
- * 搜索历史控制器
- */
-@Slf4j
-@RestController
-@RequestMapping("/api/search")
-@RequiredArgsConstructor
-public class SearchHistoryController {
-
-    private final SearchHistoryService searchHistoryService;
-
-    /**
-     * 获取搜索历史
-     */
-    @GetMapping("/history")
-    public Result<List<SearchHistoryResponse>> getHistory(
-            HttpServletRequest request,
-            @RequestParam(defaultValue = "20") int limit) {
-        Long userId = (Long) request.getAttribute("userId");
-        return Result.success(searchHistoryService.getRecentHistory(userId, limit));
-    }
-
-    /**
-     * 删除单条搜索历史
-     */
-    @DeleteMapping("/history/{id}")
-    public Result<Void> deleteHistory(HttpServletRequest request, @PathVariable Long id) {
-        Long userId = (Long) request.getAttribute("userId");
-        searchHistoryService.deleteHistory(userId, id);
-        return Result.success();
-    }
-
-    /**
-     * 清空全部搜索历史
-     */
-    @DeleteMapping("/history")
-    public Result<Void> clearAll(HttpServletRequest request) {
-        Long userId = (Long) request.getAttribute("userId");
-        searchHistoryService.clearAll(userId);
-        return Result.success();
-    }
-}

+ 88 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/SearchRecordController.java

@@ -0,0 +1,88 @@
+package com.xuekairui.user.controller;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.user.dto.SearchRecordResponse;
+import com.xuekairui.user.dto.SearchRecordSaveRequest;
+import com.xuekairui.user.service.SearchRecordService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 搜索记录控制器
+ * 主搜索页"历史检索"接口,存储完整的比价搜索快照
+ *
+ * 路径:/api/search-record/history
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/search-record")
+@RequiredArgsConstructor
+public class SearchRecordController {
+
+    private final SearchRecordService searchRecordService;
+
+    /**
+     * 保存一次主搜索比价记录
+     * POST /api/search-record/history
+     */
+    @PostMapping("/history")
+    public Result<SearchRecordResponse> saveRecord(
+            HttpServletRequest request,
+            @Valid @RequestBody SearchRecordSaveRequest body) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(searchRecordService.saveRecord(userId, body));
+    }
+
+    /**
+     * 读取当前用户最近的主搜索历史
+     * GET /api/search-record/history?limit=20
+     */
+    @GetMapping("/history")
+    public Result<List<SearchRecordResponse>> listRecent(
+            HttpServletRequest request,
+            @RequestParam(defaultValue = "20") Integer limit) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(searchRecordService.listRecent(userId, limit));
+    }
+
+    /**
+     * 读取单条历史记录详情
+     * GET /api/search-record/history/{id}
+     */
+    @GetMapping("/history/{id}")
+    public Result<SearchRecordResponse> getById(
+            HttpServletRequest request,
+            @PathVariable Long id) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(searchRecordService.getById(userId, id));
+    }
+
+    /**
+     * 删除单条搜索记录
+     * DELETE /api/search-record/history/{id}
+     */
+    @DeleteMapping("/history/{id}")
+    public Result<Void> deleteRecord(
+            HttpServletRequest request,
+            @PathVariable Long id) {
+        Long userId = (Long) request.getAttribute("userId");
+        searchRecordService.deleteRecord(userId, id);
+        return Result.success();
+    }
+
+    /**
+     * 清空当前用户全部搜索记录
+     * DELETE /api/search-record/history
+     */
+    @DeleteMapping("/history")
+    public Result<Void> clearAll(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        searchRecordService.clearAll(userId);
+        return Result.success();
+    }
+}

+ 43 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/PlatformSessionResponse.java

@@ -0,0 +1,43 @@
+package com.xuekairui.user.dto;
+
+import lombok.Data;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 平台会话响应
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PlatformSessionResponse {
+
+    /** 数据版本号 */
+    private Integer version;
+
+    /** 平台代码 */
+    private String platformCode;
+
+    /** 平台展示名称 */
+    private String platformName;
+
+    /** 平台登录页地址 */
+    private String loginUrl;
+
+    /** 账号标签 */
+    private String accountLabel;
+
+    /** 绑定元数据更新时间 */
+    private LocalDateTime updatedAt;
+
+    /** Cookie 捕获时间 */
+    private LocalDateTime capturedAt;
+
+    /** Cookie 列表(已解密) */
+    private List<Object> cookies;
+}

+ 39 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/PlatformSessionSaveRequest.java

@@ -0,0 +1,39 @@
+package com.xuekairui.user.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 保存/上传平台会话请求
+ */
+@Data
+public class PlatformSessionSaveRequest {
+
+    /** 数据版本号(固定为1) */
+    private Integer version;
+
+    /** 平台代码(必须与URL路径中的一致) */
+    @NotBlank(message = "平台代码不能为空")
+    private String platformCode;
+
+    /** 平台展示名称 */
+    private String platformName;
+
+    /** 平台登录页地址 */
+    private String loginUrl;
+
+    /** 账号标签 */
+    private String accountLabel;
+
+    /** 绑定元数据更新时间 */
+    private LocalDateTime updatedAt;
+
+    /** Cookie 捕获时间 */
+    private LocalDateTime capturedAt;
+
+    /** Cookie 列表(Electron Cookie JSON) */
+    private List<Object> cookies;
+}

+ 25 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/PlatformSessionUpdateResponse.java

@@ -0,0 +1,25 @@
+package com.xuekairui.user.dto;
+
+import lombok.Data;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 平台会话保存成功响应(轻量)
+ * 客户端不依赖 data 的具体字段,只要 code === 200 即视为上传成功
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PlatformSessionUpdateResponse {
+
+    /** 平台代码 */
+    private String platformCode;
+
+    /** 更新时间 */
+    private LocalDateTime updatedAt;
+}

+ 0 - 30
zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchHistoryResponse.java

@@ -1,30 +0,0 @@
-package com.xuekairui.user.dto;
-
-import lombok.Data;
-import lombok.Builder;
-import lombok.NoArgsConstructor;
-import lombok.AllArgsConstructor;
-
-import java.time.LocalDateTime;
-
-/**
- * 搜索历史响应
- */
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class SearchHistoryResponse {
-
-    /** 记录ID */
-    private Long id;
-
-    /** 搜索关键词 */
-    private String keyword;
-
-    /** 筛选平台 */
-    private String platform;
-
-    /** 搜索时间 */
-    private LocalDateTime searchTime;
-}

+ 7 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchQueryRequest.java

@@ -4,6 +4,8 @@ import lombok.Data;
 
 import jakarta.validation.constraints.NotBlank;
 
+import java.util.List;
+
 /**
  * 药品搜索请求
  */
@@ -14,6 +16,9 @@ public class SearchQueryRequest {
     @NotBlank(message = "搜索关键词不能为空")
     private String keyword;
 
-    /** 可选:筛选特定平台(yaoshibang/yaobangmang/yiyaocheng),不传则查询全部已启用平台 */
-    private String platform;
+    /**
+     * 可选:指定要查询的平台列表(如 ["yaoshibang", "yaobangmang"]),
+     * 不传或空列表则查询全部已启用平台
+     */
+    private List<String> platforms;
 }

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

@@ -0,0 +1,36 @@
+package com.xuekairui.user.dto;
+
+import lombok.Data;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 搜索记录响应
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SearchRecordResponse {
+
+    /** 记录ID */
+    private Long id;
+
+    /** 药品名称 */
+    private String drugName;
+
+    /** 搜索时间 */
+    private LocalDateTime searchedAt;
+
+    /** 状态文案 */
+    private String statusText;
+
+    /** 平台搜索结果(结构化JSON对象) */
+    private Object platformResults;
+
+    /** 搜索日志 */
+    private Object searchLog;
+}

+ 29 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/SearchRecordSaveRequest.java

@@ -0,0 +1,29 @@
+package com.xuekairui.user.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 保存搜索记录请求
+ */
+@Data
+public class SearchRecordSaveRequest {
+
+    /** 药品名称 */
+    @NotBlank(message = "药品名称不能为空")
+    private String drugName;
+
+    /** 搜索时间 */
+    private LocalDateTime searchedAt;
+
+    /** 状态文案 */
+    private String statusText;
+
+    /** 平台搜索结果(任意JSON结构,后端原样存储) */
+    private Object platformResults;
+
+    /** 搜索日志(任意JSON结构,可为null) */
+    private Object searchLog;
+}

+ 64 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/PlatformSession.java

@@ -0,0 +1,64 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 平台会话快照实体
+ * 存储用户在B2B平台的Cookie会话,用于跨设备恢复平台登录态
+ * Cookie 必须加密存储
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_platform_session")
+public class PlatformSession {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 平台代码(如:yiyaocheng、yaoshibang) */
+    private String platformCode;
+
+    /** 平台展示名称 */
+    private String platformName;
+
+    /** 平台登录页地址 */
+    private String loginUrl;
+
+    /** 客户端识别到的账号标签 */
+    private String accountLabel;
+
+    /** Cookie 捕获时间 */
+    private LocalDateTime capturedAt;
+
+    /** 绑定元数据更新时间 */
+    private LocalDateTime updatedAt;
+
+    /** 加密后的 Cookie JSON 数组 */
+    private String cookiesEncrypted;
+
+    /** 数据版本号(当前固定为1) */
+    @Builder.Default
+    private Integer version = 1;
+
+    /** 最早过期的 Cookie 时间(用于自动清理) */
+    private LocalDateTime expiresAt;
+
+    /** 创建时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 0 - 43
zhijiayun-user/src/main/java/com/xuekairui/user/entity/SearchHistory.java

@@ -1,43 +0,0 @@
-package com.xuekairui.user.entity;
-
-import com.baomidou.mybatisplus.annotation.*;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-
-import java.time.LocalDateTime;
-
-/**
- * 搜索历史实体
- */
-@Data
-@NoArgsConstructor
-@AllArgsConstructor
-@Builder
-@TableName("t_search_history")
-public class SearchHistory {
-
-    @TableId(type = IdType.ASSIGN_ID)
-    private Long id;
-
-    /** 用户ID */
-    private Long userId;
-
-    /** 搜索关键词(药品名称) */
-    private String keyword;
-
-    /** 筛选平台代码(NULL表示全部平台) */
-    private String platform;
-
-    /** 返回结果数量 */
-    @Builder.Default
-    private Integer resultCount = 0;
-
-    /** 搜索时间 */
-    private LocalDateTime searchTime;
-
-    /** 创建时间 */
-    @TableField(fill = FieldFill.INSERT)
-    private LocalDateTime createTime;
-}

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

@@ -0,0 +1,46 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 搜索记录实体(完整保存一次比价的搜索快照)
+ * 与 SearchHistory(轻量关键词日志)不同,此表存储完整的平台搜索结果
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_search_record")
+public class SearchRecord {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 药品名称 */
+    private String drugName;
+
+    /** 搜索时间 */
+    private LocalDateTime searchedAt;
+
+    /** 状态文案(如:已返回 2 个平台的可比价结果。) */
+    private String statusText;
+
+    /** 平台搜索结果(JSON文本,包含完整的平台响应数据) */
+    private String platformResults;
+
+    /** 搜索日志(JSON文本,可为null) */
+    private String searchLog;
+
+    /** 创建时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 29 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/PlatformSessionMapper.java

@@ -0,0 +1,29 @@
+package com.xuekairui.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.user.entity.PlatformSession;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Delete;
+
+import java.util.List;
+
+/**
+ * 平台会话 Mapper
+ */
+@Mapper
+public interface PlatformSessionMapper extends BaseMapper<PlatformSession> {
+
+    /** 按用户ID+平台代码查询唯一会话 */
+    @Select("SELECT * FROM t_platform_session WHERE user_id = #{userId} AND platform_code = #{platformCode} LIMIT 1")
+    PlatformSession findByUserAndPlatform(@Param("userId") Long userId, @Param("platformCode") String platformCode);
+
+    /** 查询用户所有平台会话 */
+    @Select("SELECT * FROM t_platform_session WHERE user_id = #{userId} ORDER BY updated_at DESC")
+    List<PlatformSession> findAllByUserId(@Param("userId") Long userId);
+
+    /** 删除用户某个平台的会话 */
+    @Delete("DELETE FROM t_platform_session WHERE user_id = #{userId} AND platform_code = #{platformCode}")
+    int deleteByUserAndPlatform(@Param("userId") Long userId, @Param("platformCode") String platformCode);
+}

+ 6 - 6
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/SearchHistoryMapper.java → zhijiayun-user/src/main/java/com/xuekairui/user/mapper/SearchRecordMapper.java

@@ -1,7 +1,7 @@
 package com.xuekairui.user.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.xuekairui.user.entity.SearchHistory;
+import com.xuekairui.user.entity.SearchRecord;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -9,13 +9,13 @@ import org.apache.ibatis.annotations.Select;
 import java.util.List;
 
 /**
- * 搜索历史 Mapper
+ * 搜索记录 Mapper
  */
 @Mapper
-public interface SearchHistoryMapper extends BaseMapper<SearchHistory> {
+public interface SearchRecordMapper extends BaseMapper<SearchRecord> {
 
     /** 查询用户最近的搜索记录 */
-    @Select("SELECT * FROM t_search_history WHERE user_id = #{userId} " +
-            "ORDER BY search_time DESC LIMIT #{limit}")
-    List<SearchHistory> findByUserId(@Param("userId") Long userId, @Param("limit") int limit);
+    @Select("SELECT * FROM t_search_record WHERE user_id = #{userId} " +
+            "ORDER BY searched_at DESC LIMIT #{limit}")
+    List<SearchRecord> findRecentByUserId(@Param("userId") Long userId, @Param("limit") int limit);
 }

+ 16 - 15
zhijiayun-user/src/main/java/com/xuekairui/user/service/DrugSearchService.java

@@ -8,6 +8,8 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.util.List;
+
 /**
  * 药品搜索编排服务
  * 负责:校验输入 → 消耗配额 → 执行搜索 → 记录历史
@@ -19,22 +21,20 @@ public class DrugSearchService {
 
     private final MockDrugPriceService mockDrugPriceService;
     private final CrawlerService crawlerService;
-    private final SearchHistoryService searchHistoryService;
 
     /**
      * 执行药品搜索
      * 1. 校验搜索关键词
      * 2. 消耗爬虫配额(每次搜索消耗 1 次)
-     * 3. 执行 Mock 搜索
-     * 4. 记录搜索历史
+     * 3. 执行搜索
      *
-     * @param userId   用户ID
-     * @param keyword  搜索关键词
-     * @param platform 平台过滤(可选
+     * @param userId    用户ID
+     * @param keyword   搜索关键词
+     * @param platforms 平台列表(可选,null或空=查询全部平台
      * @return 搜索结果
      */
     @Transactional
-    public SearchResultResponse search(Long userId, String keyword, String platform) {
+    public SearchResultResponse search(Long userId, String keyword, List<String> platforms) {
         // 1. 校验
         if (keyword == null || keyword.trim().isEmpty()) {
             throw new BusinessException(ErrorCode.SEARCH_KEYWORD_EMPTY);
@@ -42,9 +42,13 @@ public class DrugSearchService {
 
         String trimmed = keyword.trim();
 
+        // 拼接平台字符串,用于配额记录和历史记录
+        String platformStr = (platforms != null && !platforms.isEmpty())
+                ? String.join(",", platforms) : null;
+
         // 2. 消耗爬虫配额(将爬虫配额错误映射为搜索配额错误)
         try {
-            crawlerService.consumeQuota(userId, 1, false, platform);
+            crawlerService.consumeQuota(userId, 1, false, platformStr);
         } catch (BusinessException e) {
             if (e.getCode() == ErrorCode.CRAWLER_QUOTA_EXCEEDED.getCode()
                     || e.getCode() == ErrorCode.CRAWLER_MONTHLY_QUOTA_EXCEEDED.getCode()) {
@@ -53,13 +57,10 @@ public class DrugSearchService {
             throw e;
         }
 
-        // 3. 执行搜索
-        SearchResultResponse result = mockDrugPriceService.search(trimmed, platform);
-
-        // 4. 记录搜索历史
-        searchHistoryService.recordSearch(userId, trimmed, platform, result.getTotalCount());
-
-        log.info("用户 {} 搜索药品: {}, 找到 {} 条结果", userId, trimmed, result.getTotalCount());
+        // 3. 执行搜索(传入多平台过滤)
+        SearchResultResponse result = mockDrugPriceService.search(trimmed, platforms);
+        
+        log.info("用户 {} 搜索药物: {}, 平台: {}, 找到 {} 条结果", userId, trimmed, platformStr, result.getTotalCount());
         return result;
     }
 }

+ 6 - 6
zhijiayun-user/src/main/java/com/xuekairui/user/service/MockDrugPriceService.java

@@ -102,21 +102,21 @@ public class MockDrugPriceService {
 
     /**
      * 模糊搜索药品
-     * @param keyword  搜索关键词
-     * @param platform 平台过滤(null=全部平台)
+     * @param keyword   搜索关键词
+     * @param platforms 平台过滤列表(null或空=全部平台)
      * @return 匹配的药品价格列表
      */
-    public SearchResultResponse search(String keyword, String platform) {
+    public SearchResultResponse search(String keyword, List<String> platforms) {
         // 模糊匹配 - 包含关键词的药品
         List<DrugPriceItem> allPrices = MOCK_DRUGS.entrySet().stream()
                 .filter(e -> e.getKey().contains(keyword))
                 .flatMap(e -> e.getValue().stream())
                 .collect(Collectors.toList());
 
-        // 平台过滤
-        if (platform != null && !platform.isEmpty()) {
+        // 平台过滤:仅保留指定平台的结果
+        if (platforms != null && !platforms.isEmpty()) {
             allPrices = allPrices.stream()
-                    .filter(p -> p.getPlatformCode().equals(platform))
+                    .filter(p -> platforms.contains(p.getPlatformCode()))
                     .collect(Collectors.toList());
         }
 

+ 184 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/PlatformSessionService.java

@@ -0,0 +1,184 @@
+package com.xuekairui.user.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xuekairui.user.dto.PlatformSessionResponse;
+import com.xuekairui.user.dto.PlatformSessionSaveRequest;
+import com.xuekairui.user.dto.PlatformSessionUpdateResponse;
+import com.xuekairui.user.entity.PlatformSession;
+import com.xuekairui.user.mapper.PlatformSessionMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 平台会话同步服务
+ * Cookie 加密存储,按 userId + platformCode 唯一索引
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PlatformSessionService {
+
+    private final PlatformSessionMapper platformSessionMapper;
+    private final EncryptionService encryptionService;
+    private final ObjectMapper objectMapper;
+
+    /** 支持的平台代码 */
+    private static final Set<String> SUPPORTED_PLATFORMS = Set.of(
+            "yiyaocheng", "yaoshibang", "yaoyigou", "yaojiujiu", "ybm"
+    );
+
+    /**
+     * 上传或覆盖平台会话
+     */
+    @Transactional
+    public PlatformSessionUpdateResponse saveSession(Long userId, String platformCode,
+                                                      PlatformSessionSaveRequest request) {
+        // 验证平台代码
+        if (!platformCode.equals(request.getPlatformCode())) {
+            log.warn("平台代码不匹配: path={}, body={}", platformCode, request.getPlatformCode());
+            throw new IllegalArgumentException("平台代码与路径不匹配");
+        }
+
+        // 加密 Cookie
+        String cookiesJson = toJsonString(request.getCookies());
+        String encryptedCookies = null;
+        if (cookiesJson != null && !cookiesJson.equals("null")) {
+            encryptedCookies = encryptionService.encrypt(cookiesJson);
+        }
+
+        // 计算最早过期时间
+        LocalDateTime expiresAt = extractEarliestExpiration(request.getCookies());
+
+        // 查找是否已有该平台的会话
+        PlatformSession existing = platformSessionMapper.findByUserAndPlatform(userId, platformCode);
+
+        if (existing != null) {
+            // 更新
+            existing.setPlatformName(request.getPlatformName());
+            existing.setLoginUrl(request.getLoginUrl());
+            existing.setAccountLabel(request.getAccountLabel());
+            existing.setCapturedAt(request.getCapturedAt());
+            existing.setUpdatedAt(request.getUpdatedAt() != null ? request.getUpdatedAt() : LocalDateTime.now());
+            existing.setCookiesEncrypted(encryptedCookies);
+            existing.setVersion(request.getVersion() != null ? request.getVersion() : 1);
+            existing.setExpiresAt(expiresAt);
+            platformSessionMapper.updateById(existing);
+            log.info("更新平台会话: userId={}, platform={}", userId, platformCode);
+        } else {
+            // 新增
+            PlatformSession session = PlatformSession.builder()
+                    .userId(userId)
+                    .platformCode(platformCode)
+                    .platformName(request.getPlatformName())
+                    .loginUrl(request.getLoginUrl())
+                    .accountLabel(request.getAccountLabel())
+                    .capturedAt(request.getCapturedAt())
+                    .updatedAt(request.getUpdatedAt() != null ? request.getUpdatedAt() : LocalDateTime.now())
+                    .cookiesEncrypted(encryptedCookies)
+                    .version(request.getVersion() != null ? request.getVersion() : 1)
+                    .expiresAt(expiresAt)
+                    .build();
+            platformSessionMapper.insert(session);
+            log.info("创建平台会话: userId={}, platform={}", userId, platformCode);
+        }
+
+        return PlatformSessionUpdateResponse.builder()
+                .platformCode(platformCode)
+                .updatedAt(request.getUpdatedAt() != null ? request.getUpdatedAt() : LocalDateTime.now())
+                .build();
+    }
+
+    /**
+     * 获取用户所有平台会话列表(解密 Cookie)
+     */
+    public List<PlatformSessionResponse> listSessions(Long userId) {
+        List<PlatformSession> sessions = platformSessionMapper.findAllByUserId(userId);
+        return sessions.stream()
+                .map(this::toResponse)
+                .filter(Objects::nonNull) // 跳过解密失败的记录
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 删除用户某个平台的会话(幂等)
+     */
+    @Transactional
+    public void deleteSession(Long userId, String platformCode) {
+        platformSessionMapper.deleteByUserAndPlatform(userId, platformCode);
+        // 不存在时也返回成功(幂等)
+    }
+
+    /**
+     * 实体转响应DTO(解密Cookie)
+     */
+    private PlatformSessionResponse toResponse(PlatformSession session) {
+        List<Object> cookies = null;
+        if (session.getCookiesEncrypted() != null) {
+            try {
+                String decrypted = encryptionService.decrypt(session.getCookiesEncrypted());
+                cookies = objectMapper.readValue(decrypted, new TypeReference<List<Object>>() {});
+            } catch (Exception e) {
+                log.error("Cookie解密失败: userId={}, platform={}", session.getUserId(), session.getPlatformCode());
+                return null; // 跳过解密失败的记录
+            }
+        }
+
+        return PlatformSessionResponse.builder()
+                .version(session.getVersion())
+                .platformCode(session.getPlatformCode())
+                .platformName(session.getPlatformName())
+                .loginUrl(session.getLoginUrl())
+                .accountLabel(session.getAccountLabel())
+                .updatedAt(session.getUpdatedAt())
+                .capturedAt(session.getCapturedAt())
+                .cookies(cookies)
+                .build();
+    }
+
+    /**
+     * 从 Cookie 列表中提取最早的 expirationDate
+     */
+    private LocalDateTime extractEarliestExpiration(List<Object> cookies) {
+        if (cookies == null || cookies.isEmpty()) return null;
+        try {
+            long earliest = Long.MAX_VALUE;
+            for (Object cookie : cookies) {
+                if (cookie instanceof Map) {
+                    Object expDate = ((Map<?, ?>) cookie).get("expirationDate");
+                    if (expDate instanceof Number) {
+                        long ts = ((Number) expDate).longValue();
+                        if (ts > 0 && ts < earliest) {
+                            earliest = ts;
+                        }
+                    }
+                }
+            }
+            if (earliest < Long.MAX_VALUE) {
+                return LocalDateTime.ofInstant(Instant.ofEpochSecond(earliest), ZoneId.systemDefault());
+            }
+        } catch (Exception e) {
+            log.warn("提取Cookie过期时间失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    private String toJsonString(Object obj) {
+        if (obj == null) return null;
+        try {
+            return objectMapper.writeValueAsString(obj);
+        } catch (JsonProcessingException e) {
+            log.warn("JSON序列化失败: {}", e.getMessage());
+            return null;
+        }
+    }
+}

+ 0 - 95
zhijiayun-user/src/main/java/com/xuekairui/user/service/SearchHistoryService.java

@@ -1,95 +0,0 @@
-package com.xuekairui.user.service;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.xuekairui.common.BusinessException;
-import com.xuekairui.common.ErrorCode;
-import com.xuekairui.user.dto.SearchHistoryResponse;
-import com.xuekairui.user.entity.SearchHistory;
-import com.xuekairui.user.mapper.SearchHistoryMapper;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.LocalDateTime;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-/**
- * 搜索历史服务
- */
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class SearchHistoryService {
-
-    private final SearchHistoryMapper searchHistoryMapper;
-
-    /**
-     * 获取用户最近的搜索历史(按关键词去重,保留最新的一条)
-     */
-    public List<SearchHistoryResponse> getRecentHistory(Long userId, int limit) {
-        List<SearchHistory> histories = searchHistoryMapper.findByUserId(userId, limit * 3);
-
-        // Java侧按关键词去重,保留每个关键词的最新记录
-        return histories.stream()
-                .collect(Collectors.toMap(
-                        SearchHistory::getKeyword,
-                        h -> h,
-                        (existing, replacement) -> existing.getSearchTime()
-                                .isAfter(replacement.getSearchTime()) ? existing : replacement
-                ))
-                .values().stream()
-                .sorted((a, b) -> b.getSearchTime().compareTo(a.getSearchTime()))
-                .limit(limit)
-                .map(this::toResponse)
-                .collect(Collectors.toList());
-    }
-
-    /**
-     * 记录一次搜索
-     */
-    @Transactional
-    public void recordSearch(Long userId, String keyword, String platform, int resultCount) {
-        SearchHistory history = SearchHistory.builder()
-                .userId(userId)
-                .keyword(keyword)
-                .platform(platform)
-                .resultCount(resultCount)
-                .searchTime(LocalDateTime.now())
-                .build();
-        searchHistoryMapper.insert(history);
-    }
-
-    /**
-     * 删除单条搜索历史
-     */
-    @Transactional
-    public void deleteHistory(Long userId, Long historyId) {
-        SearchHistory history = searchHistoryMapper.selectById(historyId);
-        if (history == null || !history.getUserId().equals(userId)) {
-            throw new BusinessException(ErrorCode.SEARCH_HISTORY_NOT_FOUND);
-        }
-        searchHistoryMapper.deleteById(historyId);
-    }
-
-    /**
-     * 清空用户全部搜索历史
-     */
-    @Transactional
-    public void clearAll(Long userId) {
-        searchHistoryMapper.delete(
-                new LambdaQueryWrapper<SearchHistory>()
-                        .eq(SearchHistory::getUserId, userId));
-    }
-
-    private SearchHistoryResponse toResponse(SearchHistory h) {
-        return SearchHistoryResponse.builder()
-                .id(h.getId())
-                .keyword(h.getKeyword())
-                .platform(h.getPlatform())
-                .searchTime(h.getSearchTime())
-                .build();
-    }
-}

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

@@ -0,0 +1,132 @@
+package com.xuekairui.user.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.user.dto.SearchRecordResponse;
+import com.xuekairui.user.dto.SearchRecordSaveRequest;
+import com.xuekairui.user.entity.SearchRecord;
+import com.xuekairui.user.mapper.SearchRecordMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 搜索记录服务
+ * 存储完整的比价搜索快照,包含平台响应数据
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SearchRecordService {
+
+    private final SearchRecordMapper searchRecordMapper;
+    private final ObjectMapper objectMapper;
+
+    /**
+     * 保存一次主搜索比价记录
+     */
+    @Transactional
+    public SearchRecordResponse saveRecord(Long userId, SearchRecordSaveRequest request) {
+        SearchRecord record = SearchRecord.builder()
+                .userId(userId)
+                .drugName(request.getDrugName().trim())
+                .searchedAt(request.getSearchedAt() != null ? request.getSearchedAt() : LocalDateTime.now())
+                .statusText(request.getStatusText())
+                .platformResults(toJsonString(request.getPlatformResults()))
+                .searchLog(toJsonString(request.getSearchLog()))
+                .build();
+        searchRecordMapper.insert(record);
+        log.info("保存搜索记录: userId={}, drugName={}, recordId={}", userId, record.getDrugName(), record.getId());
+        return toResponse(record);
+    }
+
+    /**
+     * 读取当前用户最近的主搜索历史
+     */
+    public List<SearchRecordResponse> listRecent(Long userId, Integer limit) {
+        int actualLimit = Math.min(limit != null && limit > 0 ? limit : 20, 50);
+        List<SearchRecord> records = searchRecordMapper.findRecentByUserId(userId, actualLimit);
+        return records.stream().map(this::toResponse).collect(Collectors.toList());
+    }
+
+    /**
+     * 读取单条历史记录详情
+     */
+    public SearchRecordResponse getById(Long userId, Long id) {
+        SearchRecord record = searchRecordMapper.selectById(id);
+        if (record == null || !record.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "搜索记录不存在");
+        }
+        return toResponse(record);
+    }
+
+    /**
+     * 删除单条搜索记录
+     */
+    @Transactional
+    public void deleteRecord(Long userId, Long id) {
+        SearchRecord record = searchRecordMapper.selectById(id);
+        if (record != null && record.getUserId().equals(userId)) {
+            searchRecordMapper.deleteById(id);
+        }
+        // 不存在时也返回成功(幂等)
+    }
+
+    /**
+     * 清空当前用户所有搜索记录
+     */
+    @Transactional
+    public void clearAll(Long userId) {
+        LambdaQueryWrapper<SearchRecord> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(SearchRecord::getUserId, userId);
+        searchRecordMapper.delete(wrapper);
+    }
+
+    /**
+     * 实体转响应DTO
+     */
+    private SearchRecordResponse toResponse(SearchRecord record) {
+        return SearchRecordResponse.builder()
+                .id(record.getId())
+                .drugName(record.getDrugName())
+                .searchedAt(record.getSearchedAt())
+                .statusText(record.getStatusText())
+                .platformResults(parseJson(record.getPlatformResults()))
+                .searchLog(parseJson(record.getSearchLog()))
+                .build();
+    }
+
+    /**
+     * 对象序列化为JSON字符串(存储用)
+     */
+    private String toJsonString(Object obj) {
+        if (obj == null) return null;
+        try {
+            return objectMapper.writeValueAsString(obj);
+        } catch (JsonProcessingException e) {
+            log.warn("JSON序列化失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * JSON字符串反序列化为对象(返回用)
+     */
+    private Object parseJson(String json) {
+        if (json == null || json.isBlank()) return null;
+        try {
+            return objectMapper.readValue(json, Object.class);
+        } catch (JsonProcessingException e) {
+            log.warn("JSON反序列化失败: {}", e.getMessage());
+            return json;
+        }
+    }
+}