ソースを参照

小程序鉴权调整

liuchengsen 1 ヶ月 前
コミット
361b3cbee1

+ 9 - 9
deploy/.env.example

@@ -6,24 +6,24 @@
 # =============================================================
 
 # ---- MySQL 数据库 ----
-DB_HOST=127.0.0.1
+DB_HOST=localhost
 DB_PORT=3306
 DB_NAME=zhijiayun_pharmacy
-DB_USERNAME=zhijiayun
-DB_PASSWORD=请替换为强密码
+DB_USERNAME=user
+DB_PASSWORD=qPa5*gt8o.
 
 # ---- Redis ----
 REDIS_HOST=127.0.0.1
 REDIS_PORT=6379
-REDIS_PASSWORD=请替换为强密码
-REDIS_DATABASE=1
+REDIS_PASSWORD=123456
+REDIS_DATABASE=0
 
 # ---- JWT(必须 >= 256 bit 随机字符串,可用 openssl rand -base64 64 生成) ----
-JWT_SECRET=请替换为高强度随机密钥
+JWT_SECRET=&kB4uLH<&aL6(V64*X0;=j(<!a^?Mg]]BS&r<CelU5*mw[.Y!1L^T+Fb&Aupc^;,V,bd?samGW=wf(|Wy?|dP=7%n|K1,1p8$8iVo|>Tf.DwPfJpgpLbUR0[o7i49<gY2,&]k#)PoZn#,cBl)<2&_jorS7RPg#9.Io&KE_z0A=A6myl-:(^$zngq#0S>V$0wJV:tvfu6qpE4^gznl|4SB$,0Dk:X0I|Y&9n4&;Oo,7htC%f-NyNGx2#2baqEY5I|
 
 # ---- 阿里云短信 ----
-ALIBABA_CLOUD_ACCESS_KEY_ID=请填入AccessKeyId
-ALIBABA_CLOUD_ACCESS_KEY_SECRET=请填入AccessKeySecret
+ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5tDwjfteBvivYN41r8sJ
+ALIBABA_CLOUD_ACCESS_KEY_SECRET=yowuOGi2nYYnrqGpO3qcz94C4brcPp
 SMS_SIGN_NAME=开邻
 SMS_TEMPLATE_CODE=SMS_483305249
 
@@ -33,7 +33,7 @@ WECHAT_APP_SECRET=请填入正式AppSecret
 WECHAT_REDIRECT_URI=https://your-domain.com/api/auth/wechat/callback
 
 # ---- 邀请链接基础地址(生产域名) ----
-INVITE_BASE_URL=https://your-domain.com
+INVITE_BASE_URL=https://priceapi.kailin.com.cn
 
 # ---- 外部系统 API Key(第三方登录) ----
 # 注意:API Key 已改为通过运营后台的全局系统配置管理,无需环境变量

ファイルの差分が大きいため隠しています
+ 3908 - 0
test-output.txt


+ 14 - 3
zhijiayun-common/src/main/java/com/xuekairui/common/RandomUtil.java

@@ -7,17 +7,28 @@ import java.security.SecureRandom;
  */
 public final class RandomUtil {
 
-    /** 字符集:去掉易混淆的 I/l/O/0/1 */
+    /** 通用字符集:去掉易混淆的 I/l/O/0/1,含大小写 */
     private static final String CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
+    /** 邀请码专用字符集:纯大写字母(不含 I/O)+ 数字(不含 0/1),共32个 */
+    private static final String CHARS_UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
     private static final SecureRandom RANDOM = new SecureRandom();
 
     private RandomUtil() {}
 
-    /** 生成指定长度的随机字符串 */
+    /** 生成指定长度的随机字符串(大小写混用) */
     public static String generateCode(int length) {
+        return generateFrom(CHARS, length);
+    }
+
+    /** 生成指定长度的随机字符串(纯大写+数字,用于邀请码等场景) */
+    public static String generateUpperCode(int length) {
+        return generateFrom(CHARS_UPPER, length);
+    }
+
+    private static String generateFrom(String chars, int length) {
         StringBuilder sb = new StringBuilder(length);
         for (int i = 0; i < length; i++) {
-            sb.append(CHARS.charAt(RANDOM.nextInt(CHARS.length())));
+            sb.append(chars.charAt(RANDOM.nextInt(chars.length())));
         }
         return sb.toString();
     }

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

@@ -6,9 +6,9 @@
 spring:
   datasource:
     driver-class-name: com.mysql.cj.jdbc.Driver
-    url: jdbc:mysql://localhost:3308/zhijiayun_pharmacy_dev?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
-    username: root
-    password: 123456
+    url: jdbc:mysql://8.135.32.236:3306/zhijiayun_pharmacy?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+    username: user
+    password: qPa5*gt8o.
     hikari:
       minimum-idle: 5
       maximum-pool-size: 20

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

@@ -8,8 +8,8 @@ spring:
   datasource:
     driver-class-name: com.mysql.cj.jdbc.Driver
     url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:zhijiayun_pharmacy}?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
-    username: ${DB_USERNAME:root}
-    password: ${DB_PASSWORD:Root@123456}
+    username: ${DB_USERNAME:user}
+    password: ${DB_PASSWORD:qPa5*gt8o.}
     hikari:
       minimum-idle: 10
       maximum-pool-size: 50
@@ -42,6 +42,7 @@ jwt:
   secret: ${JWT_SECRET:YourSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!}
   user-expiration: 3600000         # 1小时(毫秒)
   admin-expiration: 43200000       # 12小时(毫秒)
+  miniapp-expiration: 2592000000   # 小程序 30天(毫秒)
   expiration: 300000
   refresh-expiration: 604800000
 
@@ -96,21 +97,15 @@ gateway:
       - /favicon.ico
   rate-limit:
     enabled: true
-    default-limit: 60
+    default-limit: 120
     default-window: 60
     rules:
       - path-prefixes:
           - /api/auth/sms
-        limit: 5
-        window: 60
-      - path-prefixes:
-          - /api/auth/login
         limit: 10
-        window: 60
       - path-prefixes:
-          - /api/crawler
+          - /api/auth/login
         limit: 30
-        window: 60
 
 # 邀请码配置
 invite:

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

@@ -24,8 +24,9 @@ spring:
 # JWT 配置(公共,各环境可覆盖)
 jwt:
   secret: YourSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!
-  user-expiration: 3600000         # 普通用户 1小时(毫秒)
+  user-expiration: 3600000         # 普通用户(PC等) 1小时(毫秒)
   admin-expiration: 43200000       # 管理员 12小时(毫秒)
+  miniapp-expiration: 2592000000   # 小程序 30天(毫秒)
   expiration: 86400000             # 旧字段兼容
   refresh-expiration: 604800000    # 7天(毫秒)
 
@@ -176,7 +177,6 @@ gateway:
       - path-prefixes:
           - /api/auth/sms
         limit: 10
-        window: 60
       # 登录/Token/注册/邀请:防刷,30次/分钟
       - path-prefixes:
           - /api/auth/login
@@ -186,16 +186,4 @@ gateway:
           - /api/auth/register
           - /api/invite/click
         limit: 30
-        window: 60
-      # 公开接口/支付回调/爬虫/搜索:60次/分钟
-      - path-prefixes:
-          - /api/invite/page
-          - /api/invite/resolve-link
-          - /api/payment/callback
-          - /api/payment/anxin/notify
-          - /api/payment/agreement/notify
-          - /api/payment/alipay
-          - /api/crawler
-          - /api/search/suggestions
-        limit: 60
-        window: 60
+      # 其余接口走默认 100次/分钟,无需单独列出

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

@@ -1,6 +1,7 @@
 package com.xuekairui.gateway;
 
 import com.xuekairui.user.security.TokenVersionCache;
+import com.xuekairui.user.util.JwtUtil;
 import io.jsonwebtoken.Jwts;
 import io.jsonwebtoken.security.Keys;
 import org.junit.jupiter.api.BeforeAll;
@@ -16,6 +17,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.jdbc.datasource.init.ScriptUtils;
 import org.springframework.test.context.ActiveProfiles;
 import org.springframework.test.web.servlet.MockMvc;
+import lombok.extern.slf4j.Slf4j;
 
 import javax.crypto.SecretKey;
 import java.nio.charset.StandardCharsets;
@@ -29,6 +31,7 @@ import java.util.Map;
  * 提供:MockMvc、JWT Token 生成、数据库初始化/清理、Redis Mock
  * </p>
  */
+@Slf4j
 @SpringBootTest
 @AutoConfigureMockMvc
 @ActiveProfiles("test")
@@ -48,6 +51,9 @@ public abstract class GatewayBaseTest {
     @Autowired
     protected TokenVersionCache tokenVersionCache;
 
+    @Autowired
+    protected JwtUtil jwtUtil;
+
     /** 测试用 JWT Secret(与 application-test.yml 一致) */
     private static final String TEST_JWT_SECRET =
             "TestSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!";
@@ -87,7 +93,11 @@ public abstract class GatewayBaseTest {
                 ScriptUtils.executeSqlScript(conn, resource);
             }
         } catch (Exception e) {
-            throw new RuntimeException("Failed to reset test database", e);
+            throw new RuntimeException(
+                "无法连接测试数据库,请检查 MySQL 是否已启动(端口 3308)"
+                    + " 且数据库 zhijiayun_pharmacy1 是否存在。"
+                    + " 配置位于 application-test.yml。",
+                e);
         }
     }
 
@@ -144,14 +154,22 @@ public abstract class GatewayBaseTest {
         claims.put("role", role != null ? role : "USER");
         claims.put("type", "access");
         claims.put("tokenVersion", ver);
+        claims.put("loginSource", "UNKNOWN");
 
-        return Jwts.builder()
+        String token = Jwts.builder()
                 .claims(claims)
                 .subject(String.valueOf(userId))
                 .issuedAt(new Date())
                 .expiration(new Date(System.currentTimeMillis() + 86400000))
                 .signWith(key)
                 .compact();
+        try {
+            jwtUtil.parseToken(token);
+            log.info("[DEBUG] generateAccessToken: parseToken OK, userId={}, token={}", userId, token);
+        } catch (Exception e) {
+            log.warn("[DEBUG] generateAccessToken: parseToken FAILED: userId={}, error={}, token={}", userId, e.getMessage(), token);
+        }
+        return token;
     }
 
     /**

+ 11 - 2
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/TestConfig.java

@@ -20,9 +20,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.
 public class TestConfig {
 
     /**
-     * 数据库初始化 Bean:启动时执行测试建表脚本
+     * 数据库初始化 Bean:启动时执行测试建表脚本。
+     * <p>使用 {@code @Lazy} 延迟初始化,避免 ApplicationContext 启动阶段
+     * 因数据库不可达而直接报错。初始化仍会在首次注入时触发,
+     * 但错误信息更清晰,不会导致所有测试的上下文缓存全部失效。</p>
      */
     @Bean
+    @org.springframework.context.annotation.Lazy
     public DatabaseInitializer databaseInitializer(JdbcTemplate jdbcTemplate) {
         return new DatabaseInitializer(jdbcTemplate);
     }
@@ -50,7 +54,12 @@ public class TestConfig {
                 );
                 System.out.println("[DB-INIT] Test database initialized successfully");
             } catch (Exception e) {
-                throw new RuntimeException("Failed to initialize test database", e);
+                throw new RuntimeException(
+                    "无法执行测试数据库初始化脚本 test-schema.sql,"
+                        + "请检查 MySQL 是否已启动(端口 3308)"
+                        + " 且数据库 zhijiayun_pharmacy1 是否存在。"
+                        + " 配置位于 application-test.yml。",
+                    e);
             }
         }
     }

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

