Explorar el Código

feat(quiz): 排行榜每周一自动清零、高频错题导出新增正确答案列、JWT优化

hechuanqi hace 7 horas
padre
commit
cc720993a2
Se han modificado 18 ficheros con 553 adiciones y 86 borrados
  1. 62 0
      docs/04-部署文档.md
  2. 181 0
      docs/小程序/401静默重登-前端需求.md
  3. 7 9
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRankController.java
  4. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RankService.java
  5. 69 5
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizTrackServiceImpl.java
  6. 88 31
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RankServiceImpl.java
  7. 33 3
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RealExamServiceImpl.java
  8. 4 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/StudyBookServiceImpl.java
  9. 16 9
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/UserQuestionServiceImpl.java
  10. 1 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/MyRankVO.java
  11. 2 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/QuestionDetailVO.java
  12. 2 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/QuestionVO.java
  13. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RankListVO.java
  14. 3 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamQuestionVO.java
  15. 1 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyBookQuestionVO.java
  16. 3 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubmitAnswerVO.java
  17. 3 1
      zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java
  18. 76 25
      zhijiayun-user/src/main/java/com/xuekairui/user/util/JwtUtil.java

+ 62 - 0
docs/04-部署文档.md

@@ -217,6 +217,68 @@ sudo systemctl start zhijiayun
 sudo systemctl status zhijiayun
 ```
 
+### 4.5 生产环境部署步骤(服务器 8.135.32.236)
+
+当前生产环境(236 服务器)采用「本地打包 → scp 上传 → deploy.sh 重启」的裸机部署方式,不使用 Docker。
+
+**部署环境信息**
+
+| 项目 | 值 |
+|------|-----|
+| 服务器 | `root@8.135.32.236` |
+| SSH 私钥 | `D:\project\D2D\docs\d2d.pem` |
+| 部署目录 | `/opt` |
+| 产物 JAR | `/opt/zhijiayun-gateway-1.0.0.jar` |
+| 部署脚本 | `/opt/deploy.sh`(start/stop/restart/status/build/logs) |
+| 环境变量 | `/opt/.env` |
+| PID 文件 | `/opt/zhijiayun-pharmacy.pid` |
+| 日志目录 | `/opt/logs/zhijiayun-pharmacy` |
+| 应用端口 | `8002`(健康检查 `/actuator/health`) |
+| 启动 Profile | `prod` |
+
+**步骤 1:本地打包**
+
+```bash
+cd D:\kailin-zuoti\projet-zuoti\zhijiayun-pharmacy
+mvn clean package -pl zhijiayun-gateway -am -DskipTests
+# 产物:zhijiayun-gateway\target\zhijiayun-gateway-1.0.0.jar
+```
+
+> 说明:必须带 `-pl zhijiayun-gateway -am`,会连同依赖模块(quiz/common/user)一起构建;不要只用 `-DskipTests` 裸打包。
+
+**步骤 2:上传 JAR 到服务器**
+
+```bash
+scp -i "D:\project\D2D\docs\d2d.pem" -o StrictHostKeyChecking=no `
+  "D:\kailin-zuoti\projet-zuoti\zhijiayun-pharmacy\zhijiayun-gateway\target\zhijiayun-gateway-1.0.0.jar" `
+  root@8.135.32.236:/opt/zhijiayun-gateway-1.0.0.jar.new
+```
+
+> 先上传为 `.jar.new`,避免直接覆盖正在运行的 JAR 导致中断。
+
+**步骤 3:覆盖 JAR 并重启**
+
+```bash
+ssh -i "D:\project\D2D\docs\d2d.pem" -o StrictHostKeyChecking=no root@8.135.32.236 `
+  "mv /opt/zhijiayun-gateway-1.0.0.jar.new /opt/zhijiayun-gateway-1.0.0.jar && cd /opt && ./deploy.sh restart"
+```
+
+`deploy.sh restart` 会自动:停止旧进程 → 加载 `/opt/.env` → 启动新 JAR → 健康检查(最多 60 秒)。
+
+**步骤 4:验证**
+
+```bash
+ssh -i "D:\project\D2D\docs\d2d.pem" -o StrictHostKeyChecking=no root@8.135.32.236 "cd /opt && ./deploy.sh status"
+# 或直接健康检查
+curl http://8.135.32.236:8002/actuator/health
+```
+
+**注意事项**
+
+- `/opt/.env` 必须包含 `JWT_SECRET`、`DB_PASSWORD`、`REDIS_PASSWORD`、`ALIBABA_CLOUD_ACCESS_KEY_ID`、`ALIBABA_CLOUD_ACCESS_KEY_SECRET` 等必填项,`deploy.sh` 启动前会校验。
+- `JWT_SECRET` 固定使用 `application-prod.yml` 中的默认值(`YourSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!`),**不要随意变更**,否则老用户缓存的 token 会全部失效。
+- 历史 JAR 备份:`/opt/zhijiayun-gateway-1.0.0.jar.bak.`(每次上线前已备份,无需重复备份)。
+
 ---
 
 ## 5. 数据库初始化

+ 181 - 0
docs/小程序/401静默重登-前端需求.md

