|
|
@@ -0,0 +1,843 @@
|
|
|
+# 浠艾福平台 · 确定性差距修复实施计划
|
|
|
+
|
|
|
+**版本:** v1.0
|
|
|
+**日期:** 2026-07-10
|
|
|
+**前置审计:** docs/superpowers/plans/2026-07-10-determinism-gap-audit.md(基于 5 路并行 explore 代码审计)
|
|
|
+**总体工作量估算:** 38-52 人天(P0: 5-7d / P1: 15-20d / P2: 12-16d / P3: 6-9d)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 实施总览
|
|
|
+
|
|
|
+本计划以"确定性"为主线,把审计报告中的 18 个修复任务按优先级分层,强调**安全先于叙事、闭环先于优化、闭环先于优化、闭环先于优化**。
|
|
|
+
|
|
|
+| 阶段 | 主题 | 任务数 | 估算人天 | 价值锚点 |
|
|
|
+|:----:|------|:------:|:--------:|----------|
|
|
|
+| **P0** | 隐私 + 营销根基 | 3 | 5-7 | 堵最大隐私漏洞;打开"成长可视化"叙事 |
|
|
|
+| **P1** | 信任 + 数据闭环 | 6 | 15-20 | 邀请/视角/订单/Dify 闭环;地址全国可用 |
|
|
|
+| **P2** | 数据质量 + 可维护性 | 5 | 12-16 | 迁移可追溯;schema 自洽;报告机读;推荐可解释 |
|
|
|
+| **P3** | 长期治理 | 4 | 6-9 | 死字段清理;PDF 多供应商;sfms 只读;前端去 mock |
|
|
|
+
|
|
|
+**关键里程碑:**
|
|
|
+- **M1(第 1 周)**:P0 完成。`familyId` 越权测试零案例;五维分数可图表化回放
|
|
|
+- **M2(第 3-4 周)**:P1 完成。地址全国可用;家长可看到"测评结果可看"通知;Dify 宕机对话不丢
|
|
|
+- **M3(第 6-7 周)**:P2 完成。每次重启跳过所有已跑迁移;schema.sql 重建数据库成功
|
|
|
+- **M4(第 9-10 周)**:P3 完成。死字段全部接入或删除;前端图表不出现假数据
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## P0 关键安全与根基(立即修)
|
|
|
+
|
|
|
+> **共用约定**:所有新建表通过 `DatabaseInitializer.runMigrations()` 新增 `CREATE TABLE IF NOT EXISTS`;schema.sql 同步追加;新增 controller 用 `@PostMapping`;DI 用 `@Resource` 字段名匹配 Bean Name。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G7. familyId 入参校验(最大隐私漏洞)
|
|
|
+
|
|
|
+**优先级:** P0 🔴
|
|
|
+**估算:** 1.5 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `JwtInterceptor.java`、`WebConfig.java`、7 个控制器
|
|
|
+
|
|
|
+#### 问题定位
|
|
|
+
|
|
|
+7 个控制器从 JSON body/参数中拿 `familyId` 后直接使用,不校验该家庭是否属于当前 JWT 用户:
|
|
|
+- `DanAssessmentController` 第 65/79/89/208 行
|
|
|
+- `InteractionLogController` 第 62 行
|
|
|
+- `FoodRecommendController` 第 50 行
|
|
|
+- `WishController` 第 229 行
|
|
|
+- `TeacherMessageController` 第 43 行
|
|
|
+- `GrowthRecordController` 第 90 行
|
|
|
+- `TraditionalMirrorController` 第 88 行
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **新增 `FamilyAccessInterceptor`**(`config/FamilyAccessInterceptor.java`)
|
|
|
+ - 拦截 `/api/**`(除 JwtInterceptor 已放行的 8 个公开路径)
|
|
|
+ - 在 preHandle 阶段:解析 body 中的 `familyId` 字段(用 `ContentCachingRequestWrapper` 缓存 body 以便可重复读)
|
|
|
+ - 从 `@RequestAttribute("userId")` 查 `User.familyId` 与 `User.teacherFamilyIds`
|
|
|
+ - 校验:body 中 `familyId` 必须等于 `user.familyId` **或** 在 `teacherFamilyIds` 列表中
|
|
|
+ - 不通过:`response.setStatus(403)`;返回 JSON `{"code":403,"message":"无权访问该家庭数据"}`
|
|
|
+ - 通过:放行
|
|
|
+
|
|
|
+2. [ ] **`WebConfig.addInterceptors`** 注册 `FamilyAccessInterceptor`,order 在 `JwtInterceptor` 之后
|
|
|
+
|
|
|
+3. [ ] **`User` 实体新增 helper** `getEffectiveFamilyIds()`:返回包含 `familyId` + 解析后的 `teacherFamilyIds` 的 `Set<Long>`
|
|
|
+
|
|
|
+4. [ ] **识别例外路径**:admin 调用路径(如 `/api/admin/*`)跳过此拦截器;teacher 绑定家庭的 `guideBindFamily` 由 G10 任务单独加审批,暂放行
|
|
|
+
|
|
|
+5. [ ] **测试**:新增 `FamilyAccessInterceptorTest`
|
|
|
+ - parent A 携 familyId=B → 403
|
|
|
+ - parent A 携 familyId=A → 200
|
|
|
+ - teacher T 携 familyId=已绑定家庭 → 200
|
|
|
+ - 缺 familyId 字段 → 放行(有些端点不强制)
|
|
|
+ - admin 路径 → 跳过
|
|
|
+
|
|
|
+6. [ ] **不要**修改原有 7 个控制器内部逻辑——拦截器统一处理
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] `mvn clean compile` 通过
|
|
|
+- [ ] `mvn test -Dtest=FamilyAccessInterceptorTest` 通过
|
|
|
+- [ ] 临时启动后端,用 parent A 的 token + body `{familyId: B的家庭ID}` 调 `/api/dan-assessment/materials` 返回 403
|
|
|
+- [ ] 用 parent A + 自家 familyId 返回 200
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 所有 `/api/**` 路径(除 admin 子树)自动校验 familyId 归属
|
|
|
+- 越权测试用例全 PASS
|
|
|
+- 无控制器内部 familyId 重复校验代码
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G1. 五维能量分数持久化(解锁全部可视化叙事)
|
|
|
+
|
|
|
+**优先级:** P0 🔴
|
|
|
+**估算:** 2.5 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** 新增 entity/mapper/service、`EnergyService.java`、`DatabaseInitializer.java`、`schema.sql`
|
|
|
+
|
|
|
+#### 问题定位
|
|
|
+
|
|
|
+`EnergyService.calculateFamilyEnergy()` 每次请求重算身/心/智/行/富 0-100 分;无任何表保存"X 孩子在 Y 日期的分数",导致:
|
|
|
+- 无法做趋势图、"上次 vs 本次"对比
|
|
|
+- 算法改一处,所有"历史成绩"瞬时漂移
|
|
|
+- AGENTS.md 宣传文案"自驱力 4.07→5.30"在系统中**根本查不到**
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **新建 `five_dimension_scores` 表**
|
|
|
+ ```sql
|
|
|
+ CREATE TABLE IF NOT EXISTS five_dimension_scores (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
|
|
|
+ family_id BIGINT NOT NULL,
|
|
|
+ dimension_code VARCHAR(32) NOT NULL COMMENT 'body/mind/wisdom/action/wealth',
|
|
|
+ score INT NOT NULL COMMENT '0-100 分',
|
|
|
+ assessed_at DATETIME NOT NULL COMMENT '评估时间',
|
|
|
+ source_type VARCHAR(32) NOT NULL COMMENT 'assessment/task_complete/manual/periodic',
|
|
|
+ source_id BIGINT COMMENT '源对象ID(如dan_assessment_results.id)',
|
|
|
+ detail_json TEXT COMMENT '细分项快照(cognitive_scores/emi_scores/...)',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ INDEX idx_member_dim_time (member_id, dimension_code, assessed_at),
|
|
|
+ INDEX idx_family_dim_time (family_id, dimension_code, assessed_at)
|
|
|
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维能量历史快照';
|
|
|
+ ```
|
|
|
+ - 通过 `DatabaseInitializer.runMigrations()` 添加
|
|
|
+ - 同步追加到 `schema.sql`
|
|
|
+
|
|
|
+2. [ ] **新建 `FiveDimensionScore` entity**(标准 MyBatis-Plus)
|
|
|
+ - `@TableName("five_dimension_scores")`, `@TableId(type=IdType.AUTO)`
|
|
|
+ - 所有字段对应表结构
|
|
|
+
|
|
|
+3. [ ] **新建 `FiveDimensionScoreMapper`**
|
|
|
+
|
|
|
+4. [ ] **新建 `FiveDimensionScoreService`**
|
|
|
+ - `recordSnapshot(memberId, familyId, dimension, score, sourceType, sourceId, detailJson)`:插入
|
|
|
+ - `getHistoryByMember(memberId, dimension, limit=20)`:返回时间序列
|
|
|
+ - `getLatestByFamilyMap(familyId)`:返回 Map<dimensionCode, score>
|
|
|
+ - `compareFirstVsLatest(memberId)`:返回 delta(先实现接口,G18 用)
|
|
|
+
|
|
|
+5. [ ] **`EnergyService.calculateFamilyEnergy()` 末尾插入持久化钩子**
|
|
|
+ - 在每次计算完每个孩子每个维度分数后,调用 `fiveDimensionScoreService.recordSnapshot(...)`
|
|
|
+ - **去重策略**:若同一 `memberId+dimension+assessed_at(精度=天)+source_type+source_id` 已存在,跳过插入
|
|
|
+ - **不要**改 `EnergyService` 的计算逻辑——只在尾部加 record
|
|
|
+
|
|
|
+6. [ ] **`EnergyController.getOverview`** 优先读 `fiveDimensionScores` 最新记录;fallback 到现有重算逻辑(保证向下兼容)
|
|
|
+
|
|
|
+7. [ ] **新增端点**:
|
|
|
+ - `POST /api/energy/score-history`:入参 `memberId, dimension, limit`,返回时间序列
|
|
|
+ - `POST /api/energy/family-score-history`:入参 `familyId, dimension, fromDate, toDate`
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] `mvn clean compile` 通过
|
|
|
+- [ ] 调 `/api/energy/overview` 两次,DB 中应出现两条快照(去重生效时只有 1 条)
|
|
|
+- [ ] 调 `/api/energy/score-history?memberId=X&dimension=wisdom&limit=5` 返回时间序列
|
|
|
+- [ ] 修改 `EnergyService.calcChildWisdom()` 系数(如临时改为 0.5),重启,**历史分数不变**,新分数按新系数
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 五维分数写入 DB
|
|
|
+- 历史可查询、趋势可绘制(前端可用)
|
|
|
+- `EnergyService` 算法改动不再影响历史数据
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G18. 首次 vs 最新测评对比端点
|
|
|
+
|
|
|
+**优先级:** P0 🔴
|
|
|
+**估算:** 1.5 人天
|
|
|
+**前置依赖:** G1(推荐先完成,否则对比来源是重算数据)
|
|
|
+**影响文件:** `GrowthRecordController`/`AssessmentService`,或新建 `AssessmentComparisonController`
|
|
|
+
|
|
|
+#### 问题定位
|
|
|
+
|
|
|
+AGENTS.md 宣传文案"自驱力 4.07→5.30"在系统中**无法查到**——无 `compareAssessments` 端点,没有方法取首个和最近一次测评计算 delta。`cognitive-report.vue` 只显示单次报告。
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **新建 `AssessmentComparisonService`**
|
|
|
+ - `compareByChild(childId)`:取该孩子 `dan_assessment_results` 中最早 + 最近两条,计算维度 delta
|
|
|
+ - `compareByChildAndDimension(childId, dimensionCode)`:聚焦单维度
|
|
|
+ - `getProgressNarrative(childId)`:返回结构化 delta + 文本叙事("智维度提升 +12 分,主要由 logicScore 拉动 +18,专注力下降 -5,建议...")
|
|
|
+
|
|
|
+2. [ ] **数据源策略**:
|
|
|
+ - 优先从 `five_dimension_scores`(G1 新增)取历史值
|
|
|
+ - fallback 到 `dan_assessment_results` 直接 diff
|
|
|
+
|
|
|
+3. [ ] **新建端点**:
|
|
|
+ - `POST /api/assessment/compare/by-child` 入参 `childId`
|
|
|
+ 返回:
|
|
|
+ ```json
|
|
|
+ {
|
|
|
+ "firstAssessment": { "id":42, "date":"2025-08-10", "scores":{...} },
|
|
|
+ "latestAssessment": { "id":88, "date":"2026-07-01", "scores":{...} },
|
|
|
+ "dimensionDeltas": [
|
|
|
+ {"dimension":"wisdom","delta":12.3,"direction":"up","drivers":["logicScore +18","memoryScore +6"]},
|
|
|
+ ...
|
|
|
+ ],
|
|
|
+ "overallDelta": 7.4,
|
|
|
+ "narrative": "智维度提升 12.3 分,主要由 logicScore..."
|
|
|
+ }
|
|
|
+ ```
|
|
|
+ - `POST /api/assessment/compare/by-family` 入参 `familyId`,返回家庭所有孩子对比数组
|
|
|
+
|
|
|
+4. [ ] **前端 `pages/wisdom-detail/` 新增 `assessment-comparison.vue`**:渲染对比卡片(先文案样式即可,不强求图表)
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] 后端单元测试 `AssessmentComparisonServiceTest`:mock 两次测评,断言 delta 计算
|
|
|
+- [ ] `/api/assessment/compare/by-child?childId=X` 返回结构化 delta
|
|
|
+- [ ] 前端能看到"首次→最新"对比卡
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 平台本身能产出 AGENTS.md 营销文案所需数据
|
|
|
+- 不依赖运营人工抠数字
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## P1 信任与数据闭环(本季度修)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G8. 视角切换统一为一套机制 + 密码校验
|
|
|
+
|
|
|
+**优先级:** P1
|
|
|
+**估算:** 2.5 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `FamilyUserController`、`UserService`、`FamilyMembersController`、前端 `store/index.js`、`pages/profile/*`
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **保留机制 3**:`/api/family/member/switch`(返回 SwitchMemberVO,不动 DB)——这是正确模型
|
|
|
+2. [ ] **废弃机制 1、2**:
|
|
|
+ - `FamilyUserController.switchMode` → 改为 404 或重定向到 `/member/switch`
|
|
|
+ - `FamilyUserController.switchToChild` → 同上
|
|
|
+ - `UserService.switchToChild`、`switchMode` 加 `@Deprecated`,保留代码以兼容老 JWT 但记 log warning
|
|
|
+3. [ ] **`UserService.switchBackToParent()` 加入密码校验**:
|
|
|
+ - 接收 `password` 参数,调用 `passwordEncoder.matches(password, user.password)`
|
|
|
+ - 校验失败返回 `Result.error("密码错误")`
|
|
|
+ - 不允许 null/空密码通过
|
|
|
+4. [ ] **新增端点** `POST /api/family/user/switch-back-verify`:前端切回家长视角前调用,强制输入密码
|
|
|
+5. [ ] **前端 `store/index.js` 改造**:
|
|
|
+ - 移除 `isSwitchedChild`、`isSwitchedMember` 双 bool,合并为 `currentView: 'self'|'member'`
|
|
|
+ - `switchBackFromMember` 调用 `/switch-back-verify`,失败显示密码弹窗
|
|
|
+ - localStorage 中 `role` 单点真源,仍写但加签名(HMAC with server-issued nonce)防止篡改(可选,先做密码校验)
|
|
|
+6. [ ] **前端文案**:家长切回视角时弹"请输入密码以确认身份"
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] 切到孩子视角后,清空 localStorage,强制刷新,调用 `/switch-back-verify` 必须密码通过才能切回
|
|
|
+- [ ] `mvn test -Dtest=UserServiceTest#switchBackWithWrongPassword` 失败
|
|
|
+- [ ] 三个端点 `/switch-mode`、`/switch-to-child` 返回 410 Gone 或 308
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 全平台只有一套视角切换机制
|
|
|
+- 切回家长必须密码
|
|
|
+- AGENTS.md 中"切换过来的孩子端退出需要密码"承诺兑现
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G9. 邀请码加过期+使用次数+吊销
|
|
|
+
|
|
|
+**优先级:** P1
|
|
|
+**估算:** 1.5 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `FamilyController`、`FamilyInviteController`、`FamilyInvitationService`、`UserAddress` 等
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **统一两个邀请流为一套**:废弃 `FamilyController /invite-code` 的简易码,全部走 `FamilyInviteController /generate` 生成 token-based invitation
|
|
|
+2. [ ] **`FamilyInvitation` 增强**:
|
|
|
+ - 添加 `maxUseCount INT DEFAULT 1`
|
|
|
+ - 添加 `usedCount INT DEFAULT 0`
|
|
|
+ - 添加 `inviterUserId BIGINT NOT NULL`
|
|
|
+ - 已有字段 `expiresAt`(7 天)保持
|
|
|
+3. [ ] **`validateInvitation()` 强化**:
|
|
|
+ - 检查 `usedCount < maxUseCount`
|
|
|
+ - 检查 `status='active'` 且未过期
|
|
|
+ - 校验失败返回明确原因
|
|
|
+4. [ ] **`acceptInvitation()`** 在事务中 `UPDATE ... SET usedCount = usedCount + 1`,并带 `WHERE usedCount < maxUseCount` 防并发
|
|
|
+5. [ ] **新增端点** `POST /api/family/invite/revoke`:入参 `invitationId`,仅 inviter 或 family admin 可调用,将 status 设为 `revoked`
|
|
|
+6. [ ] **`FamilyController.guideBindFamily`** 加 `familyId` 视角下AMILY.getId() ≠ 邀请绑定的(endpoint 自然受 G7 拦截器保护,可保留)
|
|
|
+7. [ ] **rate-limit**:每家庭每天最多生成 10 个邀请码(用 `Bucket4j` 或简单 Redis 计数)
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] 同一邀请码使用次数超 maxUseCount 后 `accept` 返回 410
|
|
|
+- [ ] revoke 后立即失效
|
|
|
+- [ ] 老简易 invite-code 端点返回 410
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 邀请码泄露可吊销
|
|
|
+- 暴力枚举有 rate limit 防护
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G10. 规划师绑定家庭加审批
|
|
|
+
|
|
|
+**优先级:** P1
|
|
|
+**估算:** 2 人天
|
|
|
+**前置依赖:** G9(共用邀请码机制)
|
|
|
+**影响文件:** `FamilyController.guideBindFamily`、`TeacherFamilyBindingRequest`(新 entity)、`FamilyController`
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **新建 `teacher_family_binding_requests` 表**
|
|
|
+ ```sql
|
|
|
+ CREATE TABLE IF NOT EXISTS teacher_family_binding_requests (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ teacher_id BIGINT NOT NULL,
|
|
|
+ family_id BIGINT NOT NULL,
|
|
|
+ invitation_id BIGINT NOT NULL,
|
|
|
+ status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/approved/rejected/cancelled',
|
|
|
+ requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ decided_by BIGINT COMMENT '审批家长 user_id',
|
|
|
+ decided_at DATETIME,
|
|
|
+ reject_reason VARCHAR(200),
|
|
|
+ UNIQUE KEY uk_teacher_family (teacher_id, family_id) COMMENT '防重复申请',
|
|
|
+ INDEX idx_family_status (family_id, status)
|
|
|
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
+ ```
|
|
|
+2. [ ] **`FamilyController.guideBindFamily()` 改造**:从直接 ADD `teacherFamilyIds` → 插入 binding_request,status=pending
|
|
|
+3. [ ] **新增端点**:
|
|
|
+ - `POST /api/family/teacher-bindings/pending`:家长查看自家待审批列表
|
|
|
+ - `POST /api/family/teacher-bindings/approve`:入参 `requestId`,调家长权限校验,status=approved,写入 `User.teacherFamilyIds`
|
|
|
+ - `POST /api/family/teacher-bindings/reject`:入参 `requestId, reason`
|
|
|
+4. [ ] **消息通知**:审批状态变化通过现有 `MessageService` 推送小程序模板消息给规划师
|
|
|
+5. [ ] **`MessageController` 增加类型**:`teacher_binding_approved` / `teacher_binding_rejected`
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] 规划师调用 guideBindFamily 后,家长收到待审批消息
|
|
|
+- [ ] 家长 reject 前规划师不能在 `teacherFamilyIds` 看到 familyId
|
|
|
+- [ ] 同一 (teacher, family) 重复申请被 UK 拦截
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 陌生规划师凭邀请码无法直接看到家庭数据
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G2. 测评订单 → 结果 linkage + "results_ready" 状态
|
|
|
+
|
|
|
+**优先级:** P1
|
|
|
+**估算:** 2 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `AssessmentOrder`、`AssessmentOrderService`、`GuideRecordController`、`AssessmentService`
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **`assessment_orders` 加字段** `result_id BIGINT` + `results_ready_at DATETIME`(通过迁移)
|
|
|
+2. [ ] **`AssessmentOrder.status` 枚举扩展**:`pending|paid|results_ready|completed|cancelled|refunded`
|
|
|
+3. [ ] **`GuideRecordController.record()` 写结果后联动更新订单**:
|
|
|
+ - 用 `appointmentId` 反查 order,写入 `orders.result_id = danAssessmentResult.id`、`status='results_ready'`、`results_ready_at=now()`
|
|
|
+ - 若找不到对应 order(直接录入场景),跳过
|
|
|
+4. [ ] **`AssessmentOrderController` 新增端点** `POST /api/assessment/order/{id}/ack-results`:家长确认收到结果,订单 status → `completed`
|
|
|
+5. [ ] **家长端 polling 或消息推送**:订单变 `results_ready` 触发小程序模板消息
|
|
|
+ - 复用 `MessageService.sendTemplateMessage` 已有路径
|
|
|
+ - 消息类型 `assessment_results_ready`
|
|
|
+6. [ ] **`AssessmentOrderController.detail` 返回** `resultId`、`resultsReadyAt`、`resultSnapshot`(精简版 DanAssessmentResult)
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] 规划师录入结果 → 订单 status=results_ready、result_id 非空
|
|
|
+- [ ] 家长端收到模板消息
|
|
|
+- [ ] `POST /order/{id}/ack-results` 后变 completed
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 付费→结果链闭合
|
|
|
+- 无"我付费了为啥看不到结果"客服投诉
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G13. 地址数据库全量导入 + 废弃静态列表
|
|
|
+
|
|
|
+**优先级:** P1
|
|
|
+**估算:** 3-4 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `DatabaseInitializer.java`(seed)、`RegionController.java`、`Street.java`、新建 `china_regions.csv`/SQL
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **获取权威数据源**:民政部《中华人民共和国县以上行政区划代码》最新版,或开源 `modood/Administrative-divisions-of-China` GitHub 仓库(CSV/JSON 格式,已包含省/市/区/街道 4 级)
|
|
|
+2. [ ] **生成 SQL 种子文件** `db/seed/china_regions.sql`:
|
|
|
+ - 用脚本把 CSV 转成 `INSERT INTO streets (...) VALUES ..., (...);` 批量语句
|
|
|
+ - 预计 ~3000 省/市/区/街道行(不动 countries)
|
|
|
+3. [ ] **`DatabaseInitializer` 添加迁移**:检测 streets 表行数 < 1000 时执行种子导入
|
|
|
+ - 跑导入前先 `DELETE FROM streets WHERE province_code != '44'` 清掉旧的广东测试数据(或保留并 IDUPSERT)
|
|
|
+ - 用 `INSERT IGNORE` 或 `ON DUPLICATE KEY UPDATE` 保证幂等
|
|
|
+4. [ ] **`RegionController` 改造**:
|
|
|
+ - 删除 `ALL_REGIONS` 静态硬编码列表
|
|
|
+ - 所有端点改为查询 `StreetService`
|
|
|
+ - `/api/region/streets` 真正返回街道数据
|
|
|
+5. [ ] **`UserAddress` 表加 `street_id BIGINT` 外键**(保留旧 `street` 字符串字段以兼容历史数据),新建地址时填 street_id
|
|
|
+6. [ ] **`user_address` 和 `danshop_addresses` 写入逻辑**:地址选择器只允许从 streets 表选,强制 street_id 非空
|
|
|
+7. [ ] **前端 `AddressPicker` 组件**:四联动从 `/api/region/provinces` → cities → districts → streets 拉 DB 数据
|
|
|
+8. [ ] **区域回退匹配保留**:`StreetService.matchByStreetWithFallback()` 仍然有效,但现在数据齐了,回退少触发
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] `SELECT COUNT(*) FROM streets` ≈ 3000+
|
|
|
+- [ ] 选北京/上海/西藏任意地址都能选到街道级
|
|
|
+- [ ] `RegionController` 全部端点单测 PASS
|
|
|
+- [ ] `AddressPicker` 组件在家长地址页能选北京→朝阳→XX街道
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 95% 中国地区可填到街道
|
|
|
+- `RegionController` 中无任何硬编码
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G14. Dify 会话本地镜像表
|
|
|
+
|
|
|
+**优先级:** P1
|
|
|
+**估算:** 2.5 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** 新建 `chat_conversations`、`chat_messages` 表 + entity + service;`AIService.sendMessage` 加镜像写入;端点 fallback 读本地
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **新建 `chat_conversations` 表**
|
|
|
+ ```sql
|
|
|
+ CREATE TABLE IF NOT EXISTS chat_conversations (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ user_id BIGINT NOT NULL,
|
|
|
+ dify_conversation_id VARCHAR(64) NOT NULL,
|
|
|
+ assistant_type VARCHAR(32) NOT NULL COMMENT 'family/nutrition/tongue',
|
|
|
+ title VARCHAR(200),
|
|
|
+ last_message_at DATETIME,
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ UNIQUE KEY uk_dify_conv (dify_conversation_id),
|
|
|
+ INDEX idx_user (user_id)
|
|
|
+ ) ENGINE=InnoDB;
|
|
|
+ ```
|
|
|
+2. [ ] **新建 `chat_messages` 表**
|
|
|
+ ```sql
|
|
|
+ CREATE TABLE IF NOT EXISTS chat_messages (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ conversation_id BIGINT NOT NULL,
|
|
|
+ dify_message_id VARCHAR(64),
|
|
|
+ role VARCHAR(20) NOT NULL COMMENT 'user/assistant',
|
|
|
+ content TEXT NOT NULL,
|
|
|
+ inputs_json TEXT,
|
|
|
+ metadata TEXT COMMENT '推荐标签解析结果等',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ INDEX idx_conv (conversation_id)
|
|
|
+ ) ENGINE=InnoDB;
|
|
|
+ ```
|
|
|
+3. [ ] **新建 `ChatConversation` / `ChatMessage` entity、Mapper、Service**
|
|
|
+4. [ ] **`AIService.sendMessage` 改造**:
|
|
|
+ - 收到 Dify 响应后异步落库(`@Async` 或直接同步,量大时再优化)
|
|
|
+ - 给响应中 `conversationId` 自动 upsert `chat_conversations`(找不到插一条)
|
|
|
+ - 给 user message + assistant message 各插一条 `chat_messages`
|
|
|
+5. [ ] **`AIChatController.getConversations` / `getMessages` 加 fallback**:
|
|
|
+ - 优先调 Dify,状态码 != 200 时读本地 `chat_messages`
|
|
|
+ - 完全不调 Dify 时返回本地数据( потенц future 应用:纯本地查询模式)
|
|
|
+6. [ ] **新增端点** `POST /api/ai/chat/search-history`:用户全文搜索自己历史对话(搜 content LIKE)
|
|
|
+
|
|
|
+#### 验证
|
|
|
+
|
|
|
+- [ ] 调 `sendMessage` 后 `chat_messages` 表 2 条新记录
|
|
|
+- [ ] 关闭 Dify 访问(DNS 指向 127.0.0.1),调 `getMessages` 仍能返回历史(来自本地)
|
|
|
+- [ ] `search-history` 能从消息中搜到关键词
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- Dify 短时不可用不影响用户查历史
|
|
|
+- Dify 永久切换 API key 时本地仍有完整对话(至少 LLM 返回过的)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## P2 数据质量与可维护性(下季度修)
|
|
|
+
|
|
|
+### G23. 迁移版本表 schema_versions
|
|
|
+
|
|
|
+**优先级:** P2
|
|
|
+**估算:** 3 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `DatabaseInitializer.java`、新建 `SchemaVersion` entity
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **新建 `schema_versions` 表**
|
|
|
+ ```sql
|
|
|
+ CREATE TABLE IF NOT EXISTS schema_versions (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ version_code VARCHAR(64) NOT NULL UNIQUE COMMENT '如 migration_2026_07_10_001',
|
|
|
+ description VARCHAR(500),
|
|
|
+ checksum VARCHAR(64) COMMENT 'SHA256 of SQL content',
|
|
|
+ applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ applied_by VARCHAR(100)
|
|
|
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
+ ```
|
|
|
+2. [ ] **重构 `DatabaseInitializer.runMigrations()`**:
|
|
|
+ - 把现有 200+ ad-hoc migration 归类到 batch(可保留 try-catch 包装每个,但每个 batch 完成后插一行 `schema_versions`)
|
|
|
+ - 提供 helper `migrationApplied(versionCode)` 查表,已跑则跳过
|
|
|
+ - 启动时第一阶段:创建 `schema_versions` 表
|
|
|
+ - 第二阶段:遍历 batch list,对每个未应用的 batch 执行 SQL then 插入记录
|
|
|
+3. [ ] **不立即重构所有现存迁移**(206 个分散的 try-catch),先冻结现状并按"重要 batch"切割为 5-10 个 version_code(如 `base_schema_v1`, `growth_record_rebuild_v1`, `address_seed_v1`)
|
|
|
+4. [ ] **新加迁移强制使用新机制**:未来所有 DDL 必须用 `runMigration("version_X", "描述", () -> { ... })` helper
|
|
|
+5. [ ] **`/api/admin/schema-versions`** 端点:admin 查看已跑迁移(只读)
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 启动跳过所有已跑 batch
|
|
|
+- 失败有日志可追溯(不只是吞掉)
|
|
|
+- 历史迁移保留兼容,新迁移走新机制
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G25. schema.sql 对账补齐
|
|
|
+
|
|
|
+**优先级:** P2
|
|
|
+**估算:** 2 人天
|
|
|
+**前置依赖:** G23(共享版本机制后对账更准)
|
|
|
+**影响文件:** `schema.sql`、新建脚本 `tools/validate-schema-sync.ps1`
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **跑脚本枚举 DatabaseInitializer + entity @TableName**:
|
|
|
+ ```ps
|
|
|
+ # 列出所有 DatabaseInitializer 中 CREATE TABLE 的表名
|
|
|
+ # 列出所有 @TableName 注解的 entity 表名
|
|
|
+ # 比对 schema.sql 中 CREATE TABLE 表名
|
|
|
+ ```
|
|
|
+2. [ ] **补齐 schema.sql 缺失的 ~40 张表**:
|
|
|
+ - danshop_* 10 张
|
|
|
+ - emotion_checkin、bazi_reading_template、numsoul_config、nutrition_indicator_mapping、nutrition_deficiency_record
|
|
|
+ - food_recommend_idx、family_invitations、member_discount_configs、article_reading_records、article_quiz_records
|
|
|
+ - health_dimension_scores、health_data_sources、health_norm_reference
|
|
|
+ - user_nutrition_profile、user_food_preference
|
|
|
+ - G1 新增的 five_dimension_scores、G14 新增的 chat_conversations/chat_messages、G9/G10 增强表
|
|
|
+3. [ ] **补齐已有表缺失的列**:扫描 entity 字段 vs schema.sql 列名,diff 出缺失字段补到 schema.sql
|
|
|
+4. [ ] **新增 CI 脚本** `tools/validate-schema-sync.ps1`:每次提交时跑对账,diff 非空时 fail build
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 从 schema.sql 重建数据库,应用启动时无"列不存在/表不存在"错误
|
|
|
+- CI 对账脚本 PASS
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G3. 测评报告核心结论结构化
|
|
|
+
|
|
|
+**优先级:** P2
|
|
|
+**估算:** 3 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `DanAssessmentResult`、`GuideRecordController`、`AssessmentService`、web 端规划师录入表单
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **`DanAssessmentResult` 新增字段 `structured_analysis JSON`**(保留 `analysis_report TEXT` 兼容历史)
|
|
|
+2. [ ] **结构化 schema**:
|
|
|
+ ```json
|
|
|
+ {
|
|
|
+ "summary": "一句话总评",
|
|
|
+ "dimensions": [
|
|
|
+ {"code":"wisdom","score":82,"level":"good","indicator":"logicScore","comment":"逻辑推理突出"},
|
|
|
+ ...5个维度...
|
|
|
+ ],
|
|
|
+ "highlight_strengths": ["专注力","记忆容量"],
|
|
|
+ "concern_areas": ["情绪管理"],
|
|
|
+ "suggestions": [
|
|
|
+ {"dimension":"mind","action":"每日 15 分钟正念练习","target":"3 周后 EMI 自我觉察 ≥75"},
|
|
|
+ ...
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ```
|
|
|
+3. [ ] **`GuideRecordController.create()`** 接收 structured_analysis JSON,验证字段必填
|
|
|
+4. [ ] **新增端点** `POST /api/dan-assessment/results/{id}/structured`:返回结构化数据(家长端用)
|
|
|
+5. [ ] **`ReportParseService` 改造**:从 AI prompt 解析时产出 structured_analysis 一起入库
|
|
|
+6. [ ] **家长端 `cognitive-report.vue`** 渲染结构化维度块、建议列表
|
|
|
+7. [ ] **web 端规划师录入表单**:从单 big textarea 改为分维度表单(强制总结 + 5 维度评分 + 至少 2 个 strength + 1 个 concern)
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 测评报告机读可解析
|
|
|
+- 家长端统一卡片式展示,不再看规划师散文
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G26. test-mode 启动断言
|
|
|
+
|
|
|
+**优先级:** P2
|
|
|
+**估算:** 0.5 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `WechatService`、`application.yml`
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **`WechatService` 加 `@PostConstruct` 启动检查**
|
|
|
+ - 注入 `@Value("${spring.profiles.active:dev}")` 与 `@Value("${wechat.test-mode:false}")`
|
|
|
+ - 若 prod 且 test-mode=true:`throw new IllegalStateException("wechat.test-mode=true in production profile!")`
|
|
|
+2. [ ] **CI 检查**:新增 `tools/check-test-mode.ps1` 扫描 `application-prod*.yml` 有 `test-mode: true` 时 fail
|
|
|
+3. [ ] **日志告警**:dev 环境下 test-mode=true 时 startup 日志输出 WARN(不是错误,但提醒)
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- prod 下 test-mode=true 启动必失败
|
|
|
+- CI 拦截配置错误
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G15. 饮食推荐规则层 + 解释链 + 推荐日志
|
|
|
+
|
|
|
+**优先级:** P2
|
|
|
+**估算:** 3-4 人天
|
|
|
+**前置依赖:** 无(G14 同期,可并行)
|
|
|
+**影响文件:** `MealRecommendService`、新建 `RecommendationRuleService`、`recommendation_log` 表
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **新建 `recommendation_log` 表**
|
|
|
+ ```sql
|
|
|
+ CREATE TABLE IF NOT EXISTS recommendation_log (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ user_id BIGINT NOT NULL,
|
|
|
+ family_member_id BIGINT,
|
|
|
+ recommendation_type VARCHAR(32) NOT NULL COMMENT 'meal/supplement/exercise',
|
|
|
+ recommended_item VARCHAR(200),
|
|
|
+ explanation_json TEXT COMMENT '规则命中的解释',
|
|
|
+ dify_response_snippet VARCHAR(500),
|
|
|
+ accepted BOOLEAN,
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ INDEX idx_user_time (user_id, created_at)
|
|
|
+ ) ENGINE=InnoDB;
|
|
|
+ ```
|
|
|
+2. [ ] **新建 `RecommendationRuleService`**:在 Dify 之前执行确定性规则层
|
|
|
+ - 输入:用户健康指标、营养偏好、缺失项
|
|
|
+ - 规则示例:
|
|
|
+ - 若缺铁 → 推荐含铁食材(来自 Food 表 `nutritionTags contains 'iron'`)+ reason="你的健康报告显示铁含量低,建议补充含铁食物"
|
|
|
+ - 若过敏原 `user_food_preference.allergens` 非空 → 排除 candidate foods 中含过敏原的
|
|
|
+ - 输出结构化 result:`{recommendations:[...], reason: "规则命中文本"}`
|
|
|
+3. [ ] **`MealRecommendService.buildDifyInputs()` 改造**:
|
|
|
+ - 先跑规则层得到候选食物列表
|
|
|
+ - 把规则层候选作为 Dify 的"must include"约束
|
|
|
+ - 规则层结果与 Dify 结果合并,规则层 reason 优先展示
|
|
|
+4. [ ] **`DietaryRestrictions` 不再硬编码**:从 `user_food_preference` 表读 allergens、restrictions
|
|
|
+5. [ ] **`MealRecommendController.recommend` 落库**:每次返回前插 `recommendation_log`
|
|
|
+6. [ ] **新增端点** `POST /api/meal/recommend/{logId}/accept`:用户标记采纳/拒绝,回填 accepted 字段
|
|
|
+7. [ ] **新增端点** `POST /api/meal/recommendations/history`:用户查历史推荐
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 用户问"为什么推荐菠菜" → 平台有确定性答案("你铁含量低,建议补铁")
|
|
|
+- 过敏原强制过滤生效
|
|
|
+- 推荐有审计日志
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## P3 长期治理
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G4. 清理死字段 / 接入消费者
|
|
|
+
|
|
|
+**优先级:** P3
|
|
|
+**估算:** 2 人天
|
|
|
+**前置依赖:** G3(测评结构化后死字段已部分被替代)
|
|
|
+**影响文件:** `DanAssessmentResult`、`ReportParseService`
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **审计 4 个字段**:
|
|
|
+ - `resultDesc` + `processDesc`:规划师原来填的,G3 改为结构化后已经无意义 → **删除**(迁移 DROP COLUMN,实体删字段)
|
|
|
+ - `gameRecords JSON`:有价值(原始测试数据),但无消费者 → **接入**:新增端点 `POST /api/dan-assessment/results/{id}/game-records` 返回解析后数据;`EnergyService.calcChildWisdom` 可读 gameRecords 替代部分 INT 字段
|
|
|
+ - Big Five(openness..neuroticism 6 列):有学术价值但无解读 → **接入**:新增 `BigFiveNormService` 做百分位换算;`cognitive-report.vue` 增"人格倾向"模块
|
|
|
+2. [ ] **删字段迁移**:
|
|
|
+ ```java
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("ALTER TABLE dan_assessment_results DROP COLUMN result_desc");
|
|
|
+ jdbcTemplate.execute("ALTER TABLE dan_assessment_results DROP COLUMN process_desc");
|
|
|
+ } catch (Exception e) {}
|
|
|
+ ```
|
|
|
+3. [ ] **同步删 entity 字段、`DanAssessmentResult.java`**
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- DB 表无死字段
|
|
|
+- 保留字段全部有端点读
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G21. PDF 解析多供应商抽象 + OCR 后备 + 置信度
|
|
|
+
|
|
|
+**优先级:** P3
|
|
|
+**估算:** 3-4 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `PdfParseService` 重构为 `ReportParserRegistry`、`ReportParser`接口、各供应商 parser impl
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **定义接口 `ReportParser`**
|
|
|
+ ```java
|
|
|
+ interface ReportParser {
|
|
|
+ String supplierCode();
|
|
|
+ boolean canHandle(String pdfTextOrFilename);
|
|
|
+ ParsedReport parse(byte[] pdfBytes) throws ReportParseException;
|
|
|
+ }
|
|
|
+ ```
|
|
|
+2. [ ] **抽象 `ReportParserRegistry`**:注入所有 `ReportParser` bean,按 `canHandle` 自动路由
|
|
|
+3. [ ] **现有募极逻辑移到 `MujiGutReportParser implements ReportParser`**(保留 868 行现有代码)
|
|
|
+4. [ ] **新增通用 fallback `SimpleScoreReportParser`**:现有 ReportParseService 抽出
|
|
|
+5. [ ] **解析结果加 `ParseConfidence`**:枚举 HIGH/MEDIUM/LOW;分数全找到=HIGH,<50% 字段找到=LOW
|
|
|
+6. [ ] **`HealthReportDraft` 加 `confidence_level` 字段**,preview 端点返回
|
|
|
+7. [ ] **OCR 后备**:若 PDF 文本提取为空(扫描版),调用腾讯云 OCR 或阿里 OCR 把图像转文本后走相同 parser
|
|
|
+8. [ ] **`HealthReportController.parse-preview` 入参加 `supplierHint`**:可显式指定供应商
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 至少 2 个供应商 parser 并行工作
|
|
|
+- 扫描版 PDF 能 OCR 后解析
|
|
|
+- 解析失败有 confidence 信号
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G27. sfms DataSource 唯读强制
|
|
|
+
|
|
|
+**优先级:** P3
|
|
|
+**估算:** 0.5 人天
|
|
|
+**前置依赖:** 无
|
|
|
+**影响文件:** `SfmsDataSourceConfig`
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **HikariConfig 加 `setReadOnly(true)`**
|
|
|
+2. [ ] **连接初始化 SQL**:`connection-test-query: SET SESSION TRANSACTION READ ONLY`(MySQL 8 支持会话级 read only)
|
|
|
+3. [ ] **`sfmsJdbcTemplate.execute()` 包装层**:新增 `SfmsReadOnlyJdbcTemplate extends JdbcTemplate`,覆写所有 write 方法抛 `UnsupportedOperationException`
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 任何 write 操作 fail-fast
|
|
|
+- DataMigration 仍能 read 正常
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### G22. 前端 mock 数据 fallback 处理
|
|
|
+
|
|
|
+**优先级:** P3
|
|
|
+**估算:** 1.5 人天
|
|
|
+**前置依赖:** G1(G1 完成后真正有数据可用)
|
|
|
+**影响文件:** `growth-curve.vue`、`health-trend.vue`、`stats/index.vue`、`dimension-detail.vue`
|
|
|
+
|
|
|
+#### 修复步骤
|
|
|
+
|
|
|
+1. [ ] **`growth-curve.vue` 第 135 行 mock 移除**
|
|
|
+ - API 调用失败时显示 Element 风格 EmptyState "暂无记录" 按钮"添加首次测量"
|
|
|
+ - 不允许显示假数据
|
|
|
+2. [ ] **`health-trend.vue` 第 109-132 行 mock 移除**,改为 EmptyState
|
|
|
+3. [ ] **统一 `EmptyState` 组件** `components/EmptyState.vue`:图标 + 提示 + CTA
|
|
|
+4. [ ] **dev 模式 mock 数据通过环境变量开关**:`process.env.NODE_ENV === 'development' && uni.getStorageSync('useMock')` 才显示 mock,且页面顶部加 "演示数据" 红色 banner
|
|
|
+
|
|
|
+#### 完成条件
|
|
|
+
|
|
|
+- 生产环境不出现假数据
|
|
|
+- dev 调试仍可用 mock,但显式可见
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 待办依赖与并行机会图
|
|
|
+
|
|
|
+```
|
|
|
+P0:
|
|
|
+ G7 ──┐
|
|
|
+ G1 ──┬──── G18 (建议 G1 先)
|
|
|
+ │
|
|
|
+P1: │
|
|
|
+ G8 ───┤ (G8 不依赖 G1)
|
|
|
+ G9 ───┬─ G10
|
|
|
+ G13 ──┤ (G13 与 G1 并行)
|
|
|
+ G14 ──┤ (G14 与 G1 并行)
|
|
|
+ G2 ───┘ (G2 与其他独立)
|
|
|
+ │
|
|
|
+P2: │
|
|
|
+ G23 ──┬─ G25 (G23 先,对账机制)
|
|
|
+ G26 ──┤ (G26 独立)
|
|
|
+ G3 ───┤ (G3 与 G23 并行)
|
|
|
+ G15 ──┤ (G15 与 G23 并行)
|
|
|
+ │
|
|
|
+P3: │
|
|
|
+ G27 ──┐ (G27 独立)
|
|
|
+ G22 ──┤ (G22 等 G1)
|
|
|
+ G21 ──┤ (G21 独立)
|
|
|
+ G4 ───┘ (G4 等 G3)
|
|
|
+```
|
|
|
+
|
|
|
+### 可立即并行的任务(同周内)
|
|
|
+
|
|
|
+- 第 1 周:G7 + G1 + G18(按序完成,但 G1 启动后 G18 可同时开始)
|
|
|
+- 第 2 周:G8 / G9+G10 / G13 / G14 / G2 / G26(六个并行)
|
|
|
+- 第 4 周:G23 / G3 / G25(等G23) / G15 / G21 / G27(六个并行)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 整体验收清单
|
|
|
+
|
|
|
+- [ ] 所有 P0 任务 `mvn clean compile` 通过 + 单测 PASS
|
|
|
+- [ ] `FamilyAccessInterceptorTest` 全 PASS(G7)
|
|
|
+- [ ] 调 `/api/energy/score-history` 返回时间序列(G1)
|
|
|
+- [ ] `/api/assessment/compare/by-child` 返回 delta(G18)
|
|
|
+- [ ] 切回家长提示输密码(G8)
|
|
|
+- [ ] 邀请码过期后 accept 返回 410(G9)
|
|
|
+- [ ] 规划师绑定家庭家长需要 approve(G10)
|
|
|
+- [ ] 订单 status=results_ready 触发模板消息(G2)
|
|
|
+- [ ] 北京地址能选到街道(G13)
|
|
|
+- [ ] 关闭 Dify 后能查历史对话(G14)
|
|
|
+- [ ] schema.sql 重建数据库可用(G25)
|
|
|
+- [ ] prod 启动含 test-mode=true 失败(G26)
|
|
|
+- [ ] "为什么推荐菠菜" 平台能答(G15)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 风险与回滚策略
|
|
|
+
|
|
|
+- **每个任务独立迁移**:用 `try-catch` 包裹,失败不阻塞应用启动
|
|
|
+- **G7 拦截器**:先以日志模式上线(不实际拦截只打 warn),观察 3 天后切为强制模式
|
|
|
+- **G8 视角切换合并**:保留旧端点返回 308 redirect 一周,再删除
|
|
|
+- **G13 地址数据**:上线前先 `SELECT` 校验种子数据完整性,确保关键省/市/区无空缺
|
|
|
+- **G3 结构化报告**:保留 `analysis_report TEXT` 字段,新加 `structured_analysis JSON` 并行存在 3 个月,再淘汰旧字段
|
|
|
+- **G14 Dify 镜像**:先只写本地不切读取路径,观察 1 周数据完整后再 enable fallback read
|
|
|
+- **G1 五维快照**:探针期间每条线都计算 + persist;观察性能不降级再优化为异步
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 后续工作(不在本计划)
|
|
|
+
|
|
|
+- 家长端可视化(趋势图对比图)—— 等 G1 数据积累 1 个月再返工前端图表
|
|
|
+- AI 健康助手对历史对话做 RAG 检索 —— 等 G14 数据 2 周
|
|
|
+- 知识库与测评结果自动联动(G16)—— 等 G3 结构化分析上线后接入
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 修订记录
|
|
|
+
|
|
|
+| 版本 | 日期 | 修订内容 |
|
|
|
+|:----:|:----:|------|
|
|
|
+| v1.0 | 2026-07-10 | 首次发布,18 任务 4 优先级 |
|