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

优化代码,添加接口

liuchengsen 1 сар өмнө
parent
commit
cca1b9b6c3

+ 4 - 0
zhijiayun-gateway/src/main/java/com/xuekairui/Application.java

@@ -5,12 +5,16 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.scheduling.annotation.EnableScheduling;
 
+import java.util.TimeZone;
+
 @SpringBootApplication
 @EnableScheduling
 @EnableAsync
 public class Application {
 
     public static void main(String[] args) {
+        // 全局默认时区设为北京时间,确保 LocalDateTime.now() / new Date() / db 写入均为北京时间
+        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
         SpringApplication.run(Application.class, args);
     }
 }

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

@@ -9,6 +9,11 @@ spring:
   profiles:
     active: dev
 
+  # Jackson 序列化时区(全局生效,北京时间返回前端)
+  jackson:
+    time-zone: Asia/Shanghai
+    date-format: yyyy-MM-dd HH:mm:ss
+
   # 文件上传配置
   servlet:
     multipart:

+ 86 - 3
zhijiayun-gateway/src/main/resources/db/migration-v2.sql

@@ -11,9 +11,11 @@ USE `zhijiayun_pharmacy`;
 -- ============================================
 -- 1. DDL: t_invite_config 新增 max_total_invites 列
 -- ============================================
-ALTER TABLE `t_invite_config`
-    ADD COLUMN IF NOT EXISTS `max_total_invites` INT NOT NULL DEFAULT 30
-    COMMENT '单个账号总邀请上限' AFTER `max_invite_per_day`;
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'max_total_invites');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `max_total_invites` INT NOT NULL DEFAULT 30 COMMENT ''单个账号总邀请上限'' AFTER `max_invite_per_day`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
 
 -- ============================================
 -- 2. DML: t_payment_plan 普通会员方案改为免费
@@ -85,3 +87,84 @@ CREATE TABLE IF NOT EXISTS `t_platform_session` (
     KEY `idx_user_id` (`user_id`),
     KEY `idx_expires_at` (`expires_at`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台会话快照表';
+
+-- ============================================
+-- 6. DDL: t_invite_config 新增文案模板及渠道提示列(兼容所有 MySQL 版本)
+-- ============================================
+
+-- share_title_template
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'share_title_template');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `share_title_template` VARCHAR(256) DEFAULT ''{inviter} 邀请你加入{appName}'' COMMENT ''分享标题模板'' AFTER `status`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- copy_text_template
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'copy_text_template');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `copy_text_template` TEXT COMMENT ''一键复制文案模板'' AFTER `share_title_template`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- reward_description
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'reward_description');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `reward_description` VARCHAR(256) DEFAULT ''邀请人每成功邀请1家药店注册,赠送1个月高级会员时长'' COMMENT ''奖励说明文案'' AFTER `copy_text_template`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- summary_text_template
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'summary_text_template');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `summary_text_template` VARCHAR(256) DEFAULT ''已邀请{totalInvited}家药店,{registeredCount}家注册,累计获得{totalReward}个月高级会员'' COMMENT ''邀请统计摘要模板'' AFTER `reward_description`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- instruction_windows
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'instruction_windows');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `instruction_windows` VARCHAR(256) COMMENT ''Windows渠道提示文案'' AFTER `summary_text_template`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- instruction_app
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'instruction_app');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `instruction_app` VARCHAR(256) COMMENT ''App渠道提示文案'' AFTER `instruction_windows`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- instruction_miniapp
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'instruction_miniapp');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `instruction_miniapp` VARCHAR(256) COMMENT ''小程序渠道提示文案'' AFTER `instruction_app`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- instruction_wechat
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'instruction_wechat');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `instruction_wechat` VARCHAR(256) COMMENT ''微信公众号渠道提示文案'' AFTER `instruction_miniapp`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- instruction_dingtalk
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'instruction_dingtalk');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `instruction_dingtalk` VARCHAR(256) COMMENT ''钉钉渠道提示文案'' AFTER `instruction_wechat`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- instruction_feishu
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'instruction_feishu');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `instruction_feishu` VARCHAR(256) COMMENT ''飞书渠道提示文案'' AFTER `instruction_dingtalk`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- instruction_default
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_invite_config' AND COLUMN_NAME = 'instruction_default');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE `t_invite_config` ADD COLUMN `instruction_default` VARCHAR(256) COMMENT ''默认渠道提示文案'' AFTER `instruction_feishu`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

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

