# 浠艾福平台 · 确定性差距修复实施计划 **版本:** 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` 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 - `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 优先级 |