2026-07-10-determinism-gap-fix-plan.md 36 KB

浠艾福平台 · 确定性差距修复实施计划

版本: 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.javaWebConfig.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. [ ] 新增 FamilyAccessInterceptorconfig/FamilyAccessInterceptor.java

    • 拦截 /api/**(除 JwtInterceptor 已放行的 8 个公开路径)
    • 在 preHandle 阶段:解析 body 中的 familyId 字段(用 ContentCachingRequestWrapper 缓存 body 以便可重复读)
    • @RequestAttribute("userId")User.familyIdUser.teacherFamilyIds
    • 校验:body 中 familyId 必须等于 user.familyId teacherFamilyIds 列表中
    • 不通过:response.setStatus(403);返回 JSON {"code":403,"message":"无权访问该家庭数据"}
    • 通过:放行
  2. [ ] WebConfig.addInterceptors 注册 FamilyAccessInterceptor,order 在 JwtInterceptor 之后

  3. [ ] User 实体新增 helper getEffectiveFamilyIds():返回包含 familyId + 解析后的 teacherFamilyIdsSet<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.javaDatabaseInitializer.javaschema.sql

问题定位

EnergyService.calculateFamilyEnergy() 每次请求重算身/心/智/行/富 0-100 分;无任何表保存"X 孩子在 Y 日期的分数",导致:

  • 无法做趋势图、"上次 vs 本次"对比
  • 算法改一处,所有"历史成绩"瞬时漂移
  • AGENTS.md 宣传文案"自驱力 4.07→5.30"在系统中根本查不到

修复步骤

  1. [ ] 新建 five_dimension_scores

    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
  8. 验证

    • 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 返回:

        {
        "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 人天 前置依赖:影响文件: FamilyUserControllerUserServiceFamilyMembersController、前端 store/index.jspages/profile/*

    修复步骤

    1. 保留机制 3/api/family/member/switch(返回 SwitchMemberVO,不动 DB)——这是正确模型
    2. 废弃机制 1、2
      • FamilyUserController.switchMode → 改为 404 或重定向到 /member/switch
      • FamilyUserController.switchToChild → 同上
      • UserService.switchToChildswitchMode@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 改造
      • 移除 isSwitchedChildisSwitchedMember 双 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 人天 前置依赖:影响文件: FamilyControllerFamilyInviteControllerFamilyInvitationServiceUserAddress

    修复步骤

    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.guideBindFamilyfamilyId 视角下AMILY.getId() ≠ 邀请绑定的(endpoint 自然受 G7 拦截器保护,可保留)
    7. rate-limit:每家庭每天最多生成 10 个邀请码(用 Bucket4j 或简单 Redis 计数)

    验证

    • 同一邀请码使用次数超 maxUseCount 后 accept 返回 410
    • revoke 后立即失效
    • 老简易 invite-code 端点返回 410

    完成条件

    • 邀请码泄露可吊销
    • 暴力枚举有 rate limit 防护

    G10. 规划师绑定家庭加审批

    优先级: P1 估算: 2 人天 前置依赖: G9(共用邀请码机制) 影响文件: FamilyController.guideBindFamilyTeacherFamilyBindingRequest(新 entity)、FamilyController

    修复步骤

    1. [ ] 新建 teacher_family_binding_requests

      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 人天 前置依赖:影响文件: AssessmentOrderAssessmentOrderServiceGuideRecordControllerAssessmentService

    修复步骤

    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.idstatus='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 返回 resultIdresultsReadyAtresultSnapshot(精简版 DanAssessmentResult)

    验证

    • 规划师录入结果 → 订单 status=results_ready、result_id 非空
    • 家长端收到模板消息
    • POST /order/{id}/ack-results 后变 completed

    完成条件

    • 付费→结果链闭合
    • 无"我付费了为啥看不到结果"客服投诉

    G13. 地址数据库全量导入 + 废弃静态列表

    优先级: P1 估算: 3-4 人天 前置依赖:影响文件: DatabaseInitializer.java(seed)、RegionController.javaStreet.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 IGNOREON DUPLICATE KEY UPDATE 保证幂等
    4. RegionController 改造
      • 删除 ALL_REGIONS 静态硬编码列表
      • 所有端点改为查询 StreetService
      • /api/region/streets 真正返回街道数据
    5. UserAddress 表加 street_id BIGINT 外键(保留旧 street 字符串字段以兼容历史数据),新建地址时填 street_id
    6. user_addressdanshop_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_conversationschat_messages 表 + entity + service;AIService.sendMessage 加镜像写入;端点 fallback 读本地

    修复步骤

    1. [ ] 新建 chat_conversations

      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

      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)

    验证

    • sendMessagechat_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

      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

      # 列出所有 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 人天 前置依赖:影响文件: DanAssessmentResultGuideRecordControllerAssessmentService、web 端规划师录入表单

    修复步骤

    1. DanAssessmentResult 新增字段 structured_analysis JSON(保留 analysis_report TEXT 兼容历史)
    2. [ ] 结构化 schema

      {
       "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 人天 前置依赖:影响文件: WechatServiceapplication.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*.ymltest-mode: true 时 fail
    3. 日志告警:dev 环境下 test-mode=true 时 startup 日志输出 WARN(不是错误,但提醒)

    完成条件

    • prod 下 test-mode=true 启动必失败
    • CI 拦截配置错误

    G15. 饮食推荐规则层 + 解释链 + 推荐日志

    优先级: P2 估算: 3-4 人天 前置依赖: 无(G14 同期,可并行) 影响文件: MealRecommendService、新建 RecommendationRuleServicerecommendation_log

    修复步骤

    1. [ ] 新建 recommendation_log

      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(测评结构化后死字段已部分被替代) 影响文件: DanAssessmentResultReportParseService

    修复步骤

    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. [ ] 删字段迁移

      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 重构为 ReportParserRegistryReportParser接口、各供应商 parser impl

    修复步骤

    1. [ ] 定义接口 ReportParser

      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. [ ] HealthReportDraftconfidence_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. 连接初始化 SQLconnection-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.vuehealth-trend.vuestats/index.vuedimension-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 优先级