@@ -165,6 +165,19 @@ CREATE TABLE IF NOT EXISTS `t_invite_config` (
     `landing_desc`          VARCHAR(512)  DEFAULT NULL COMMENT '落地页描述',
     `app_name`              VARCHAR(64)   DEFAULT '药易采(药店端)' COMMENT '应用名称',
     `status`                INT           NOT NULL DEFAULT 1 COMMENT '状态:0-停用 1-启用',
+    -- 文案模板配置
+    `share_title_template`  VARCHAR(256)  DEFAULT '{inviter} 邀请你加入{appName}' COMMENT '分享标题模板(占位符:{inviter}、{appName})',
+    `copy_text_template`    TEXT          DEFAULT NULL COMMENT '一键复制文案模板(占位符:{inviter}、{appName}、{inviteLink}、{code})',
+    `reward_description`    VARCHAR(256)  DEFAULT '邀请人每成功邀请1家药店注册,赠送1个月高级会员时长' COMMENT '奖励说明文案(占位符:{rewardMonths})',
+    `summary_text_template` VARCHAR(256)  DEFAULT '已邀请{totalInvited}家药店,{registeredCount}家注册,累计获得{totalReward}个月高级会员' COMMENT '邀请统计摘要模板(占位符:{totalInvited}、{registeredCount}、{totalReward})',
+    -- 各渠道操作提示
+    `instruction_windows`   VARCHAR(256)  DEFAULT NULL COMMENT 'Windows渠道提示文案(占位符:{code})',
+    `instruction_app`       VARCHAR(256)  DEFAULT NULL COMMENT 'App渠道提示文案(占位符:{code})',
+    `instruction_miniapp`   VARCHAR(256)  DEFAULT NULL COMMENT '小程序渠道提示文案(占位符:{code})',
+    `instruction_wechat`    VARCHAR(256)  DEFAULT NULL COMMENT '微信公众号渠道提示文案(占位符:{code})',
+    `instruction_dingtalk`  VARCHAR(256)  DEFAULT NULL COMMENT '钉钉渠道提示文案(占位符:{code}、{appName})',
+    `instruction_feishu`    VARCHAR(256)  DEFAULT NULL COMMENT '飞书渠道提示文案(占位符:{code}、{appName})',
+    `instruction_default`   VARCHAR(256)  DEFAULT NULL COMMENT '默认/未知渠道提示文案(占位符:{code})',
     `remark`                VARCHAR(256)  DEFAULT NULL COMMENT '备注',
     `create_time`           DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     `update_time`           DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

+ 118 - 3
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminControllerTest.java

@@ -115,13 +115,128 @@ class AdminControllerTest extends GatewayBaseTest {
 
     @Test
     @Order(7)
-    @DisplayName("GET /api/admin/invite/configs - 应返回配置列表")
-    void listInviteConfigs_shouldReturnList() throws Exception {
+    @DisplayName("GET /api/admin/invite/configs - 应返回分页配置列表")
+    void listInviteConfigs_shouldReturnPage() throws Exception {
         mockMvc.perform(get("/api/admin/invite/configs")
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray());
+                .andExpect(jsonPath("$.data.records").isArray())
+                .andExpect(jsonPath("$.data.total").isNumber())
+                .andExpect(jsonPath("$.data.current").value(1))
+                .andExpect(jsonPath("$.data.size").value(20));
+    }
+
+    @Test
+    @Order(8)
+    @DisplayName("GET /api/admin/invite/config/{id} - 根据ID获取配置")
+    void getInviteConfigById_shouldReturnConfig() throws Exception {
+        mockMvc.perform(get("/api/admin/invite/config/1")
+                        .header("Authorization", "Bearer " + adminToken()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.id").value(1))
+                .andExpect(jsonPath("$.data.rewardCrawlerCount").isNumber());
+    }
+
+    @Test
+    @Order(9)
+    @DisplayName("GET /api/admin/invite/config/{id} - 不存在应返回错误")
+    void getInviteConfigById_notFound_shouldReturnError() throws Exception {
+        mockMvc.perform(get("/api/admin/invite/config/99999")
+                        .header("Authorization", "Bearer " + adminToken()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").isNumber());
+    }
+
+    @Test
+    @Order(10)
+    @DisplayName("POST /api/admin/invite/config - 创建新配置")
+    void createInviteConfig_shouldSucceed() throws Exception {
+        String body = """
+                {
+                    "rewardCrawlerCount": 3,
+                    "rewardType": "MEMBERSHIP",
+                    "rewardMonths": 2,
+                    "maxDailyReward": 30,
+                    "inviteCodeExpireDays": 15,
+                    "maxInvitePerDay": 5,
+                    "maxTotalInvites": 20,
+                    "landingTitle": "新配置标题",
+                    "appName": "药易采"
+                }
+                """;
+        mockMvc.perform(post("/api/admin/invite/config")
+                        .header("Authorization", "Bearer " + adminToken())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.id").isNumber())
+                .andExpect(jsonPath("$.data.landingTitle").value("新配置标题"));
+    }
+
+    @Test
+    @Order(11)
+    @DisplayName("PUT /api/admin/invite/config/{id} - 更新指定ID的配置")
+    void updateInviteConfigById_shouldSucceed() throws Exception {
+        String body = """
+                {
+                    "rewardMonths": 3,
+                    "landingTitle": "更新后的标题"
+                }
+                """;
+        mockMvc.perform(put("/api/admin/invite/config/1")
+                        .header("Authorization", "Bearer " + adminToken())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    @Test
+    @Order(12)
+    @DisplayName("PUT /api/admin/invite/config/{id} - 不存在应返回错误")
+    void updateInviteConfigById_notFound_shouldReturnError() throws Exception {
+        mockMvc.perform(put("/api/admin/invite/config/99999")
+                        .header("Authorization", "Bearer " + adminToken())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"rewardMonths\": 3}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").isNumber());
+    }
+
+    @Test
+    @Order(13)
+    @DisplayName("DELETE /api/admin/invite/config/{id} - 删除配置")
+    void deleteInviteConfig_shouldSucceed() throws Exception {
+        // 先创建一个临时配置用于删除
+        String createBody = """
+                {
+                    "rewardMonths": 1,
+                    "landingTitle": "待删除配置"
+                }
+                """;
+        String createResult = mockMvc.perform(post("/api/admin/invite/config")
+                        .header("Authorization", "Bearer " + adminToken())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(createBody))
+                .andExpect(status().isOk())
+                .andReturn().getResponse().getContentAsString();
+
+        com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+        int newId = mapper.readTree(createResult).get("data").get("id").asInt();
+
+        mockMvc.perform(delete("/api/admin/invite/config/" + newId)
+                        .header("Authorization", "Bearer " + adminToken()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        // 再次查询应返回错误
+        mockMvc.perform(get("/api/admin/invite/config/" + newId)
+                        .header("Authorization", "Bearer " + adminToken()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").isNumber());
     }
 
     // ==================== 爬虫次数手动发放 ====================

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

@@ -16,6 +16,8 @@ DROP TABLE IF EXISTS t_crawler_usage_log;
 DROP TABLE IF EXISTS t_invite_relation;
 DROP TABLE IF EXISTS t_invite_code;
 DROP TABLE IF EXISTS t_invite_config;
+DROP TABLE IF EXISTS t_search_record;
+DROP TABLE IF EXISTS t_platform_session;
 DROP TABLE IF EXISTS t_user_coupon;
 DROP TABLE IF EXISTS t_coupon;
 DROP TABLE IF EXISTS t_membership_benefit;
@@ -156,6 +158,19 @@ CREATE TABLE IF NOT EXISTS t_invite_config (
     landing_desc            VARCHAR(512)  DEFAULT NULL,
     app_name                VARCHAR(64)   DEFAULT '药易采(药店端)',
     status                  INT           NOT NULL DEFAULT 1,
+    -- 文案模板配置
+    share_title_template    VARCHAR(256)  DEFAULT '{inviter} 邀请你加入{appName}',
+    copy_text_template      TEXT          DEFAULT NULL,
+    reward_description      VARCHAR(256)  DEFAULT '邀请人每成功邀请1家药店注册,赠送1个月高级会员时长',
+    summary_text_template   VARCHAR(256)  DEFAULT '已邀请{totalInvited}家药店,{registeredCount}家注册,累计获得{totalReward}个月高级会员',
+    -- 各渠道操作提示
+    instruction_windows     VARCHAR(256)  DEFAULT NULL,
+    instruction_app         VARCHAR(256)  DEFAULT NULL,
+    instruction_miniapp     VARCHAR(256)  DEFAULT NULL,
+    instruction_wechat      VARCHAR(256)  DEFAULT NULL,
+    instruction_dingtalk    VARCHAR(256)  DEFAULT NULL,
+    instruction_feishu      VARCHAR(256)  DEFAULT NULL,
+    instruction_default     VARCHAR(256)  DEFAULT NULL,
     remark                  VARCHAR(256)  DEFAULT NULL,
     create_time             TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
     update_time             TIMESTAMP     DEFAULT CURRENT_TIMESTAMP
@@ -174,6 +189,42 @@ PREPARE stmt FROM @sql;
 EXECUTE stmt;
 DEALLOCATE PREPARE stmt;
 
+-- 搜索记录表(完整比价快照)
+CREATE TABLE IF NOT EXISTS t_search_record (
+    id                BIGINT        NOT NULL COMMENT '主键ID(雪花算法)',
+    user_id           BIGINT        NOT NULL COMMENT '用户ID',
+    drug_name         VARCHAR(200)  NOT NULL COMMENT '药品名称',
+    searched_at       TIMESTAMP     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文本)',
+    create_time       TIMESTAMP     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (id),
+    INDEX idx_user_id (user_id),
+    INDEX idx_user_searched (user_id, searched_at)
+);
+
+-- 平台会话快照表
+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         TIMESTAMP     DEFAULT NULL COMMENT 'Cookie捕获时间',
+    updated_at          TIMESTAMP     DEFAULT NULL COMMENT '绑定元数据更新时间',
+    cookies_encrypted   LONGTEXT      DEFAULT NULL COMMENT '加密后的Cookie JSON数组',
+    version             INT           DEFAULT 1 COMMENT '数据版本号',
+    expires_at          TIMESTAMP     DEFAULT NULL COMMENT '最早过期的Cookie时间',
+    create_time         TIMESTAMP     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    update_time         TIMESTAMP     DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_user_platform (user_id, platform_code),
+    INDEX idx_user_id (user_id),
+    INDEX idx_expires_at (expires_at)
+);
+
 -- 邀请码表
 CREATE TABLE IF NOT EXISTS t_invite_code (
     id              BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 64 - 6
zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/AdminController.java

@@ -1,5 +1,6 @@
 package com.xuekairui.invite.controller;
 
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.xuekairui.common.Result;
 import com.xuekairui.invite.dto.InviteConfigRequest;
 import com.xuekairui.invite.dto.InviteConversionStatsResponse;
@@ -35,7 +36,7 @@ public class AdminController {
     // ==================== 邀请配置管理 ====================
 
     /**
-     * 获取邀请配置
+     * 获取当前生效的邀请配置
      * GET /api/admin/invite/config
      */
     @GetMapping("/invite/config")
@@ -44,7 +45,7 @@ public class AdminController {
     }
 
     /**
-     * 更新邀请配置
+     * 更新当前生效的邀请配置(无则自动创建)
      * PUT /api/admin/invite/config
      */
     @PutMapping("/invite/config")
@@ -59,12 +60,69 @@ public class AdminController {
     }
 
     /**
-     * 获取所有邀请配置
-     * GET /api/admin/invite/configs
+     * 分页获取邀请配置列表
+     * GET /api/admin/invite/configs?page=1&size=20
      */
     @GetMapping("/invite/configs")
-    public Result<List<InviteConfig>> listInviteConfigs() {
-        return Result.success(inviteConfigService.listConfigs());
+    public Result<Page<InviteConfig>> listInviteConfigs(
+            @RequestParam(defaultValue = "1") Integer page,
+            @RequestParam(defaultValue = "20") Integer size) {
+        return Result.success(inviteConfigService.listConfigs(page, size));
+    }
+
+    /**
+     * 根据ID获取邀请配置
+     * GET /api/admin/invite/config/{id}
+     */
+    @GetMapping("/invite/config/{id}")
+    public Result<InviteConfig> getInviteConfigById(@PathVariable Long id) {
+        return Result.success(inviteConfigService.getById(id));
+    }
+
+    /**
+     * 创建新的邀请配置
+     * POST /api/admin/invite/config
+     */
+    @PostMapping("/invite/config")
+    public Result<InviteConfig> createInviteConfig(@Valid @RequestBody InviteConfigRequest request,
+                                                    HttpServletRequest httpRequest) {
+        Long operatorId = (Long) httpRequest.getAttribute("userId");
+        String operatorRole = getOperatorRole(httpRequest);
+        InviteConfig result = inviteConfigService.createConfig(request);
+        auditLogService.logSuccess(operatorId != null ? operatorId : 0L, "管理员", operatorRole,
+                OperationType.INVITE_CONFIG_UPDATE, "INVITE_CONFIG_CREATE", result.getId());
+        return Result.success(result);
+    }
+
+    /**
+     * 更新指定ID的邀请配置
+     * PUT /api/admin/invite/config/{id}
+     */
+    @PutMapping("/invite/config/{id}")
+    public Result<InviteConfig> updateInviteConfigById(@PathVariable Long id,
+                                                        @Valid @RequestBody InviteConfigRequest request,
+                                                        HttpServletRequest httpRequest) {
+        Long operatorId = (Long) httpRequest.getAttribute("userId");
+        String operatorRole = getOperatorRole(httpRequest);
+        InviteConfig result = inviteConfigService.updateConfig(id, request);
+        auditLogService.logSuccess(operatorId != null ? operatorId : 0L, "管理员", operatorRole,
+                OperationType.INVITE_CONFIG_UPDATE, "INVITE_CONFIG_UPDATE:" + id, id);
+        return Result.success(result);
+    }
+
+    /**
+     * 删除邀请配置
+     * DELETE /api/admin/invite/config/{id}
+     */
+    @DeleteMapping("/invite/config/{id}")
+    public Result<Void> deleteInviteConfig(@PathVariable Long id,
+                                            HttpServletRequest httpRequest) {
+        Long operatorId = (Long) httpRequest.getAttribute("userId");
+        String operatorRole = getOperatorRole(httpRequest);
+        inviteConfigService.deleteConfig(id);
+        auditLogService.logSuccess(operatorId != null ? operatorId : 0L, "管理员", operatorRole,
+                OperationType.INVITE_CONFIG_UPDATE, "INVITE_CONFIG_DELETE:" + id, id);
+        return Result.success();
     }
 
     // ==================== 爬虫次数手动发放 ====================

+ 80 - 20
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteConfigService.java

@@ -1,5 +1,9 @@
 package com.xuekairui.invite.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
 import com.xuekairui.invite.dto.InviteConfigRequest;
 import com.xuekairui.invite.entity.InviteConfig;
 import com.xuekairui.invite.mapper.InviteConfigMapper;
@@ -49,11 +53,86 @@ public class InviteConfigService {
     }
 
     /**
-     * 更新邀请配置
+     * 更新当前生效的邀请配置(无则自动创建)
      */
     public InviteConfig updateConfig(InviteConfigRequest request) {
         InviteConfig config = getActiveConfig();
+        buildConfigFromRequest(config, request);
 
+        if (config.getId() != null) {
+            inviteConfigMapper.updateById(config);
+        } else {
+            inviteConfigMapper.insert(config);
+        }
+
+        log.info("邀请配置已更新: rewardType={}, rewardMonths={}, appDownloadUrl={}, miniappPath={}",
+                config.getRewardType(), config.getRewardMonths(), config.getAppDownloadUrl(), config.getMiniappPath());
+        return config;
+    }
+
+    /**
+     * 分页获取配置列表(运营查看)
+     */
+    public Page<InviteConfig> listConfigs(Integer page, Integer size) {
+        int pageNo = page != null && page > 0 ? page : 1;
+        int pageSize = Math.min(size != null && size > 0 ? size : 20, 50);
+        Page<InviteConfig> pageParam = new Page<>(pageNo, pageSize);
+        LambdaQueryWrapper<InviteConfig> wrapper = new LambdaQueryWrapper<>();
+        wrapper.orderByDesc(InviteConfig::getCreateTime);
+        return inviteConfigMapper.selectPage(pageParam, wrapper);
+    }
+
+    /**
+     * 根据ID获取配置
+     */
+    public InviteConfig getById(Long id) {
+        InviteConfig config = inviteConfigMapper.selectById(id);
+        if (config == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "邀请配置不存在");
+        }
+        return config;
+    }
+
+    /**
+     * 创建新的邀请配置
+     */
+    public InviteConfig createConfig(InviteConfigRequest request) {
+        InviteConfig config = buildConfigFromRequest(new InviteConfig(), request);
+        inviteConfigMapper.insert(config);
+        log.info("创建邀请配置: id={}, rewardType={}, rewardMonths={}", config.getId(), config.getRewardType(), config.getRewardMonths());
+        return config;
+    }
+
+    /**
+     * 更新指定ID的邀请配置
+     */
+    public InviteConfig updateConfig(Long id, InviteConfigRequest request) {
+        InviteConfig config = inviteConfigMapper.selectById(id);
+        if (config == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "邀请配置不存在");
+        }
+        buildConfigFromRequest(config, request);
+        inviteConfigMapper.updateById(config);
+        log.info("更新邀请配置: id={}, rewardType={}, rewardMonths={}", config.getId(), config.getRewardType(), config.getRewardMonths());
+        return config;
+    }
+
+    /**
+     * 删除邀请配置
+     */
+    public void deleteConfig(Long id) {
+        InviteConfig config = inviteConfigMapper.selectById(id);
+        if (config == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "邀请配置不存在");
+        }
+        inviteConfigMapper.deleteById(id);
+        log.info("删除邀请配置: id={}", id);
+    }
+
+    /**
+     * 将请求参数填充到配置实体
+     */
+    private InviteConfig buildConfigFromRequest(InviteConfig config, InviteConfigRequest request) {
         if (request.getRewardCrawlerCount() != null) {
             config.setRewardCrawlerCount(request.getRewardCrawlerCount());
         }
@@ -75,7 +154,6 @@ public class InviteConfigService {
         if (request.getMaxTotalInvites() != null) {
             config.setMaxTotalInvites(request.getMaxTotalInvites());
         }
-        // 多渠道配置
         if (request.getAppDownloadUrl() != null) {
             config.setAppDownloadUrl(request.getAppDownloadUrl());
         }
@@ -94,7 +172,6 @@ public class InviteConfigService {
         if (request.getFeishuAppId() != null) {
             config.setFeishuAppId(request.getFeishuAppId());
         }
-        // 落地页配置
         if (request.getLandingTitle() != null) {
             config.setLandingTitle(request.getLandingTitle());
         }
@@ -110,7 +187,6 @@ public class InviteConfigService {
         if (request.getRemark() != null) {
             config.setRemark(request.getRemark());
         }
-        // 文案模板配置
         if (request.getShareTitleTemplate() != null) {
             config.setShareTitleTemplate(request.getShareTitleTemplate());
         }
@@ -144,22 +220,6 @@ public class InviteConfigService {
         if (request.getInstructionDefault() != null) {
             config.setInstructionDefault(request.getInstructionDefault());
         }
-
-        if (config.getId() != null) {
-            inviteConfigMapper.updateById(config);
-        } else {
-            inviteConfigMapper.insert(config);
-        }
-
-        log.info("邀请配置已更新: rewardType={}, rewardMonths={}, appDownloadUrl={}, miniappPath={}",
-                config.getRewardType(), config.getRewardMonths(), config.getAppDownloadUrl(), config.getMiniappPath());
         return config;
     }
-
-    /**
-     * 获取配置列表(运营查看)
-     */
-    public List<InviteConfig> listConfigs() {
-        return inviteConfigMapper.selectList(null);
-    }
 }

+ 37 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/config/JacksonConfig.java

@@ -0,0 +1,37 @@
+package com.xuekairui.user.config;
+
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Jackson 全局配置:LocalDateTime 出入参统一使用北京时间格式 yyyy-MM-dd HH:mm:ss
+ * <p>
+ * 注意:spring.jackson.date-format 只对 java.util.Date 生效,
+ * LocalDateTime 由 JavaTimeModule 处理,需单独注册 Serializer/Deserializer。
+ */
+@Configuration
+public class JacksonConfig {
+
+    private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
+    private static final DateTimeFormatter DATE_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
+
+    @Bean
+    public Jackson2ObjectMapperBuilderCustomizer localDateTimeCustomizer() {
+        return builder -> {
+            // 出参:LocalDateTime → "yyyy-MM-dd HH:mm:ss"
+            builder.serializerByType(LocalDateTime.class,
+                    new LocalDateTimeSerializer(DATE_TIME_FORMATTER));
+            // 入参:"yyyy-MM-dd HH:mm:ss" → LocalDateTime(前端传参)
+            builder.deserializerByType(LocalDateTime.class,
+                    new LocalDateTimeDeserializer(DATE_TIME_FORMATTER));
+            builder.simpleDateFormat(DATE_TIME_PATTERN);
+        };
+    }
+}

+ 8 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/config/MyBatisPlusMetaHandler.java

@@ -5,21 +5,25 @@ import org.apache.ibatis.reflection.MetaObject;
 import org.springframework.stereotype.Component;
 
 import java.time.LocalDateTime;
+import java.time.ZoneId;
 
 /**
- * MyBatis-Plus 字段自动填充处理器
+ * MyBatis-Plus 字段自动填充处理器(北京时间)
  */
 @Component
 public class MyBatisPlusMetaHandler implements MetaObjectHandler {
 
+    private static final ZoneId ZONE_BEIJING = ZoneId.of("Asia/Shanghai");
+
     @Override
     public void insertFill(MetaObject metaObject) {
-        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
-        this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+        LocalDateTime now = LocalDateTime.now(ZONE_BEIJING);
+        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
+        this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
     }
 
     @Override
     public void updateFill(MetaObject metaObject) {
-        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now(ZONE_BEIJING));
     }
 }

+ 13 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/RefreshTokenRequest.java

@@ -1,15 +1,28 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.annotation.JsonCreator;
 import jakarta.validation.constraints.NotBlank;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 /**
  * 刷新Token请求
+ * <p>
+ * 支持两种请求格式:
+ * <pre>{@code {"refreshToken": "eyJ..."}}</pre>(标准 JSON 对象)
+ * 或直接传原始 token 字符串(兼容部分老客户端)
  */
 @Data
+@NoArgsConstructor
 public class RefreshTokenRequest {
 
     /** refreshToken */
     @NotBlank(message = "refreshToken不能为空")
     private String refreshToken;
+
+    /** 兼容客户端直接发送原始 token 字符串作为请求体 */
+    @JsonCreator
+    public RefreshTokenRequest(String rawToken) {
+        this.refreshToken = rawToken;
+    }
 }

+ 51 - 7
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -48,6 +48,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
     private static final String AUTH_HEADER = "Authorization";
     private static final String TOKEN_PREFIX = "Bearer ";
+    /** 允许使用过期 token 访问的路径(续期/心跳类接口) */
+    private static final String[] LENIENT_PATHS = {
+            "/api/auth/heartbeat",
+            "/api/auth/user/info"
+    };
 
     @Override
     protected void doFilterInternal(
@@ -63,21 +68,34 @@ 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 {
-                String tokenType = jwtUtil.getTokenType(token);
+                boolean isLenientPath = isLenientPath(request);
+                String tokenType = isLenientPath
+                        ? jwtUtil.parseTokenLenient(token).get("type", String.class)
+                        : jwtUtil.getTokenType(token);
                 if ("access".equals(tokenType)) {
-                    Long userId = jwtUtil.getUserId(token);
-
+                    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, jwtUtil.getTokenVersion(token))) {
+                    if (!isTokenVersionValid(userId, tokenVer)) {
                         log.info("用户 {} 的Token版本号不匹配(已在其他设备登录),拒绝访问", userId);
                         writeKickedResponse(response);
                         return;
                     }
 
-                    String role = jwtUtil.getRole(token);
+                    String role = isLenientPath
+                            ? jwtUtil.parseTokenLenient(token).get("role", String.class)
+                            : jwtUtil.getRole(token);
+                    if (role == null) {
+                        role = "USER";
+                    }
 
                     List<GrantedAuthority> authorities = Collections.singletonList(
                             new SimpleGrantedAuthority("ROLE_" + role)
@@ -101,9 +119,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
                     // 尽早注入用户ID到MDC,使链路中后续日志都能带上uid
                     MDC.put("uid", String.valueOf(userId));
+                } else if (isHeartbeat) {
+                    log.warn("[heartbeat] 401原因: token类型不是access,实际type={},请用accessToken调用heartbeat", tokenType);
                 }
             } catch (Exception e) {
-                log.debug("JWT认证失败: {}", e.getMessage());
+                log.warn("JWT认证失败: {}", e.getMessage());
                 SecurityContextHolder.clearContext();
             }
         }
@@ -157,4 +177,28 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
         }
         return null;
     }
+
+    /** 判断当前请求路径是否允许使用过期 token */
+    private boolean isLenientPath(HttpServletRequest request) {
+        String path = request.getRequestURI();
+        for (String lenientPath : LENIENT_PATHS) {
+            if (path.equals(lenientPath)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 从可能已过期的 token 中提取版本号 */
+    private Long getTokenVersionLenient(String token) {
+        try {
+            Object version = jwtUtil.parseTokenLenient(token).get("tokenVersion");
+            if (version instanceof Number) {
+                return ((Number) version).longValue();
+            }
+            return 0L;
+        } catch (Exception e) {
+            return 0L;
+        }
+    }
 }

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

@@ -16,6 +16,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
+import java.time.ZoneId;
 
 /**
  * 搜索记录服务
@@ -37,7 +38,7 @@ public class SearchRecordService {
         SearchRecord record = SearchRecord.builder()
                 .userId(userId)
                 .drugName(request.getDrugName().trim())
-                .searchedAt(request.getSearchedAt() != null ? request.getSearchedAt() : LocalDateTime.now())
+                .searchedAt(LocalDateTime.now(ZoneId.of("Asia/Shanghai")))
                 .statusText(request.getStatusText())
                 .platformResults(toJsonString(request.getPlatformResults()))
                 .searchLog(toJsonString(request.getSearchLog()))

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

@@ -91,7 +91,7 @@ public class JwtUtil {
     }
 
     /**
-     * 解析令牌
+     * 解析令牌(严格模式:过期即抛异常)
      */
     public Claims parseToken(String token) {
         try {
@@ -108,6 +108,27 @@ public class JwtUtil {
         }
     }
 
+    /**
+     * 解析令牌(宽容模式:过期 token 仍可提取 claims,用于 heartbeat 等续期场景)
+     * <p>
+     * 仅在校验签名失败等严重错误时抛异常,过期不在此列。
+     */
+    public Claims parseTokenLenient(String token) {
+        try {
+            return Jwts.parser()
+                    .verifyWith(getSigningKey())
+                    .build()
+                    .parseSignedClaims(token)
+                    .getPayload();
+        } catch (ExpiredJwtException e) {
+            // 过期仍返回 claims,调用方自行判断
+            return e.getClaims();
+        } catch (JwtException e) {
+            log.warn("JWT解析失败, 原因: {}", e.getMessage());
+            throw new BusinessException(ErrorCode.TOKEN_INVALID);
+        }
+    }
+
     /**
      * 获取用户ID
      */