@@ -0,0 +1,181 @@
+# 小程序 401 静默重新登录 - 前端需求
+
+## 一、背景
+
+生产环境 JWT 密钥发生过变更,老用户本地缓存的 token 已失效。当前老用户打开小程序后,所有需要登录的接口均返回 401,前端未处理 401,导致页面渲染空白。
+
+**结论**:这是纯前端问题。后端 401 响应规范(HTTP 401 + JSON body),前端补齐 401 处理逻辑后,老用户即可"无感自动重新登录",无需弹窗提醒。
+
+---
+
+## 二、后端接口约定(已确认)
+
+### 1. 401 响应格式
+
+HTTP 状态码 `401`,响应体(JSON):
+
+```json
+{ "code": 401, "message": "未授权,请先登录", "data": null, "timestamp": 1234567890 }
+```
+
+被踢下线(在其他设备登录)时:
+
+```json
+{ "code": 1304, "message": "您的账号已在其他设备登录,当前会话已失效,请重新登录", "data": null, "timestamp": 1234567890 }
+```
+
+**判定依据**:`HTTP status === 401`,或响应体 `code === 401 || code === 1304`。
+
+### 2. 静默重新登录接口(公开接口,无需鉴权)
+
+```
+POST /api/auth/wechat/miniapp/login
+Content-Type: application/json
+```
+
+请求体:
+
+```json
+{
+  "jsCode": "wx.login() 返回的 code",
+  "deviceType": "MINIAPP"
+}
+```
+
+成功响应:
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "accessToken": "...",
+    "refreshToken": "...",
+    "expiresIn": 2592000,
+    "needBindPhone": false,
+    "newUser": false,
+    "userInfo": {}
+  },
+  "timestamp": 1234567890
+}
+```
+
+**说明**:
+
+- 该接口在公开白名单内,即使当前 token 失效也能正常调用。
+- 统一登录按 openid 自动复用老账号,**不会新建账号、不会丢失用户数据**。
+- `needBindPhone = true` 时表示需要绑定手机号(老用户一般已绑定,正常返回 `false`)。
+- `expiresIn` 单位为秒。
+
+---
+
+## 三、前端实现方案
+
+在**请求封装层**(axios / request / 拦截器)增加 401 处理,核心逻辑如下。
+
+### 核心代码(示意)
+
+```js
+// 全局单例锁,防止多个请求同时 401 时重复触发重登
+let refreshing = null;
+
+async function handle401(error) {
+  // 1. 若已有重登在进行,等它完成后直接重试
+  if (refreshing) {
+    await refreshing;
+    return retryRequest(error.config);
+  }
+
+  refreshing = (async () => {
+    try {
+      // 2. 用 wx.login 获取新的 code(Promise 化)
+      const code = await wxLogin();
+
+      // 3. 静默重新登录(该请求不带旧 token,且不进入 401 拦截)
+      const res = await request({
+        url: '/api/auth/wechat/miniapp/login',
+        method: 'POST',
+        data: { jsCode: code, deviceType: 'MINIAPP' },
+        skipAuth: true
+      });
+
+      if (res.code === 200 && res.data) {
+        // 4. 保存新 token(覆盖本地缓存)
+        saveToken(res.data.accessToken, res.data.refreshToken);
+
+        // 5. 需要绑定时才走绑定流程
+        if (res.data.needBindPhone) {
+          await bindPhone(res.data.tempToken);
+        }
+      } else {
+        // 登录接口本身失败,才真正引导用户重登
+        redirectToLogin();
+      }
+    } catch (e) {
+      redirectToLogin();
+    } finally {
+      refreshing = null;
+    }
+  })();
+
+  await refreshing;
+  // 6. 用新 token 重试原请求
+  return retryRequest(error.config);
+}
+
+function wxLogin() {
+  return new Promise((resolve, reject) => {
+    wx.login({
+      success: (res) => res.code ? resolve(res.code) : reject(new Error('no code')),
+      fail: reject
+    });
+  });
+}
+```
+
+### 拦截器接入(示意)
+
+```js
+request.interceptors.response.use(
+  (response) => {
+    const res = response.data;
+    if (res.code === 401 || res.code === 1304) {
+      return handle401({ config: response.config });
+    }
+    return res;
+  },
+  (error) => {
+    if (error.response && error.response.status === 401) {
+      return handle401({ config: error.config });
+    }
+    return Promise.reject(error);
+  }
+);
+```
+
+---
+
+## 四、必须满足的要点
+
+1. **静默无感**:收到 401 时不弹窗、不跳登录页,直接执行 `wx.login → miniapp/login → 重试原请求`。
+2. **防并发**:多个请求同时 401 时,只触发一次重登(使用全局单例 Promise 锁,见上文 `refreshing`)。
+3. **兜底提示**:仅当 `miniapp/login` 本身失败(网络异常、code 过期等)时,才提示"登录已失效,请重新进入小程序",并清空本地 token 引导重登。
+4. **token 存储**:重新登录成功后,`accessToken` 与 `refreshToken` 需覆盖本地旧值;后续请求自动携带新 token。
+5. **公开接口豁免**:`miniapp/login` 请求本身必须标记为跳过鉴权(不携带旧 token、不进入 401 拦截),否则会死循环。
+
+---
+
+## 五、为什么用静默重登而非弹窗提醒
+
+- 小程序登录是无状态的(`wx.login` 拿 code 即可换 token),用户完全无感知,体验最好。
+- 弹窗提醒需要用户手动操作,容易造成用户流失。
+- 该方案为一次性:token 换新后,只要密钥不再变更,就不会再次触发。
+
+---
+
+## 六、验收标准
+
+1. 老用户(本地已缓存旧 token)打开小程序,页面正常渲染,无空白、无弹窗。
+2. 老用户刷新页面 / 重复进入,仍能正常使用,无需重新登录。
+3. 弱网或多接口并发场景下,不会出现重复登录请求。
+4. 服务端断网 / 登录接口异常时,有友好兜底提示,而非白屏。

+ 7 - 9
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRankController.java

@@ -15,23 +15,21 @@ public class UserQuizRankController {
 
     private final RankService rankService;
 
-    /** 获取排行榜 */
+    /** 获取排行榜(TOP10 + 本人排名),默认按本周统计,每周一00:00自动清零 */
     @GetMapping("/weekly")
     public Result<RankListVO> rankList(
-            @RequestParam(defaultValue = "all") String period,
-            @RequestParam(defaultValue = "totalCount") String rankType,
-            @RequestParam(defaultValue = "1") int pageNo,
-            @RequestParam(defaultValue = "20") int pageSize,
+            @RequestParam(defaultValue = "week") String period,
+            @RequestParam(defaultValue = "correctCount") String rankType,
             HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(rankService.getRankList(userId, period, rankType, pageNo, pageSize));
+        return Result.success(rankService.getRankList(userId, period, rankType));
     }
 
-    /** 获取我的排名 */
+    /** 获取我的排名,默认按本周统计,每周一00:00自动清零 */
     @GetMapping("/my")
     public Result<MyRankVO> myRank(
-            @RequestParam(defaultValue = "all") String period,
-            @RequestParam(defaultValue = "totalCount") String rankType,
+            @RequestParam(defaultValue = "week") String period,
+            @RequestParam(defaultValue = "correctCount") String rankType,
             HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
         return Result.success(rankService.getMyRank(userId, period, rankType));

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RankService.java

@@ -4,6 +4,6 @@ import com.xuekairui.quiz.vo.MyRankVO;
 import com.xuekairui.quiz.vo.RankListVO;
 
 public interface RankService {
-    RankListVO getRankList(Long userId, String period, String rankType, int pageNo, int pageSize);
+    RankListVO getRankList(Long userId, String period, String rankType);
     MyRankVO getMyRank(Long userId, String period, String rankType);
 }

+ 69 - 5
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizTrackServiceImpl.java

@@ -15,6 +15,7 @@ import com.xuekairui.quiz.mapper.QuizSubjectMapper;
 import com.xuekairui.quiz.mapper.QuizTrackLogMapper;
 import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
 import com.xuekairui.quiz.service.QuizTrackService;
+import com.xuekairui.quiz.util.QuizDedupUtils;
 import com.xuekairui.user.entity.User;
 import com.xuekairui.user.mapper.UserMapper;
 import lombok.RequiredArgsConstructor;
@@ -147,7 +148,19 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
             userItem.put("phone", phone);
             userItem.put("registerTime", user != null ? user.getCreateTime() : "");
             userItem.put("firstUseTime", records.stream().map(QuizUserRecord::getCreateTime).min(LocalDateTime::compareTo).orElse(null));
-            userItem.put("totalAnswerCount", records.stream().mapToInt(r -> r.getTotalCount() != null ? r.getTotalCount() : 0).sum());
+            // 按题目去重统计:同一道题只算一次,与排行榜口径一致
+            List<QuizUserRecord> sortedForDedup = records.stream()
+                    .sorted((a, b) -> {
+                        LocalDateTime ta = a.getCreateTime();
+                        LocalDateTime tb = b.getCreateTime();
+                        if (ta == null && tb == null) return 0;
+                        if (ta == null) return 1;
+                        if (tb == null) return -1;
+                        return tb.compareTo(ta);
+                    })
+                    .collect(Collectors.toList());
+            Map<Long, Integer> latestMap = QuizDedupUtils.extractLatestAnswerMap(sortedForDedup);
+            userItem.put("totalAnswerCount", latestMap.size());
             userItem.put("lastAnswerTime", records.stream().map(QuizUserRecord::getCreateTime).max(LocalDateTime::compareTo).orElse(null));
             userList.add(userItem);
         }
@@ -189,6 +202,24 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
 
         // 简单分页
         long total = userRecordMapper.selectCount(wrapper);
+
+        // 去重汇总统计(同一道题只算一次,与排行榜口径一致)
+        List<QuizUserRecord> allRecordsForSummary = userRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(subjectId != null, QuizUserRecord::getSubjectId, subjectId)
+                        .eq(answerType != null, QuizUserRecord::getAnswerType, answerType)
+                        .eq(completeStatus != null, QuizUserRecord::getStatus, completeStatus)
+                        .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                        .le(QuizUserRecord::getCreateTime, timeRange[1])
+                        .orderByDesc(QuizUserRecord::getCreateTime)
+        );
+        Map<Long, Integer> summaryLatestMap = QuizDedupUtils.extractLatestAnswerMap(allRecordsForSummary);
+        int summaryTotalCount = summaryLatestMap.size();
+        int summaryCorrectCount = 0;
+        for (Integer isCorrect : summaryLatestMap.values()) {
+            if (isCorrect != null && isCorrect == 1) summaryCorrectCount++;
+        }
+
         wrapper.last("LIMIT " + (pageNo - 1) * pageSize + "," + pageSize);
         List<QuizUserRecord> records = userRecordMapper.selectList(wrapper);
 
@@ -239,6 +270,14 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("total", total);
         result.put("list", list);
+        // 去重汇总统计(与排行榜口径一致)
+        Map<String, Object> summary = new LinkedHashMap<>();
+        summary.put("totalCount", summaryTotalCount);
+        summary.put("correctCount", summaryCorrectCount);
+        summary.put("wrongCount", summaryTotalCount - summaryCorrectCount);
+        summary.put("correctRate", summaryTotalCount > 0
+                ? Math.round(summaryCorrectCount * 10000.0 / summaryTotalCount) / 100.0 : 0);
+        result.put("summary", summary);
         return result;
     }
 
@@ -297,7 +336,11 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("questionId", qid);
-            item.put("stem", question.getStem() != null && question.getStem().length() > 50 ? question.getStem().substring(0, 50) + "..." : question.getStem());
+            item.put("stem", question.getStem());
+            item.put("options", question.getOptions());
+            item.put("answer", question.getAnswer());
+            item.put("analysis", question.getAnalysis());
+            item.put("questionType", question.getQuestionType());
             item.put("subjectName", subject != null ? subject.getName() : "");
             item.put("chapterName", chapter != null ? chapter.getName() : "");
             item.put("wrongTimes", wrongCount);
@@ -507,12 +550,12 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
         for (Map<String, Object> item : list) {
             List<Object> row = new ArrayList<>();
             row.add(item.get("userId"));
-            row.add(item.get("answerType"));
-            row.add(item.get("subjectId"));
+            row.add(item.get("answerTypeName"));
+            row.add(item.get("subjectName"));
             row.add(item.get("totalCount"));
             row.add(item.get("correctCount"));
             row.add(item.get("correctRate"));
-            row.add(item.get("status"));
+            row.add(item.get("statusName"));
             row.add(item.get("createTime"));
             data.add(row);
         }
@@ -523,6 +566,8 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
         List<List<String>> head = new ArrayList<>();
         head.add(Collections.singletonList("排名"));
         head.add(Collections.singletonList("题干"));
+        head.add(Collections.singletonList("选项"));
+        head.add(Collections.singletonList("正确答案"));
         head.add(Collections.singletonList("科目"));
         head.add(Collections.singletonList("考点"));
         head.add(Collections.singletonList("错误次数"));
@@ -531,12 +576,31 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
         return head;
     }
 
+    private String formatOptions(Object options) {
+        if (options == null) return "";
+        if (options instanceof List<?> list) {
+            StringBuilder sb = new StringBuilder();
+            for (Object o : list) {
+                if (o instanceof Map<?, ?> m) {
+                    String key = m.get("key") != null ? m.get("key").toString() : "";
+                    String content = m.get("content") != null ? m.get("content").toString() : "";
+                    if (sb.length() > 0) sb.append("\n");
+                    sb.append(key).append(". ").append(content);
+                }
+            }
+            return sb.toString();
+        }
+        return options.toString();
+    }
+
     private List<List<Object>> buildWrongData(List<Map<String, Object>> list) {
         List<List<Object>> data = new ArrayList<>();
         for (Map<String, Object> item : list) {
             List<Object> row = new ArrayList<>();
             row.add(item.get("rank"));
             row.add(item.get("stem"));
+            row.add(formatOptions(item.get("options")));
+            row.add(item.get("answer"));
             row.add(item.get("subjectName"));
             row.add(item.get("chapterName"));
             row.add(item.get("wrongTimes"));

+ 88 - 31
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RankServiceImpl.java

@@ -7,6 +7,8 @@ import com.xuekairui.quiz.service.RankService;
 import com.xuekairui.quiz.util.QuizDedupUtils;
 import com.xuekairui.quiz.vo.MyRankVO;
 import com.xuekairui.quiz.vo.RankListVO;
+import com.xuekairui.user.entity.User;
+import com.xuekairui.user.mapper.UserMapper;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 
@@ -28,14 +30,16 @@ import java.util.stream.Collectors;
 public class RankServiceImpl implements RankService {
 
     private final QuizUserRecordMapper userRecordMapper;
+    private final UserMapper userMapper;
 
     @Override
-    public RankListVO getRankList(Long userId, String period, String rankType, int pageNo, int pageSize) {
+    public RankListVO getRankList(Long userId, String period, String rankType) {
         LocalDateTime[] timeRange = getTimeRange(period);
 
         List<QuizUserRecord> records = userRecordMapper.selectList(
                 new LambdaQueryWrapper<QuizUserRecord>()
                         .eq(QuizUserRecord::getStatus, 1)
+                        .ne(QuizUserRecord::getAnswerType, 99)
                         .ge(QuizUserRecord::getCreateTime, timeRange[0])
                         .le(QuizUserRecord::getCreateTime, timeRange[1])
         );
@@ -47,23 +51,43 @@ public class RankServiceImpl implements RankService {
         List<UserStats> sorted = new ArrayList<>(statsMap.values());
         if ("correctRate".equals(rankType)) {
             sorted.sort((a, b) -> b.correctRate.compareTo(a.correctRate));
-        } else {
-            // 默认按做题总数排序
+        } else if ("totalCount".equals(rankType)) {
             sorted.sort((a, b) -> b.totalCount - a.totalCount);
+        } else {
+            // 默认按做对题数排序
+            sorted.sort((a, b) -> b.correctCount - a.correctCount);
         }
 
-        // 分页
+        // 固定取 TOP 10
         int total = sorted.size();
-        int fromIndex = Math.min((pageNo - 1) * pageSize, total);
-        int toIndex = Math.min(fromIndex + pageSize, total);
-        List<UserStats> pageList = sorted.subList(fromIndex, toIndex);
+        int topSize = Math.min(10, total);
+        List<UserStats> topList = sorted.subList(0, topSize);
 
         List<RankListVO.RankItemVO> list = new ArrayList<>();
-        int rank = fromIndex + 1;
-        for (UserStats stats : pageList) {
+        // 批量查询用户昵称
+        List<Long> userIds = topList.stream().map(s -> s.userId).collect(Collectors.toList());
+        Map<Long, String> nicknameMap = new LinkedHashMap<>();
+        if (!userIds.isEmpty()) {
+            List<User> users = userMapper.selectList(
+                    new LambdaQueryWrapper<User>()
+                            .in(User::getId, userIds)
+                            .select(User::getId, User::getNickname)
+            );
+            for (User u : users) {
+                nicknameMap.put(u.getId(), u.getNickname());
+            }
+        }
+        int rank = 1;
+        for (UserStats stats : topList) {
+            String nick = nicknameMap.get(stats.userId);
+            // 本人返回完整昵称,其他用户脱敏
+            if (!stats.userId.equals(userId)) {
+                nick = maskNickname(nick);
+            }
             list.add(RankListVO.RankItemVO.builder()
                     .rank(rank++)
                     .userId(stats.userId)
+                    .nickname(nick)
                     .totalCount(stats.totalCount)
                     .correctCount(stats.correctCount)
                     .correctRate(stats.correctRate)
@@ -79,27 +103,11 @@ public class RankServiceImpl implements RankService {
                 .build();
     }
 
-    @Override
-    public MyRankVO getMyRank(Long userId, String period, String rankType) {
-        LocalDateTime[] timeRange = getTimeRange(period);
-
-        List<QuizUserRecord> records = userRecordMapper.selectList(
-                new LambdaQueryWrapper<QuizUserRecord>()
-                        .eq(QuizUserRecord::getStatus, 1)
-                        .ge(QuizUserRecord::getCreateTime, timeRange[0])
-                        .le(QuizUserRecord::getCreateTime, timeRange[1])
-        );
-
-        Map<Long, UserStats> statsMap = aggregateStats(records);
-
-        // 排序并找到当前用户的排名
-        List<UserStats> sorted = new ArrayList<>(statsMap.values());
-        if ("correctRate".equals(rankType)) {
-            sorted.sort((a, b) -> b.correctRate.compareTo(a.correctRate));
-        } else {
-            sorted.sort((a, b) -> b.totalCount - a.totalCount);
-        }
-
+    /**
+     * 构建当前用户自身排名数据
+     */
+    private MyRankVO buildMyRank(Long userId, Map<Long, UserStats> statsMap,
+                                  List<UserStats> sorted, String period, String rankType) {
         UserStats myStats = statsMap.get(userId);
         int myRank = 0;
         for (int i = 0; i < sorted.size(); i++) {
@@ -111,6 +119,7 @@ public class RankServiceImpl implements RankService {
 
         MyRankVO.MyRankVOBuilder builder = MyRankVO.builder()
                 .userId(userId)
+                .nickname(getNickname(userId))
                 .rank(myRank)
                 .totalUsers(sorted.size())
                 .period(period)
@@ -130,6 +139,54 @@ public class RankServiceImpl implements RankService {
         return builder.build();
     }
 
+    @Override
+    public MyRankVO getMyRank(Long userId, String period, String rankType) {
+        LocalDateTime[] timeRange = getTimeRange(period);
+
+        List<QuizUserRecord> records = userRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getStatus, 1)
+                        .ne(QuizUserRecord::getAnswerType, 99)
+                        .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                        .le(QuizUserRecord::getCreateTime, timeRange[1])
+        );
+
+        Map<Long, UserStats> statsMap = aggregateStats(records);
+
+        // 排序
+        List<UserStats> sorted = new ArrayList<>(statsMap.values());
+        if ("correctRate".equals(rankType)) {
+            sorted.sort((a, b) -> b.correctRate.compareTo(a.correctRate));
+        } else if ("totalCount".equals(rankType)) {
+            sorted.sort((a, b) -> b.totalCount - a.totalCount);
+        } else {
+            sorted.sort((a, b) -> b.correctCount - a.correctCount);
+        }
+
+        return buildMyRank(userId, statsMap, sorted, period, rankType);
+    }
+
+    private String getNickname(Long userId) {
+        if (userId == null) return null;
+        User user = userMapper.selectById(userId);
+        return user != null ? user.getNickname() : null;
+    }
+
+    /**
+     * 昵称脱敏:保留首字,其余用 * 替换
+     * 例:张三 → 张*,张三丰 → 张**,John → J***
+     */
+    private String maskNickname(String nickname) {
+        if (nickname == null || nickname.isEmpty()) return nickname;
+        if (nickname.length() == 1) return "*";
+        StringBuilder sb = new StringBuilder();
+        sb.append(nickname.charAt(0));
+        for (int i = 1; i < nickname.length(); i++) {
+            sb.append("*");
+        }
+        return sb.toString();
+    }
+
     private Map<Long, UserStats> aggregateStats(List<QuizUserRecord> records) {
         Map<Long, List<QuizUserRecord>> grouped = records.stream()
                 .collect(Collectors.groupingBy(QuizUserRecord::getUserId));
@@ -175,7 +232,7 @@ public class RankServiceImpl implements RankService {
 
     private LocalDateTime[] getTimeRange(String period) {
         LocalDate today = LocalDate.now();
-        return switch (period != null ? period : "all") {
+        return switch (period != null ? period : "week") {
             case "week" -> new LocalDateTime[]{
                     today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(),
                     today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(LocalTime.MAX)

+ 33 - 3
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RealExamServiceImpl.java

@@ -7,9 +7,11 @@ import com.xuekairui.quiz.dto.AnswerItem;
 import com.xuekairui.quiz.entity.QuizQuestion;
 import com.xuekairui.quiz.entity.QuizSubject;
 import com.xuekairui.quiz.entity.QuizUserExamRecord;
+import com.xuekairui.quiz.entity.QuizUserRecord;
 import com.xuekairui.quiz.mapper.QuizQuestionMapper;
 import com.xuekairui.quiz.mapper.QuizSubjectMapper;
 import com.xuekairui.quiz.mapper.QuizUserExamRecordMapper;
+import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
 import com.xuekairui.quiz.service.RealExamService;
 import com.xuekairui.quiz.vo.ExamDetailVO;
 import com.xuekairui.quiz.vo.ExamRecordVO;
@@ -40,6 +42,7 @@ public class RealExamServiceImpl implements RealExamService {
     private final QuizQuestionMapper questionMapper;
     private final QuizSubjectMapper subjectMapper;
     private final QuizUserExamRecordMapper examRecordMapper;
+    private final QuizUserRecordMapper userRecordMapper;
 
     @Override
     public List<RealExamYearGroupVO> list(Long userId, Long subjectId) {
@@ -202,7 +205,7 @@ public class RealExamServiceImpl implements RealExamService {
                         .orderByAsc(QuizQuestion::getId)
         );
 
-        List<RealExamQuestionVO> questionList = buildQuestionList(questions);
+        List<RealExamQuestionVO> questionList = buildQuestionList(questions, record.getUserId());
 
         return StartExamVO.builder()
                 .recordId(record.getId())
@@ -220,7 +223,9 @@ public class RealExamServiceImpl implements RealExamService {
      * - 配伍选择题(3):id + stem + matchGroup + sharedOptions(无独立 options)
      * - 综合分析选择题(4):id + stem + matchGroup + sharedOptions(共用题干)+ 各自的 options
      */
-    private List<RealExamQuestionVO> buildQuestionList(List<QuizQuestion> questions) {
+    private List<RealExamQuestionVO> buildQuestionList(List<QuizQuestion> questions, Long userId) {
+        // 批量查询用户收藏的题目
+        Map<Long, LocalDateTime> collectedMap = getCollectedQuestionMap(userId);
         List<RealExamQuestionVO> result = new ArrayList<>();
         for (QuizQuestion q : questions) {
             Integer type = q.getQuestionType();
@@ -232,7 +237,9 @@ public class RealExamServiceImpl implements RealExamService {
                     .id(q.getId())
                     .questionType(type)
                     .questionTypeName(getTypeName(type))
-                    .stem(q.getStem());
+                    .stem(q.getStem())
+                    .isCollected(collectedMap.containsKey(q.getId()))
+                    .collectedAt(collectedMap.get(q.getId()));
 
             if (grouped) {
                 builder.matchGroup(group).sharedOptions(q.getSharedOptions());
@@ -427,4 +434,27 @@ public class RealExamServiceImpl implements RealExamService {
             default -> "";
         };
     }
+
+    /**
+     * 批量查询用户收藏的题目ID及收藏时间
+     */
+    private Map<Long, LocalDateTime> getCollectedQuestionMap(Long userId) {
+        if (userId == null) return new LinkedHashMap<>();
+        List<QuizUserRecord> collectRecords = userRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .eq(QuizUserRecord::getAnswerType, 99)
+        );
+        Map<Long, LocalDateTime> map = new LinkedHashMap<>();
+        for (QuizUserRecord r : collectRecords) {
+            if (r.getAnswers() != null) {
+                for (Map<String, Object> ans : r.getAnswers()) {
+                    if (ans.get("questionId") != null) {
+                        map.put(Long.valueOf(ans.get("questionId").toString()), r.getCreateTime());
+                    }
+                }
+            }
+        }
+        return map;
+    }
 }

+ 4 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/StudyBookServiceImpl.java

@@ -470,6 +470,7 @@ public class StudyBookServiceImpl implements StudyBookService {
                         QuizQuestion q = questionMapper.selectById(questionId);
                         if (q != null) {
                             StudyBookQuestionVO item = buildQuestionItem(q);
+                            item.setIsCollected(true);
                             item.setCollectedAt(r.getCreateTime());
                             questionList.add(item);
                         }
@@ -536,7 +537,9 @@ public class StudyBookServiceImpl implements StudyBookService {
                 QuizQuestion q = questionMapper.selectById(qId);
                 if (q != null) {
                     StudyBookQuestionVO item = buildQuestionItem(q);
-                    if (collectedQuestionIds.contains(qId)) {
+                    boolean collected = collectedQuestionIds.contains(qId);
+                    item.setIsCollected(collected);
+                    if (collected) {
                         item.setCollectedAt(collectedTimeMap.get(qId));
                     }
                     questionList.add(item);

+ 16 - 9
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/UserQuestionServiceImpl.java

@@ -106,8 +106,8 @@ public class UserQuestionServiceImpl implements UserQuestionService {
      * - 综合分析选择题(4):id + stem + matchGroup + sharedOptions(共用题干)+ 各自的 options
      */
     private List<QuestionVO> buildChapterQuestionList(List<QuizQuestion> questions, Long userId) {
-        // 批量查询用户收藏的题目ID(1次查询代替N次)
-        Set<Long> collectedIds = getCollectedQuestionIds(userId);
+        // 批量查询用户收藏的题目ID及收藏时间(1次查询代替N次)
+        Map<Long, LocalDateTime> collectedMap = getCollectedQuestionMap(userId);
         List<QuestionVO> result = new ArrayList<>();
         for (QuizQuestion q : questions) {
             Integer type = q.getQuestionType();
@@ -120,7 +120,8 @@ public class UserQuestionServiceImpl implements UserQuestionService {
                     .questionType(type)
                     .questionTypeName(getTypeName(type))
                     .stem(q.getStem())
-                    .isCollected(collectedIds.contains(q.getId()))
+                    .isCollected(collectedMap.containsKey(q.getId()))
+                    .collectedAt(collectedMap.get(q.getId()))
                     .userRecord(getUserRecord(userId, q.getId()));
 
             if (grouped) {
@@ -187,6 +188,7 @@ public class UserQuestionServiceImpl implements UserQuestionService {
                 .correctAnswer(correctAnswer)
                 .analysis(question.getAnalysis())
                 .isCollected(isCollected(userId, questionId))
+                .collectedAt(getCollectedQuestionMap(userId).get(questionId))
                 .build();
     }
 
@@ -266,6 +268,7 @@ public class UserQuestionServiceImpl implements UserQuestionService {
                 .matchingGroup(question.getMatchingGroup())
                 .sharedOptions(question.getSharedOptions())
                 .isCollected(isCollected(userId, questionId))
+                .collectedAt(getCollectedQuestionMap(userId).get(questionId))
                 .userRecord(getUserRecord(userId, questionId))
                 .build();
     }
@@ -276,26 +279,30 @@ public class UserQuestionServiceImpl implements UserQuestionService {
     }
 
     /**
-     * 批量查询用户收藏的题目ID(避免JSON_CONTAINS对大数字的类型匹配问题)
+     * 批量查询用户收藏的题目ID及收藏时间(避免JSON_CONTAINS对大数字的类型匹配问题)
      */
-    private Set<Long> getCollectedQuestionIds(Long userId) {
-        if (userId == null) return new LinkedHashSet<>();
+    private Map<Long, LocalDateTime> getCollectedQuestionMap(Long userId) {
+        if (userId == null) return new LinkedHashMap<>();
         List<QuizUserRecord> collectRecords = userRecordMapper.selectList(
                 new LambdaQueryWrapper<QuizUserRecord>()
                         .eq(QuizUserRecord::getUserId, userId)
                         .eq(QuizUserRecord::getAnswerType, 99)
         );
-        Set<Long> ids = new LinkedHashSet<>();
+        Map<Long, LocalDateTime> map = new LinkedHashMap<>();
         for (QuizUserRecord r : collectRecords) {
             if (r.getAnswers() != null) {
                 for (Map<String, Object> ans : r.getAnswers()) {
                     if (ans.get("questionId") != null) {
-                        ids.add(Long.valueOf(ans.get("questionId").toString()));
+                        map.put(Long.valueOf(ans.get("questionId").toString()), r.getCreateTime());
                     }
                 }
             }
         }
-        return ids;
+        return map;
+    }
+
+    private Set<Long> getCollectedQuestionIds(Long userId) {
+        return getCollectedQuestionMap(userId).keySet();
     }
 
     private UserRecordVO getUserRecord(Long userId, Long questionId) {

+ 1 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/MyRankVO.java

@@ -18,6 +18,7 @@ import java.math.BigDecimal;
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class MyRankVO {
     private Long userId;
+    private String nickname;
     private Integer rank;
     private Integer totalUsers;
     private Integer totalCount;

+ 2 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/QuestionDetailVO.java

@@ -6,6 +6,7 @@ import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 
+import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
 
@@ -27,5 +28,6 @@ public class QuestionDetailVO {
     private String matchingGroup;
     private String sharedOptions;
     private Boolean isCollected;
+    private LocalDateTime collectedAt;
     private UserRecordVO userRecord;
 }

+ 2 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/QuestionVO.java

@@ -6,6 +6,7 @@ import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 
+import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
 
@@ -26,5 +27,6 @@ public class QuestionVO {
     private String stem;
     private List<Map<String, Object>> options;
     private Boolean isCollected;
+    private LocalDateTime collectedAt;
     private UserRecordVO userRecord;
 }

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RankListVO.java

@@ -27,10 +27,10 @@ public class RankListVO {
     @Builder
     @NoArgsConstructor
     @AllArgsConstructor
-    @JsonInclude(JsonInclude.Include.NON_NULL)
     public static class RankItemVO {
         private Integer rank;
         private Long userId;
+        private String nickname;
         private Integer totalCount;
         private Integer correctCount;
         private BigDecimal correctRate;

+ 3 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamQuestionVO.java

@@ -6,6 +6,7 @@ import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 
+import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
 
@@ -25,4 +26,6 @@ public class RealExamQuestionVO {
     private String sharedOptions;
     private String stem;
     private List<Map<String, Object>> options;
+    private Boolean isCollected;
+    private LocalDateTime collectedAt;
 }

+ 1 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyBookQuestionVO.java

@@ -30,5 +30,6 @@ public class StudyBookQuestionVO {
     private List<Map<String, Object>> options;
     private String answer;
     private String analysis;
+    private Boolean isCollected;
     private LocalDateTime collectedAt;
 }

+ 3 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubmitAnswerVO.java

@@ -6,6 +6,8 @@ import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 
+import java.time.LocalDateTime;
+
 /**
  * 提交单题答案结果
  */
@@ -19,4 +21,5 @@ public class SubmitAnswerVO {
     private String correctAnswer;
     private String analysis;
     private Boolean isCollected;
+    private LocalDateTime collectedAt;
 }

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

@@ -75,7 +75,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
         boolean isHeartbeat = "/api/auth/heartbeat".equals(path);
 
         // 打印所有请求的URL(含无JWT的请求)
-        log.info("[JWT解析] uri={}", path);
+        log.info("[JWT解析] uri={}, hasToken={}", path, token != null && !token.isEmpty());
         // 尽早从 token 中提取 userId 注入 MDC(宽容模式,过期 token 也能提取)
         Claims claims1 = extractUidForMdcAndSetRequestAttr(token, request);
 
@@ -98,6 +98,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
                     String loginSource = claims.get("loginSource", String.class);
                     boolean isAdmin = "ADMIN".equals(role) || "SUPER_ADMIN".equals(role);
 
+                    log.info("[JWT认证] userId={}, tokenType={}, tokenVer={}, role={}, loginSource={}", userId, tokenType, tokenVer, role, loginSource);
+
                     // userId 和 loginSource 已经在 extractUidForMdcAndSetRequestAttr 中设置过了
                     if ("access".equals(tokenType)) {
                         if (!isTokenVersionValid(userId, loginSource, tokenVer, role)) {

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

@@ -39,10 +39,84 @@ public class JwtUtil {
     private static final String ROLE_ADMIN = "ADMIN";
     private static final String ROLE_SUPER_ADMIN = "SUPER_ADMIN";
 
+    @Value("${jwt.fallback-secret:YourSuperSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong!!}")
+    private String fallbackSecret;
+
+    @Value("${jwt.fallback-secret2:bR9!vD2&mP5#zT7_sG3+nF8=Xj[0hL]aC|1dQ;eW,.yB{kN}}")
+    private String fallbackSecret2;
+
     private SecretKey getSigningKey() {
         return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
     }
 
+    private SecretKey getFallbackSigningKey() {
+        return Keys.hmacShaKeyFor(fallbackSecret.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private SecretKey getFallbackSigningKey2() {
+        return Keys.hmacShaKeyFor(fallbackSecret2.getBytes(StandardCharsets.UTF_8));
+    }
+
+    /**
+     * 尝试用主密钥和备用密钥验证 token
+     */
+    private Claims parseWithFallback(String token, boolean lenient) {
+        // 打印token头部和payload信息(不验证签名)
+        try {
+            String[] parts = token.split("\\.");
+            if (parts.length >= 3) {
+                String headerJson = new String(java.util.Base64.getUrlDecoder().decode(parts[0]));
+                String payloadJson = new String(java.util.Base64.getUrlDecoder().decode(parts[1]));
+                log.info("[JWT调试] token={}, header={}, payload={}", token, headerJson, payloadJson);
+            }
+        } catch (Exception ignored) {}
+        // 先用主密钥
+        try {
+            return Jwts.parser()
+                    .verifyWith(getSigningKey())
+                    .build()
+                    .parseSignedClaims(token)
+                    .getPayload();
+        } catch (ExpiredJwtException e) {
+            if (lenient) return e.getClaims();
+            throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
+        } catch (JwtException e) {
+            // 主密钥失败,尝试备用密钥1
+            try {
+                return Jwts.parser()
+                        .verifyWith(getFallbackSigningKey())
+                        .build()
+                        .parseSignedClaims(token)
+                        .getPayload();
+            } catch (ExpiredJwtException e2) {
+                if (lenient) return e2.getClaims();
+                throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
+            } catch (JwtException e2) {
+                // 备用密钥1失败,尝试备用密钥2
+                try {
+                    return Jwts.parser()
+                            .verifyWith(getFallbackSigningKey2())
+                            .build()
+                            .parseSignedClaims(token)
+                            .getPayload();
+                } catch (ExpiredJwtException e3) {
+                    if (lenient) return e3.getClaims();
+                    throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
+                } catch (JwtException e3) {
+                    log.warn("JWT解析失败(所有密钥均失败), 原因: {}", e3.getMessage());
+                    log.warn("[密钥长度] 主={}, 备用1={}, 备用2={}, 主hash={}, 备用1hash={}, 备用2hash={}",
+                            secret == null ? -1 : secret.getBytes(StandardCharsets.UTF_8).length,
+                            fallbackSecret == null ? -1 : fallbackSecret.getBytes(StandardCharsets.UTF_8).length,
+                            fallbackSecret2 == null ? -1 : fallbackSecret2.getBytes(StandardCharsets.UTF_8).length,
+                            hashToken(secret == null ? "" : secret),
+                            hashToken(fallbackSecret == null ? "" : fallbackSecret),
+                            hashToken(fallbackSecret2 == null ? "" : fallbackSecret2));
+                    throw new BusinessException(ErrorCode.TOKEN_INVALID);
+                }
+            }
+        }
+    }
+
     private boolean isAdminRole(String role) {
         return ROLE_ADMIN.equals(role) || ROLE_SUPER_ADMIN.equals(role);
     }
@@ -123,18 +197,7 @@ public class JwtUtil {
      * 解析令牌(严格模式:过期即抛异常)
      */
     public Claims parseToken(String token) {
-        try {
-            return Jwts.parser()
-                    .verifyWith(getSigningKey())
-                    .build()
-                    .parseSignedClaims(token)
-                    .getPayload();
-        } catch (ExpiredJwtException e) {
-            throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
-        } catch (JwtException e) {
-            log.warn("JWT解析Token失败, 原因: {}", e.getMessage());
-            throw new BusinessException(ErrorCode.TOKEN_INVALID);
-        }
+        return parseWithFallback(token, false);
     }
 
     /**
@@ -143,19 +206,7 @@ public class JwtUtil {
      * 仅在校验签名失败等严重错误时抛异常,过期不在此列。
      */
     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解析TokenLenient失败, 原因: {}", e.getMessage());
-            throw new BusinessException(ErrorCode.TOKEN_INVALID);
-        }
+        return parseWithFallback(token, true);
     }
 
     /**