Quellcode durchsuchen

docs: 五维自检15天复检周期实现计划

iwt vor 2 Wochen
Ursprung
Commit
a027dec43e
1 geänderte Dateien mit 1160 neuen und 0 gelöschten Zeilen
  1. 1160 0
      docs/superpowers/plans/2026-08-31-self-check-reminder.md

+ 1160 - 0
docs/superpowers/plans/2026-08-31-self-check-reminder.md

@@ -0,0 +1,1160 @@
+# 五维家庭自检 · 15 天复检周期 + 首页分数 + 题目轮换 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 为五维家庭自检增加 15 天复检周期、首页显示上次自检分数、中间页二选一(再次自检/忽略)、以及合并题库随机抽题轮换。
+
+**架构:** 后端新增 `self_check_ignores` 表 + 两个新接口(`/status` + `/ignore`);改造现有 `/questions`(合并题库抽题)和 `/submit`(按提交题号计分 + 落库题号);前端新增中间页 `self-check-entry.vue`,首页入口接入 `/status` 并跳转中间页。
+
+**技术栈:** Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / uni-app Vue 2 Options API / MySQL 8.0
+
+**规格文档:** `docs/superpowers/specs/2026-08-31-self-check-reminder-design.md`
+
+---
+
+## 文件结构
+
+| 文件 | 职责 | 变更类型 |
+|------|------|---------|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java` | 忽略记录实体 | 新建 |
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java` | 忽略记录 Mapper | 新建 |
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/FiveDimensionSelfCheck.java` | 自检记录实体(新增 `questionIdsJson`) | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java` | 抽题/状态/忽略/计分改造 | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java` | 新增 `/status` + `/ignore` | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 迁移 269/270 | 修改 |
+| `cfc-backend/src/main/resources/schema.sql` | 表定义同步 | 修改 |
+| `cfc-frontend/pages/family/self-check-entry.vue` | 中间页(结果摘要 + 再次自检/忽略) | 新建 |
+| `cfc-frontend/pages/index-home/index.vue` | 首页入口显示自检总分 | 修改 |
+| `cfc-frontend/pages/home-pages/parent-index.vue` | 家长首页入口显示自检总分 | 修改 |
+| `cfc-frontend/pages/family/self-check-result.vue` | 「重新自检」改跳中间页 | 修改 |
+| `cfc-frontend/pages.json` | 注册中间页 | 修改 |
+| `cfc-frontend/utils/api.js` | 新增 `getSelfCheckStatus` / `ignoreSelfCheck` | 修改 |
+
+---
+
+## 任务 1:后端 — 数据库迁移(表 + 列)
+
+**文件:**
+- 新建:`cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java`
+- 新建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java:9574`(`migrateCouponStatus` 方法后追加迁移 269/270)
+- 修改:`cfc-backend/src/main/resources/schema.sql`(`five_dimension_self_checks` 表 + 追加 `self_check_ignores` 表)
+
+- [ ] **步骤 1:创建 SelfCheckIgnore 实体**
+
+创建文件 `cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java`:
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("self_check_ignores")
+public class SelfCheckIgnore implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long checkId;
+
+    private Date createdAt;
+}
+```
+
+- [ ] **步骤 2:创建 SelfCheckIgnoreMapper**
+
+创建文件 `cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java`:
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SelfCheckIgnore;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SelfCheckIgnoreMapper extends BaseMapper<SelfCheckIgnore> {
+}
+```
+
+- [ ] **步骤 3:在 DatabaseInitializer 追加迁移 269/270**
+
+在 `DatabaseInitializer.java` 中,于 `migrateCouponStatus()` 方法结束后、`migrateSysMenuReorg()` 方法开始前,插入:
+
+```java
+// 迁移269: five_dimension_self_checks 表添加 question_ids_json 列(自检轮换用)
+ensureColumn("five_dimension_self_checks", "question_ids_json", "VARCHAR(255) COMMENT '本次自检使用的题号JSON: [1,5,9,...]'");
+
+// 迁移270: 创建 self_check_ignores 表(记录用户忽略自检提醒的动作,作为 15 天周期重置依据)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS self_check_ignores (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+            "check_id BIGINT COMMENT '忽略的自检记录ID(可空)', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间', " +
+            "INDEX idx_user_id (user_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录'");
+    log.info("已创建 self_check_ignores 表");
+} catch (Exception e) {
+    log.warn("创建 self_check_ignores 表失败: {}", e.getMessage());
+}
+```
+
+`ensureColumn` 方法已在 DatabaseInitializer 第 4099 行定义,直接复用。
+
+- [ ] **步骤 4:同步 schema.sql**
+
+在 `cfc-backend/src/main/resources/schema.sql` 中找到 `five_dimension_self_checks` 建表语句(约第 4285 行),在 `created_at DATETIME DEFAULT CURRENT_TIMESTAMP` 之后追加:
+
+```sql
+    question_ids_json VARCHAR(255) DEFAULT NULL COMMENT '本次自检使用的题号JSON: [1,5,9,...]',
+```
+
+在 schema.sql 末尾追加 `self_check_ignores` 建表语句:
+
+```sql
+-- ============================================================
+-- 五维自检忽略提醒记录表(迁移270)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS self_check_ignores (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    check_id BIGINT COMMENT '忽略的自检记录ID(可空)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间',
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录';
+```
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java \
+       cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java \
+       cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+       cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(self-check): 新增 self_check_ignores 表和 question_ids_json 列(迁移269/270)"
+```
+
+---
+
+## 任务 2:后端 — Entity 与 Service 改造
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/FiveDimensionSelfCheck.java`(新增字段)
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java`(抽题/状态/忽略/计分改造)
+
+- [ ] **步骤 1:FiveDimensionSelfCheck 实体新增字段**
+
+在 `FiveDimensionSelfCheck.java` 的 `private Date createdAt;` 之前追加:
+
+```java
+    /** 本次自检使用的题号JSON,如 [1,2,3,101] */
+    private String questionIdsJson;
+```
+
+- [ ] **步骤 2:Service 新增私有字段 — 合并题库(34题)**
+
+在 `FiveDimensionSelfCheckService.java` 中,于 `SUB_DIMENSION_QUESTION_BANK` 字段之后,追加:
+
+```java
+    /** 合并题库:主题库(15题) + 子维度附加库(19题),用于轮换抽题 */
+    private static final List<SelfCheckQuestionVO> FULL_QUESTION_POOL = buildFullPool();
+
+    @SuppressWarnings("unchecked")
+    private static List<SelfCheckQuestionVO> buildFullPool() {
+        List<SelfCheckQuestionVO> pool = new ArrayList<>(QUESTION_BANK);
+        pool.addAll(SUB_DIMENSION_QUESTION_BANK);
+        return pool;
+    }
+
+    /** 按 dimension 分组的合并题库 Map<dimension, List<SelfCheckQuestionVO>> */
+    private Map<String, List<SelfCheckQuestionVO>> groupByDimension(List<SelfCheckQuestionVO> pool) {
+        Map<String, List<SelfCheckQuestionVO>> groups = new LinkedHashMap<>();
+        for (SelfCheckQuestionVO q : pool) {
+            String dim = q.getDimension();
+            if (dim == null) continue;
+            groups.computeIfAbsent(dim, k -> new ArrayList<>()).add(q);
+        }
+        return groups;
+    }
+
+    /** 随机抽题:从 pool 中抽 count 题,优先排除 excludeIds */
+    private List<SelfCheckQuestionVO> pickQuestions(List<SelfCheckQuestionVO> pool, List<Integer> excludeIds, int count) {
+        List<Integer> excludeSet = new ArrayList<>(excludeIds != null ? excludeIds : Collections.emptyList());
+        List<SelfCheckQuestionVO> candidates = new ArrayList<>();
+        for (SelfCheckQuestionVO q : pool) {
+            if (!excludeSet.contains(q.getId())) {
+                candidates.add(q);
+            }
+        }
+        // 洗牌
+        Collections.shuffle(candidates);
+        List<SelfCheckQuestionVO> result = new ArrayList<>();
+        int need = Math.min(count, candidates.size());
+        for (int i = 0; i < need; i++) {
+            result.add(candidates.get(i));
+        }
+        // 不足时从 excludeSet 补足(保持顺序)
+        if (result.size() < count) {
+            for (SelfCheckQuestionVO q : pool) {
+                if (result.size() >= count) break;
+                if (!excludeSet.contains(q.getId())) continue;
+                boolean alreadyIn = false;
+                for (SelfCheckQuestionVO r : result) {
+                    if (r.getId().equals(q.getId())) { alreadyIn = true; break; }
+                }
+                if (!alreadyIn) result.add(q);
+            }
+        }
+        return result;
+    }
+```
+
+同时在文件头部导入 `Collections`:
+
+```java
+import java.util.Collections;
+```
+
+- [ ] **步骤 3:Service 新增 `getQuestionsForRetake` 方法**
+
+在 `getQuestions()` 方法后追加:
+
+```java
+    /**
+     * 返回轮换后的题库(用于再次自检):
+     * 合并题库按维度分组,每维度随机抽 3 题,优先排除上次使用的题号。
+     * body 维度仅 3 题,若排除后不足 3 题则允许复用。
+     */
+    public List<SelfCheckQuestionVO> getQuestionsForRetake(Long userId) {
+        // 查最近一次自检的题号
+        LambdaQueryWrapper<FiveDimensionSelfCheck> wrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                .eq(FiveDimensionSelfCheck::getUserId, userId)
+                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                .last("LIMIT 1");
+        SortUtil.applySort(wrapper);
+        FiveDimensionSelfCheck lastRecord = selfCheckMapper.selectOne(wrapper);
+        List<Integer> lastQuestionIds = parseQuestionIds(lastRecord != null ? lastRecord.getQuestionIdsJson() : null);
+
+        // 按维度分组抽题
+        Map<String, List<SelfCheckQuestionVO>> dimGroups = groupByDimension(FULL_QUESTION_POOL);
+        String[] dimOrder = {"body", "wisdom", "wealth", "action", "mind"};
+        List<SelfCheckQuestionVO> result = new ArrayList<>();
+        for (String dim : dimOrder) {
+            List<SelfCheckQuestionVO> pool = dimGroups.getOrDefault(dim, Collections.emptyList());
+            result.addAll(pickQuestions(pool, lastQuestionIds, 3));
+        }
+        return result;
+    }
+
+    private List<Integer> parseQuestionIds(String json) {
+        if (json == null || json.isEmpty()) return Collections.emptyList();
+        try {
+            return objectMapper.readValue(json, objectMapper.getTypeFactory().constructCollectionType(List.class, Integer.class));
+        } catch (Exception e) {
+            log.warn("解析 questionIdsJson 失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+```
+
+- [ ] **步骤 4:Service 改造 `submitSelfCheck` — 按提交题号计分 + 落库题号**
+
+将 `submitSelfCheck` 方法中第 348-380 行的计分逻辑替换为:
+
+```java
+        // 构建 题号→题目 映射(从合并池)
+        Map<Integer, SelfCheckQuestionVO> questionMap = new LinkedHashMap<>();
+        for (SelfCheckQuestionVO q : FULL_QUESTION_POOL) {
+            questionMap.put(q.getId(), q);
+        }
+        // 题目ID → 答案
+        Map<Integer, String> answerMap = new LinkedHashMap<>();
+        for (SubmitSelfCheckDTO.AnswerItem item : dto.getAnswers()) {
+            if (item.getQuestionId() == null || item.getAnswer() == null || item.getAnswer().isEmpty()) {
+                continue;
+            }
+            answerMap.put(item.getQuestionId(), item.getAnswer().trim().toUpperCase());
+        }
+
+        // 按维度分组计分
+        Map<String, List<SelfCheckQuestionVO>> dimGroups = groupByDimension(FULL_QUESTION_POOL);
+        List<SelfCheckResultVO.DimensionScoreVO> dimensions = new ArrayList<>();
+        int totalScore = 0;
+        List<Integer> submittedQuestionIds = new ArrayList<>();
+        for (Map.Entry<String, String[]> entry : DIMENSION_META.entrySet()) {
+            String dim = entry.getKey();
+            List<SelfCheckQuestionVO> dimQuestions = dimGroups.getOrDefault(dim, Collections.emptyList());
+            int sum = 0;
+            int answered = 0;
+            for (SelfCheckQuestionVO qv : dimQuestions) {
+                String ans = answerMap.get(qv.getId());
+                if (ans == null || "E".equals(ans)) continue;
+                submittedQuestionIds.add(qv.getId());
+                sum += scoreOf(ans);
+                answered++;
+            }
+            if (answered == 0) {
+                throw new IllegalArgumentException("维度[" + dim + "]至少回答1题");
+            }
+            int dimScore = new BigDecimal(sum * 3.0 / answered).setScale(0, RoundingMode.HALF_UP).intValue();
+            if (dimScore > 9) dimScore = 9;
+            totalScore += dimScore;
+            dimensions.add(buildDimensionScoreVO(dim, dimScore));
+        }
+```
+
+并在落库部分(`selfCheckMapper.insert(record)` 之前)追加:
+
+```java
+        record.setQuestionIdsJson(toJson(submittedQuestionIds));
+```
+
+同时确保 `submittedQuestionIds` 升序排列:
+
+```java
+        Collections.sort(submittedQuestionIds);
+```
+
+- [ ] **步骤 5:Service 新增 `getStatus` 方法**
+
+```java
+    /**
+     * 返回自检状态:是否有记录、是否可检、距下次提醒天数、上次题号
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> getStatus(Long userId) {
+        Map<String, Object> result = new HashMap<>();
+        // 最近一次自检
+        LambdaQueryWrapper<FiveDimensionSelfCheck> checkWrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                .eq(FiveDimensionSelfCheck::getUserId, userId)
+                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                .last("LIMIT 1");
+        SortUtil.applySort(checkWrapper);
+        FiveDimensionSelfCheck lastCheck = selfCheckMapper.selectOne(checkWrapper);
+        result.put("hasCheck", lastCheck != null);
+        result.put("lastResult", lastCheck != null ? toVO(lastCheck) : null);
+        result.put("lastQuestionIds", parseQuestionIds(lastCheck != null ? lastCheck.getQuestionIdsJson() : null));
+
+        // 最近一次忽略
+        LambdaQueryWrapper<SelfCheckIgnore> ignoreWrapper = new LambdaQueryWrapper<SelfCheckIgnore>()
+                .eq(SelfCheckIgnore::getUserId, userId)
+                .orderByDesc(SelfCheckIgnore::getCreatedAt)
+                .last("LIMIT 1");
+        SelfCheckIgnore lastIgnore = ignoreMapper.selectOne(ignoreWrapper);
+
+        // 最近决定性时间
+        Date lastDecisiveTime = null;
+        if (lastCheck != null) lastDecisiveTime = lastCheck.getCreatedAt();
+        if (lastIgnore != null && (lastDecisiveTime == null || lastIgnore.getCreatedAt().after(lastDecisiveTime))) {
+            lastDecisiveTime = lastIgnore.getCreatedAt();
+        }
+
+        if (lastDecisiveTime == null) {
+            // 从未自检/忽略
+            result.put("canCheck", true);
+            result.put("daysLeft", 0);
+        } else {
+            long diffDays = (System.currentTimeMillis() - lastDecisiveTime.getTime()) / (1000L * 60 * 60 * 24);
+            boolean canCheck = diffDays >= 15;
+            int daysLeft = canCheck ? 0 : (int) (15 - diffDays);
+            result.put("canCheck", canCheck);
+            result.put("daysLeft", daysLeft);
+        }
+        return result;
+    }
+```
+
+需要新增 `ignoreMapper` 注入:
+
+```java
+    @Resource
+    private SelfCheckIgnoreMapper ignoreMapper;
+```
+
+- [ ] **步骤 6:Service 新增 `ignore` 方法**
+
+```java
+    /**
+     * 记录忽略动作(幂等:15 天内重复忽略不插入)
+     */
+    public Date ignoreSelfCheck(Long userId, Long checkId) {
+        // 查最近一次忽略(15 天内)
+        LambdaQueryWrapper<SelfCheckIgnore> wrapper = new LambdaQueryWrapper<SelfCheckIgnore>()
+                .eq(SelfCheckIgnore::getUserId, userId)
+                .orderByDesc(SelfCheckIgnore::getCreatedAt)
+                .last("LIMIT 1");
+        SelfCheckIgnore lastIgnore = ignoreMapper.selectOne(wrapper);
+        if (lastIgnore != null) {
+            long diffDays = (System.currentTimeMillis() - lastIgnore.getCreatedAt().getTime()) / (1000L * 60 * 60 * 24);
+            if (diffDays < 15) {
+                return lastIgnore.getCreatedAt(); // 幂等,返回已有忽略时间
+            }
+        }
+        SelfCheckIgnore ignore = new SelfCheckIgnore();
+        ignore.setUserId(userId);
+        ignore.setCheckId(checkId);
+        ignore.setCreatedAt(new Date());
+        ignoreMapper.insert(ignore);
+        return ignore.getCreatedAt();
+    }
+```
+
+- [ ] **步骤 7:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 8:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/FiveDimensionSelfCheck.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java
+git commit -m "feat(self-check): Service 层抽题/状态/忽略/计分改造"
+```
+
+---
+
+## 任务 3:后端 — Controller 新增接口
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java`
+
+- [ ] **步骤 1:注入 ignoreMapper**
+
+在 `FiveDimensionSelfCheckController.java` 中追加字段:
+
+```java
+    @Resource
+    private com.etotem.cfc.mapper.SelfCheckIgnoreMapper selfCheckIgnoreMapper;
+```
+
+- [ ] **步骤 2:新增 `/status` 接口**
+
+在类中追加方法:
+
+```java
+    @Operation(summary = "获取自检状态(是否有记录、是否可检、距下次提醒天数)")
+    @PostMapping("/status")
+    public Result<Map<String, Object>> getStatus(@RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        return Result.success(selfCheckService.getStatus(userId));
+    }
+```
+
+- [ ] **步骤 3:新增 `/ignore` 接口**
+
+```java
+    @Operation(summary = "忽略本次自检提醒(重置 15 天计时)")
+    @PostMapping("/ignore")
+    public Result<Map<String, Object>> ignore(@RequestBody(required = false) Map<String, Object> body,
+                                               @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long checkId = body != null ? ParamUtils.getLong(body.get("checkId")) : null;
+        Date ignoredAt = selfCheckService.ignoreSelfCheck(userId, checkId);
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("nextRemindAt", new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").format(
+                new Date(ignoredAt.getTime() + 15L * 24 * 60 * 60 * 1000)));
+        return Result.success(resp);
+    }
+```
+
+- [ ] **步骤 4:修改 `/questions` 接口 — 区分首次/再次**
+
+将 `/questions` 方法改为:
+
+```java
+    @Operation(summary = "获取自检问卷题目(首次固定 15 题;再次合并题库随机抽)")
+    @PostMapping("/questions")
+    public Result<List<SelfCheckQuestionVO>> getQuestions(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody(required = false) Map<String, Object> body) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Boolean retake = body != null && body.get("retake") != null && Boolean.TRUE.equals(body.get("retake"));
+        if (retake) {
+            return Result.success(selfCheckService.getQuestionsForRetake(userId));
+        }
+        return Result.success(selfCheckService.getQuestions());
+    }
+```
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java
+git commit -m "feat(self-check): 新增 /status 和 /ignore 接口;/questions 支持 retake 参数"
+```
+
+---
+
+## 任务 4:前端 — utils/api.js 新增方法
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`(在自检相关方法后追加)
+
+- [ ] **步骤 1:追加两个 API 方法**
+
+在 `cfc-frontend/utils/api.js` 中,于 `getSelfCheckHistory` 方法之后追加:
+
+```js
+export const getSelfCheckStatus = () => {
+  return request('/api/family/self-check/status', 'POST', {})
+}
+
+export const ignoreSelfCheck = (data) => {
+  return request('/api/family/self-check/ignore', 'POST', data || {})
+}
+```
+
+- [ ] **步骤 2:语法校验**
+
+```bash
+node --check cfc-frontend/utils/api.js
+```
+
+预期:无输出(语法正确)
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git commit -m "feat(self-check): 新增 getSelfCheckStatus 和 ignoreSelfCheck API 方法"
+```
+
+---
+
+## 任务 5:前端 — 新增中间页 self-check-entry.vue
+
+**文件:**
+- 新建:`cfc-frontend/pages/family/self-check-entry.vue`
+
+- [ ] **步骤 1:创建中间页文件**
+
+创建 `cfc-frontend/pages/family/self-check-entry.vue`:
+
+```vue
+<template>
+  <view class="sce-page">
+    <view v-if="loading" class="sce-loading">
+      <text class="sce-loading-text">加载中...</text>
+    </view>
+    <template v-else>
+      <!-- 总分横幅 -->
+      <view class="sce-hero" :style="{ background: heroGradient }">
+        <view class="sce-hero-inner">
+          <text class="sce-hero-label">最近自检总分</text>
+          <view class="sce-hero-score-row">
+            <text class="sce-hero-score">{{ lastResult && lastResult.totalScore || 0 }}</text>
+            <text class="sce-hero-total">/45</text>
+          </view>
+          <text class="sce-hero-date" v-if="lastResult && lastResult.createdAt">
+            {{ formatCreatedDate(lastResult.createdAt) }}
+          </text>
+        </view>
+      </view>
+
+      <!-- 维度分数条 -->
+      <view class="sce-card" v-if="lastResult && lastResult.dimensions && lastResult.dimensions.length > 0">
+        <view class="sce-card-title">
+          <text class="sce-card-title-text">五维得分</text>
+        </view>
+        <view class="sce-dim-list">
+          <view class="sce-dim-item" v-for="dim in lastResult.dimensions" :key="dim.dimension">
+            <view class="sce-dim-head">
+              <view class="sce-dim-label">
+                <view class="sce-dim-dot" :style="{ background: dim.color }"></view>
+                <text class="sce-dim-name">{{ dim.name }}</text>
+              </view>
+              <view class="sce-dim-score-wrap">
+                <text class="sce-dim-score" :style="{ color: dim.color }">{{ dim.score }}</text>
+                <text class="sce-dim-max">/9</text>
+              </view>
+            </view>
+            <view class="sce-dim-bar">
+              <view class="sce-dim-bar-fill" :style="{ width: (dim.score / 9 * 100) + '%', background: dim.color }"></view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 状态提示 -->
+      <view class="sce-status" v-if="!canCheck && lastResult">
+        <text class="sce-status-text">距下次自检还有 {{ daysLeft }} 天</text>
+      </view>
+      <view class="sce-status" v-else-if="canCheck && lastResult">
+        <text class="sce-status-text">已满 15 天,可再次自检</text>
+      </view>
+
+      <!-- 按钮区域 -->
+      <view class="sce-footer">
+        <button v-if="canCheck && lastResult" class="sce-btn sce-btn-primary" @click="retake">
+          再次自检
+        </button>
+        <button v-if="canCheck && lastResult" class="sce-btn sce-btn-outline" @click="ignore">
+          忽略本次
+        </button>
+        <button v-if="!lastResult" class="sce-btn sce-btn-primary" @click="retake">
+          开始自检
+        </button>
+        <button v-if="!canCheck && lastResult" class="sce-btn sce-btn-disabled" disabled>
+          {{ daysLeft }} 天后可再次自检
+        </button>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script>
+import { getSelfCheckStatus, ignoreSelfCheck } from '@/utils/api'
+import { parseDate } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      lastResult: null,
+      canCheck: false,
+      daysLeft: 0
+    }
+  },
+  computed: {
+    heroGradient: function() {
+      if (!this.lastResult || !this.lastResult.dimensions || this.lastResult.dimensions.length === 0) {
+        return 'linear-gradient(135deg, #F97316, #FF8C42)'
+      }
+      var lowest = this.lastResult.dimensions.reduce(function(min, d) {
+        return d.score < min.score ? d : min
+      }, this.lastResult.dimensions[0])
+      return 'linear-gradient(135deg, ' + lowest.color + ', ' + (lowest.color + 'bb') + ')'
+    }
+  },
+  onLoad: function() {
+    this.loadStatus()
+  },
+  methods: {
+    loadStatus: function() {
+      var self = this
+      getSelfCheckStatus().then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          self.lastResult = res.data.lastResult || null
+          self.canCheck = res.data.canCheck
+          self.daysLeft = res.data.daysLeft || 0
+        } else {
+          uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.loading = false
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      })
+    },
+    retake: function() {
+      uni.navigateTo({ url: '/pages/family/self-check?retake=1' })
+    },
+    ignore: function() {
+      var self = this
+      ignoreSelfCheck({ checkId: this.lastResult && this.lastResult.id }).then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '已忽略,15 天后再次提醒', icon: 'none' })
+          setTimeout(function() {
+            uni.navigateBack()
+          }, 800)
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.showToast({ title: '操作失败', icon: 'none' })
+      })
+    },
+    formatCreatedDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return ''
+      var pad = function(n) { return n < 10 ? '0' + n : '' + n }
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+    }
+  }
+}
+</script>
+
+<style scoped>
+.sce-page {
+  min-height: 100vh;
+  background: #F5FAFE;
+  padding: 24rpx 30rpx 120rpx;
+  box-sizing: border-box;
+}
+.sce-loading {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 50vh;
+}
+.sce-loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+.sce-hero {
+  background: linear-gradient(135deg, #F97316, #FF8C42);
+  border-radius: 24rpx;
+  padding: 40rpx 30rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 6rpx 20rpx rgba(249, 114, 22, 0.25);
+}
+.sce-hero-inner {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.sce-hero-label {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.9);
+}
+.sce-hero-score-row {
+  display: flex;
+  align-items: baseline;
+  margin-top: 10rpx;
+}
+.sce-hero-score {
+  font-size: 88rpx;
+  font-weight: 700;
+  color: #fff;
+}
+.sce-hero-total {
+  font-size: 30rpx;
+  color: rgba(255, 255, 255, 0.7);
+  margin-left: 6rpx;
+}
+.sce-hero-date {
+  display: block;
+  text-align: center;
+  font-size: 22rpx;
+  color: rgba(255, 255, 255, 0.8);
+  margin-top: 16rpx;
+}
+.sce-card {
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 30rpx 26rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+}
+.sce-card-title {
+  margin-bottom: 24rpx;
+}
+.sce-card-title-text {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+.sce-dim-list {
+  display: flex;
+  flex-direction: column;
+}
+.sce-dim-item {
+  margin-bottom: 24rpx;
+}
+.sce-dim-item:last-child {
+  margin-bottom: 0;
+}
+.sce-dim-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.sce-dim-label {
+  display: flex;
+  align-items: center;
+}
+.sce-dim-dot {
+  width: 28rpx;
+  height: 28rpx;
+  border-radius: 50%;
+  margin-right: 12rpx;
+}
+.sce-dim-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.sce-dim-score-wrap {
+  display: flex;
+  align-items: baseline;
+}
+.sce-dim-score {
+  font-size: 40rpx;
+  font-weight: 700;
+}
+.sce-dim-max {
+  font-size: 22rpx;
+  color: #BBB;
+}
+.sce-dim-bar {
+  height: 12rpx;
+  background: #F0F0F0;
+  border-radius: 6rpx;
+  margin-top: 14rpx;
+  overflow: hidden;
+}
+.sce-dim-bar-fill {
+  height: 100%;
+  border-radius: 6rpx;
+  transition: width 0.5s ease;
+}
+.sce-status {
+  background: #FFF7ED;
+  border-radius: 16rpx;
+  padding: 24rpx 20rpx;
+  margin-bottom: 24rpx;
+  text-align: center;
+}
+.sce-status-text {
+  font-size: 26rpx;
+  color: #E65100;
+}
+.sce-footer {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+  margin-top: 20rpx;
+}
+.sce-btn {
+  height: 88rpx;
+  line-height: 88rpx;
+  font-size: 30rpx;
+  border-radius: 44rpx;
+  text-align: center;
+  border: none;
+}
+.sce-btn-primary {
+  background: linear-gradient(135deg, #F97316, #FF8C42);
+  color: #fff;
+  font-weight: 600;
+}
+.sce-btn-outline {
+  background: #fff;
+  color: #F97316;
+  border: 2rpx solid #F97316;
+}
+.sce-btn-disabled {
+  background: #E5E7EB;
+  color: #999;
+  font-weight: 400;
+}
+</style>
+```
+
+- [ ] **步骤 2:语法校验**
+
+```bash
+node --check cfc-frontend/pages/family/self-check-entry.vue 2>&1 || echo "Vue 文件跳过 node --check"
+```
+
+预期:Vue 文件无法直接 node --check,跳过即可;重点检查 script 块语法。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/pages/family/self-check-entry.vue
+git commit -m "feat(self-check): 新增自检中间页 self-check-entry.vue"
+```
+
+---
+
+## 任务 6:前端 — 注册中间页 + 修改结果页跳转
+
+**文件:**
+- 修改:`cfc-frontend/pages.json`
+- 修改:`cfc-frontend/pages/family/self-check-result.vue`
+
+- [ ] **步骤 1:pages.json 注册中间页**
+
+在 `cfc-frontend/pages.json` 的 `pages/family` 分包中找到 `self-check-result` 注册,在其后追加:
+
+```json
+{
+  "path": "self-check-entry",
+  "style": {
+    "navigationBarTitleText": "自检"
+  }
+}
+```
+
+- [ ] **步骤 2:self-check-result.vue 修改「重新自检」按钮**
+
+在 `cfc-frontend/pages/family/self-check-result.vue` 中,找到 `retake` 方法(约第 394 行),将其修改为:
+
+```javascript
+retake: function() {
+  uni.navigateTo({ url: '/pages/family/self-check-entry' })
+}
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/pages.json cfc-frontend/pages/family/self-check-result.vue
+git commit -m "feat(self-check): 注册 self-check-entry 页面;结果页「重新自检」改跳中间页"
+```
+
+---
+
+## 任务 7:前端 — 首页入口接入自检状态
+
+**文件:**
+- 修改:`cfc-frontend/pages/index-home/index.vue`
+- 修改:`cfc-frontend/pages/home-pages/parent-index.vue`
+
+- [ ] **步骤 1:index-home/index.vue — 加载自检状态 + 显示分数**
+
+在 `index.vue` 的 `data()` 中追加:
+
+```javascript
+selfCheckScore: null,
+selfCheckLoading: false,
+```
+
+在 `methods` 中追加方法(放入 `loadFamilyMembers` 调用链之后):
+
+```javascript
+loadSelfCheckStatus: function() {
+  var self = this
+  getSelfCheckStatus().then(function(res) {
+    if (res.code === 200 && res.data && res.data.lastResult) {
+      self.selfCheckScore = res.data.lastResult.totalScore
+    }
+  }).catch(function() {})
+}
+```
+
+在 `loadDimensionData` 方法中追加调用:
+
+```javascript
+loadDimensionData: function() {
+  this.loadSandboxData()
+  this.loadEnergyOverview()
+  this.loadActionArticles()
+  this.loadChallenges()
+  this.loadSelfCheckStatus()
+}
+```
+
+修改 `selfTestSub` 计算属性(约第 538 行):
+
+```javascript
+selfTestSub: function() {
+  if (this.sandboxData) {
+    if (this.selfCheckScore != null) {
+      return '最近自检得分 ' + this.selfCheckScore + '/45 分 · 点击查看'
+    }
+    return '最近综合得分 ' + (this.sandboxData.overallScore || 0) + ' 分 · 点击查看'
+  }
+  return '15 题五维自检 · 约 20 秒'
+}
+```
+
+修改自检入口点击事件,改为跳转中间页:
+
+```javascript
+// 找到原 goToSelfCheck 或类似方法,改为:
+goToSelfCheck: function() {
+  uni.navigateTo({ url: '/pages/family/self-check-entry' })
+}
+```
+
+同时确保文件头部已导入 `getSelfCheckStatus`:
+
+```javascript
+import { getSelfCheckStatus } from '@/utils/api'
+```
+
+- [ ] **步骤 2:home-pages/parent-index.vue — 加载自检状态 + 显示分数**
+
+在 `data()` 中追加:
+
+```javascript
+selfCheckScore: null,
+```
+
+在 `methods` 中追加:
+
+```javascript
+loadSelfCheckStatus: function() {
+  var self = this
+  getSelfCheckStatus().then(function(res) {
+    if (res.code === 200 && res.data && res.data.lastResult) {
+      self.selfCheckScore = res.data.lastResult.totalScore
+    }
+  }).catch(function() {})
+}
+```
+
+在 `onLoad` 或 `onShow` 中调用 `loadSelfCheckStatus()`(找到现有初始化调用处追加)。
+
+修改 `selfcheck-subtitle` 或相关文案显示逻辑(找到渲染 `selfcheck-subtitle` 的模板部分),改为:
+
+```html
+<text class="selfcheck-subtitle" v-if="selfCheckScore != null">
+  最近得分 {{ selfCheckScore }}/45 分
+</text>
+<text class="selfcheck-subtitle" v-else>
+  15题 · 5分钟 · 生成五行相生寻源建议
+</text>
+```
+
+修改 `goToSelfCheck` 方法(约第 1086 行):
+
+```javascript
+goToSelfCheck: function() {
+  uni.navigateTo({ url: '/pages/family/self-check-entry' })
+}
+```
+
+在文件头部导入 `getSelfCheckStatus`:
+
+```javascript
+import { getSelfCheckStatus } from '@/utils/api'
+```
+
+- [ ] **步骤 3:语法校验**
+
+```bash
+node --check cfc-frontend/pages/index-home/index.vue 2>&1 || echo "跳过"
+node --check cfc-frontend/pages/home-pages/parent-index.vue 2>&1 || echo "跳过"
+```
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/index-home/index.vue cfc-frontend/pages/home-pages/parent-index.vue
+git commit -m "feat(self-check): 首页入口显示自检总分 + 跳转中间页"
+```
+
+---
+
+## 任务 8:前端 — self-check.vue 支持 retake 参数
+
+**文件:**
+- 修改:`cfc-frontend/pages/family/self-check.vue`
+
+- [ ] **步骤 1:onLoad 读取 retake 参数并传给 questions 接口**
+
+在 `cfc-frontend/pages/family/self-check.vue` 中,修改 `loadQuestions` 方法:
+
+```javascript
+loadQuestions: function() {
+  var self = this
+  var retake = this.$route && this.$route.query && this.$route.query.retake === '1'
+  var apiCall = retake ? getSelfCheckQuestions({ retake: true }) : getSelfCheckQuestions()
+  apiCall.then(function(res) {
+    self.loading = false
+    if (res.code === 200 && res.data) {
+      self.questions = res.data || []
+    } else {
+      uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
+    }
+  }).catch(function() {
+    self.loading = false
+    uni.showToast({ title: '网络异常,请重试', icon: 'none' })
+  })
+}
+```
+
+同时修改 `api.js` 中 `getSelfCheckQuestions` 支持传参:
+
+```javascript
+export const getSelfCheckQuestions = (params) => {
+  return request('/api/family/self-check/questions', 'POST', params || {})
+}
+```
+
+- [ ] **步骤 2:语法校验**
+
+```bash
+node --check cfc-frontend/utils/api.js
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages/family/self-check.vue
+git commit -m "feat(self-check): 支持 retake 参数获取轮换题库"
+```
+
+---
+
+## 任务 9:最终验证
+
+- [ ] **步骤 1:后端编译**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 2:前端语法校验**
+
+```bash
+node --check cfc-frontend/utils/api.js && echo "api.js OK"
+```
+
+- [ ] **步骤 3:提交最终 commit(如有遗漏)**
+
+```bash
+git add -A
+git diff --cached --stat
+git commit -m "feat(self-check): 五维自检 15 天复检周期全栈实现"
+```
+
+---
+
+## 自检清单
+
+**规格覆盖度:**
+- [x] 15 天复检周期(个人维度)→ 任务 2(getStatus)、任务 3(Controller)
+- [x] 首页显示上次自检分数 → 任务 7
+- [x] 进入时显示上次结果 + 二选一 → 任务 5(中间页)
+- [x] 忽略后重置计时 → 任务 2(ignoreSelfCheck)、任务 3(/ignore)
+- [x] 题目轮换 → 任务 2(getQuestionsForRetake + pickQuestions)
+
+**占位符扫描:** 无"待定"/"TODO"/"后续实现"
+
+**类型一致性:**
+- `SelfCheckIgnore` / `SelfCheckIgnoreMapper` — 任务 1 定义,任务 3 注入
+- `getStatus` / `ignoreSelfCheck` / `getQuestionsForRetake` — 任务 2 定义,任务 3 Controller 调用
+- `getSelfCheckStatus` / `ignoreSelfCheck` — 任务 4 定义,任务 5/7 调用