@@ -42,6 +42,7 @@ jwt:
   secret: TestSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!
   user-expiration: 3600000         # 1小时(毫秒)
   admin-expiration: 43200000       # 12小时(毫秒)
+  miniapp-expiration: 2592000000   # 小程序 30天(毫秒)
   expiration: 86400000
   refresh-expiration: 604800000
 

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

@@ -953,7 +953,7 @@ public class InviteService {
     private String generateUniqueCode() {
         int maxRetries = 5;
         for (int attempt = 0; attempt < maxRetries; attempt++) {
-            String code = com.xuekairui.common.RandomUtil.generateCode(8);
+            String code = com.xuekairui.common.RandomUtil.generateUpperCode(8);
             if (inviteCodeMapper.selectCount(
                     new LambdaQueryWrapper<InviteCode>().eq(InviteCode::getCode, code)) == 0) {
                 return code;

+ 60 - 0
zhijiayun-test/src/main/resources/static/activity-test.html

@@ -1438,6 +1438,65 @@ function renderAdminSystemConfigModule() {
         [{label:'配置ID(id)', name:'id', placeholder:'需删除的配置ID', default:''}], true, null, '删除指定ID的系统配置。删除后对应API Key将立即失效')}`;
 }
 
+// ==================== 运营端:操作审计日志模块 ====================
+function renderAdminAuditLogModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runAdminAuditLogTests()">运行操作审计日志测试</button>
+    </div>
+    ${testSection('admin-audit-log-list', '分页查询操作日志', 'GET', 'get', '/api/admin/audit-log?page={page}&size={size}', '需认证', 'auth-required',
+        [
+         {label:'页码(page)', name:'page', placeholder:'1', default:'1'},
+         {label:'每页(size)', name:'size', placeholder:'20', default:'20'}
+        ], true, null, '分页查询操作审计日志,默认按操作时间倒序排列')}
+    ${testSection('admin-audit-log-filter-module', '按模块筛选', 'GET', 'get', '/api/admin/audit-log?module={module}&page={page}&size={size}', '需认证', 'auth-required',
+        [
+         {label:'模块(module)', name:'module', placeholder:'USER/LEVEL/PLATFORM/LICENSE/INVITE/COUPON/SYSTEM', default:'LICENSE'},
+         {label:'页码(page)', name:'page', placeholder:'1', default:'1'},
+         {label:'每页(size)', name:'size', placeholder:'20', default:'20'}
+        ], true, null, '按操作模块筛选日志。module可选值:USER-用户管理 / LEVEL-等级配置 / PLATFORM-平台配置 / LICENSE-入驻信息 / INVITE-邀请系统 / COUPON-优惠券 / SYSTEM-系统管理')}
+    ${testSection('admin-audit-log-filter-type', '按操作类型筛选', 'GET', 'get', '/api/admin/audit-log?operationType={operationType}', '需认证', 'auth-required',
+        [{label:'操作类型(operationType)', name:'operationType', placeholder:'LICENSE_CREATE/LICENSE_EDIT/LICENSE_REJECT', default:'LICENSE_CREATE'}],
+        true, null, '按操作类型精确筛选。入驻相关类型:LICENSE_CREATE-新增 / LICENSE_EDIT-编辑 / LICENSE_REJECT-驳回 / LICENSE_APPROVE-审核通过 / LICENSE_RE_REJECT-重新驳回')}
+    ${testSection('admin-audit-log-filter-source', '按操作来源筛选', 'GET', 'get', '/api/admin/audit-log?operationSource={operationSource}', '需认证', 'auth-required',
+        [{label:'操作来源(operationSource)', name:'operationSource', placeholder:'ADMIN_CREATE/USER_SUBMIT/EXTERNAL', default:'ADMIN_CREATE'}],
+        true, null, '按操作来源筛选(第四批新增)。operationSource可选值:ADMIN_CREATE-管理员新增 / USER_SUBMIT-用户自主提交 / EXTERNAL-外部系统。用于区分操作发起来源,便于审计追溯')}
+    ${testSection('admin-audit-log-filter-operator', '按操作人筛选', 'GET', 'get', '/api/admin/audit-log?operatorId={operatorId}', '需认证', 'auth-required',
+        [{label:'操作人ID(operatorId)', name:'operatorId', placeholder:'100', default:'100'}],
+        true, null, '按操作人ID筛选,查看指定管理员或用户的全部操作记录')}
+    ${testSection('admin-audit-log-filter-sensitive', '筛选敏感操作', 'GET', 'get', '/api/admin/audit-log?isSensitive={isSensitive}', '需认证', 'auth-required',
+        [{label:'是否敏感(isSensitive)', name:'isSensitive', placeholder:'true/false', default:'true', type:'string'}],
+        true, null, '筛选敏感操作日志。敏感操作包括:入驻审核、等级变更、权限修改等需要特别关注的操作')}
+    ${testSection('admin-audit-log-filter-time', '按时间范围筛选', 'GET', 'get', '/api/admin/audit-log?startTime={startTime}&endTime={endTime}', '需认证', 'auth-required',
+        [
+         {label:'开始时间(startTime)', name:'startTime', placeholder:'2024-01-01T00:00:00', default:'2024-01-01T00:00:00'},
+         {label:'结束时间(endTime)', name:'endTime', placeholder:'2030-12-31T23:59:59', default:'2030-12-31T23:59:59'}
+        ], true, null, '按操作时间范围筛选,ISO 8601格式:yyyy-MM-ddTHH:mm:ss')}
+    ${testSection('admin-audit-log-multi-filter', '多条件组合筛选', 'GET', 'get', '/api/admin/audit-log?operationSource={operationSource}&module={module}&page={page}&size={size}', '需认证', 'auth-required',
+        [
+         {label:'操作来源(operationSource)', name:'operationSource', placeholder:'ADMIN_CREATE', default:'ADMIN_CREATE'},
+         {label:'模块(module)', name:'module', placeholder:'LICENSE', default:'LICENSE'},
+         {label:'页码(page)', name:'page', placeholder:'1', default:'1'},
+         {label:'每页(size)', name:'size', placeholder:'10', default:'10'}
+        ], true, null, '操作来源与模块组合筛选示例:查看所有管理员发起的入驻相关操作')}
+    ${testSection('admin-audit-log-sensitive', '查询敏感操作日志', 'GET', 'get', '/api/admin/audit-log/sensitive', '需认证', 'auth-required',
+        [], true, null, '查询所有敏感操作日志列表(不分页),默认查询全部,可配合startTime参数筛选')}
+    ${testSection('admin-audit-log-user', '查询用户操作日志', 'GET', 'get', '/api/admin/audit-log/user/{userId}?limit={limit}', '需认证', 'auth-required',
+        [
+         {label:'用户ID(userId)', name:'userId', placeholder:'1', default:'1'},
+         {label:'返回数量(limit)', name:'limit', placeholder:'20', default:'20'}
+        ], true, null, '查询指定用户的操作日志,按操作时间倒序,limit控制返回条数')}
+    ${testSection('admin-audit-log-target', '查询目标操作历史', 'GET', 'get', '/api/admin/audit-log/target/{targetType}/{targetId}?limit={limit}', '需认证', 'auth-required',
+        [
+         {label:'目标类型(targetType)', name:'targetType', placeholder:'USER/LEVEL_CONFIG/PLATFORM/ACCOUNT/LICENSE', default:'LICENSE'},
+         {label:'目标ID(targetId)', name:'targetId', placeholder:'1', default:'1'},
+         {label:'返回数量(limit)', name:'limit', placeholder:'20', default:'20'}
+        ], true, null, '查询指定目标对象的操作历史。入驻信息审核记录可通过 targetType=LICENSE + targetId=入驻记录ID 查询完整操作链路')}
+    ${testSection('admin-audit-log-stats', '模块操作统计', 'GET', 'get', '/api/admin/audit-log/stats?module={module}', '需认证', 'auth-required',
+        [{label:'模块(module)', name:'module', placeholder:'USER/LEVEL/LICENSE', default:'LICENSE'}],
+        true, null, '统计指定模块的操作次数,可配合startTime/endTime参数按时间范围统计')}`;
+}
+
 // ===================== 批量测试 ====================
 function getSectionsInModule(modulePrefix) {
     return Array.from(document.querySelectorAll('.test-section')).filter(s => {
@@ -1604,6 +1663,7 @@ function runAdminActivityCheckinTests() { runBatchTests('admin-activity-checkin'
 function runAdminActivityLotteryTests() { runBatchTests('admin-activity-lottery'); }
 function runAdminLicenseTests() { runBatchTests('admin-license'); }
 function runAdminSystemConfigTests() { runBatchTests('admin-system-config'); }
+function runAdminAuditLogTests() { runBatchTests('admin-audit-log'); }
 function runSearchRecordTests() { runBatchTests('search-record'); }
 
 // ===================== 验证器 =====================

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

@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*;
 /**
  * 搜索记录控制器
  * 主搜索页"历史检索"接口,存储完整的比价搜索快照
- *
+ * <p>
  * 路径:/api/search-record/history
  */
 @Slf4j
@@ -62,27 +62,4 @@ public class SearchRecordController {
         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();
-    }
 }

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

@@ -237,8 +237,9 @@ public class AuthService {
             existUser.setLoginSource("PC");
             userMapper.updateById(existUser);
 
-            // 删除旧的微信用户
-            userMapper.deleteById(wechatUser.getId());
+            // 禁用旧的微信用户(不物理删除,仅改状态)
+            wechatUser.setStatus(0);
+            userMapper.updateById(wechatUser);
             wechatUser = existUser;
         } else if (existUser == null) {
             // 绑定手机号到微信用户(首次完成注册)
@@ -387,7 +388,7 @@ public class AuthService {
                 .userId(userId)
                 .accessToken(newAccessToken)
                 .refreshToken(newRefreshToken)
-                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
+                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
                 .needBindPhone(false)
                 .role(roleCode)
                 .build();
@@ -815,7 +816,7 @@ public class AuthService {
                 .userId(user.getId())
                 .accessToken(newAccessToken)
                 .refreshToken(newRefreshToken)
-                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
+                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
                 .userInfo(convertToUserInfoResponse(user))
                 .needBindPhone(false)
                 .newUser(false)
@@ -860,7 +861,7 @@ public class AuthService {
                 .userId(user.getId())
                 .accessToken(accessToken)
                 .refreshToken(refreshToken)
-                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
+                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
                 .userInfo(convertToUserInfoResponse(user))
                 .needBindPhone(false)
                 .newUser(false)
@@ -1166,7 +1167,9 @@ public class AuthService {
             existUser.setLastLoginTime(LocalDateTime.now());
             existUser.setLastLoginIp(ip);
             userMapper.updateById(existUser);
-            userMapper.deleteById(wechatUser.getId());
+            // 禁用旧的小程序用户(不物理删除,仅改状态)
+            wechatUser.setStatus(0);
+            userMapper.updateById(wechatUser);
             wechatUser = existUser;
         } else if (existUser == null) {
             // 绑定手机号(完成注册)

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

@@ -1,6 +1,5 @@
 package com.xuekairui.user.service;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -77,28 +76,6 @@ public class SearchRecordService {
         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(详情用:含完整 platformResults / searchLog)
      */

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

@@ -30,6 +30,8 @@ public class JwtUtil {
     private Long userExpiration;       // 默认 1 小时
     @Value("${jwt.admin-expiration:43200000}")
     private Long adminExpiration;      // 默认 12 小时
+    @Value("${jwt.miniapp-expiration:2592000000}")
+    private Long miniappExpiration;    // 默认 30 天
 
     @Value("${jwt.refresh-expiration:604800000}")
     private Long refreshExpiration;    // 默认 7 天
@@ -48,10 +50,17 @@ public class JwtUtil {
     /**
      * 生成访问令牌(含 loginSource + tokenVersion 用于分端登录控制)
      * <p>
-     * 管理员 12 小时过期,普通用户 1 小时过期。
+     * 管理员 12 小时过期,小程序 30 天过期,普通用户(PC等)1 小时过期。
      */
     public String generateAccessToken(Long userId, String role, String loginSource, Long tokenVersion) {
-        long expirationMs = isAdminRole(role) ? adminExpiration : userExpiration;
+        long expirationMs;
+        if (isAdminRole(role)) {
+            expirationMs = adminExpiration;
+        } else if ("MINIAPP".equals(loginSource)) {
+            expirationMs = miniappExpiration;
+        } else {
+            expirationMs = userExpiration;
+        }
 
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
@@ -192,10 +201,22 @@ public class JwtUtil {
     }
 
     /**
-     * 获取 access token 过期时间(秒),按角色区分
+     * 获取 access token 过期时间(秒),按角色 + 登录来源区分
      */
-    public Long getExpirationInSeconds(String role) {
-        long expirationMs = isAdminRole(role) ? adminExpiration : userExpiration;
+    public Long getExpirationInSeconds(String role, String loginSource) {
+        long expirationMs;
+        if (isAdminRole(role)) {
+            expirationMs = adminExpiration;
+        } else if ("MINIAPP".equals(loginSource)) {
+            expirationMs = miniappExpiration;
+        } else {
+            expirationMs = userExpiration;
+        }
         return expirationMs / 1000;
     }
+
+    /** 兼容旧调用(仅按角色区分,不区分来源) */
+    public Long getExpirationInSeconds(String role) {
+        return getExpirationInSeconds(role, "UNKNOWN");
+    }
 }

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません