Просмотр исходного кода

docs(specs): 食材推荐按健康问卷个性化调整 需求变更+设计

新增 specs 文档:报告解析食材推荐须按健康问卷(饮食习惯/过敏/用药/主要健康体症)个性化调整。
TDD 入池 diet.food.healthAdjust(UI) 能力键 RED 登记(S-cfc-96/97)。
溯源:用户 2026-11-09 拍板。
Agnes Agent 4 дней назад
Родитель
Сommit
404f9f74d2

+ 125 - 0
docs/superpowers/specs/2026-11-09-diet-recommendation-health-questionnaire-adjust.md

@@ -0,0 +1,125 @@
+# 需求变更 · 食材推荐按健康问卷个性化调整
+
+> **来源**:用户 2026-11-09 拍板(`@skill:tdd-orchestrator`):报告解析出的食材推荐,还要根据用户健康问卷中的**饮食习惯、过敏反应、用药情况、主要健康体症**做个性化调整。
+> **最新复盘数据**:2026-11-09 00:39 只读核查实证(见 §1 行号引用)。
+> **涉及时面**:后端 `FoodRecommendService` / `HealthFilterService` / `BeijingNutritionService` + 前端 `cfc-frontend/pages/diet/recommendation.vue`。
+> **状态**:新需求,RED 入池待派发(非契约补建)。
+> **总开发目标锚点**:用户场景(`tests/user-scenarios/应用场景总览.md`)端到端跑通。
+
+## 1. 现状数据流(2026-11-09 只读核查实证)
+
+「用户在报告中看到的食材推荐」= **报告解析产出 → food_recommend_idx → `/api/health/foods` 展示**,全程**未经健康问卷过滤**:
+
+```
+报告确认/解析 → FoodRecommendService.recalculateForUser (L97-237)
+  ├─ 优先 foodService.listByReportId(report.getId()) (L116)
+  ├─ 空则回退 report_food_suitability (L118-138)
+  └─ 写入 food_recommend_idx(foodId + indexScore,L151-220)
+
+前端 recommendation.vue loadFoods() (L491-523)
+  └─ POST /api/health/foods(无 reportId,L497)
+       → HealthReportController.listFoods (L1964-1981)
+            └─ 无 reportId 分支:getCurrentFoodsAsFoodList(memberId ?? JWT userId) (L1978)
+                 → FoodRecommendService.getCurrentFoods (L283-317)
+                      └─ food_recommend_idx join foods → List<Food>
+                           (仅 name/category/score/protein/fat/carbs/fiber/energyKj,L300-314)
+前端 filteredFoods computed (L285-334):按 score 分「适合(>=0)/戒备(<0)」
+```
+
+**缺口实证**:
+1. `FoodRecommendService.getCurrentFoods`(L283-317)**从未调用** `HealthFilterService`——过敏/用药/疾病/体症/饮食习惯五个维度全部缺位。
+2. `HealthFilterService`(L39-320)已具备完整三级规则引擎:`loadProfile`(L89-136,HealthStatus 疾病/用药 + DietPreferences 过敏)、`checkProductBlocks`(L169-205,商品级硬拦截)、`checkDrugInteractions`(L210-238,药物-食物风险分级 risk1 硬拦截/risk2-3 软警告)、`isFoodHardBlocked`(L243-253,disease_intake_limits.block_foods)——**已可用,只需接入**。
+3. `HealthFilterService.loadProfile` 未读 `HealthStatus.lifestyle.allergySymptoms`(过敏症状,L50 实体注释)——体症/症状维度未入画像。
+4. `BeijingNutritionService.generateIngredientList`(饮食首页降级菜单链,L577-820)已做过敏/绝对避免过滤(L713)+ 疾病硬拦截(L714),但**未调用 `checkDrugInteractions`**(用药维度缺位)、未做宗教饮食(Food.dietType)与 mustEat 饮食习惯标记——与报告页口径不一致。
+5. `Food.dietType`(L45,none/清真/halal/素食/vegan)、`Food.allergens`(L35)字段存在但当前推荐链未用。
+6. 前端 `loadFoods` map(L511-516)只透传 score/status,无任何调整提示字段。
+
+## 2. 健康问卷四维度 → 调整规则映射(单一事实源)
+
+「健康问卷」= `health_status`(主要健康体症/疾病史/用药,实体 `HealthStatus.java` L14-63)+ `diet_preferences`(饮食习惯/过敏/避免/必须吃,实体 `DietPreferences.java` L12-34)。
+
+| # | 维度 | 数据源字段 | 调整规则 | 等级 |
+|---|---|---|---|---|
+| D1 | 过敏反应 | `diet_preferences.allergies`(JSON[string]);`health_status.lifestyle.allergySymptoms`(对象内字段) | 食材名或 `foods.allergens` 命中过敏原 → 标记 `blocked`,原因「过敏原 X 命中,建议不食」 | 硬拦截(blocked) |
+| D2 | 用药情况 | `health_status.medications`(JSON[{name,note}]) | `drug_interactions` 命中:risk_level=1 → `blocked`(药物-食物禁忌硬拦截);risk_level=3 → `warning`(「药物 X 与 Y 需间隔 N 分钟」);risk_level=2 → `warning`(建议咨询医生) | blocked / warning |
+| D3 | 主要健康体症 | `health_status.disease_history`(JSON[{name,note}]) | 命中 `disease_intake_limits.block_foods` → `blocked`(「疾病 X 禁忌食用」);疾病-营养素阈值超限在菜单聚合侧提示(本期不落) | 硬拦截(blocked) |
+| D4 | 饮食习惯 | `diet_preferences.absolute_avoid`(JSON[string]) | 食材名命中 → `blocked`「该成员明确不吃」 | 硬拦截(blocked) |
+| D5 | 饮食习惯 | `diet_preferences.religious_diet`(如 素食/清真) | 与 `foods.diet_type` 不匹配 → `blocked`「饮食禁忌(宗教/素食)」;`none` 或空 → 不拦 | 硬拦截(blocked) |
+| D6 | 饮食习惯 | `diet_preferences.must_eat`(JSON[string]) | 食材名命中 → `boost`「符合饮食习惯偏好」,前端置顶/高亮 | 提升(boost) |
+| D7 | 饮食习惯 | `diet_preferences.diet_structure`({vegetableFruit,dairy,sugaryDrinks}) | 供前端展示「饮食结构提醒」文本(本期只透传,不做排序干预) | 展示(warning 文本) |
+| D8 | 年龄体症 | `family_members.age` + 食材名(蜂蜜/整粒坚果) | <1 岁蜂蜜、<3 岁整粒坚果 → `blocked`(现有 AGE_BLOCK_RULES L56-60 复用) | 硬拦截(blocked) |
+
+> 分级语义(叠加在现有 score「适合/戒备」之上的**第二层个性化调整**):
+> - `adjustment=blocked`:健康问卷硬性不建议,前端红色提示 + 「适合」Tab 降到底部
+> - `adjustment=warning`:软警告/间隔提示,前端黄色提示,位置不变
+> - `adjustment=boost`:明确偏好,前端绿色高亮/置顶
+> - `adjustment=normal`:无调整
+
+## 3. 实现落点
+
+### 后端(主改,任务 A:`diet.food.healthAdjust`)
+
+**A1. `HealthFilterService` 增「按食材检查」便捷方法(复用既有规则,适配 Food)**
+- `public List<String> checkFoodBlocks(HealthProfile profile, Food food)`:逻辑对齐 `checkProductBlocks`(L169-205)——`food.name`/`food.allergens`(L35)作为命中面,疾病(+block_foods 需另走 `isFoodHardBlocked` L243-253)、用药(`checkDrugInteractions` 参数改为 {name, allergenTags} 或轻量适配)、年龄禁忌(AGE_BLOCK_RULES L56-60)全部复用。
+- `public List<String> checkDietPreferenceAdjust(HealthProfile profile, DietPreferences prefs, Food food)`:D4 绝对避免 / D5 宗教饮食(`food.dietType` 与 prefs.religiousDiet 匹配,`none`/空⇢不拦)/ D6 mustEat boost 判定,返回结构化结果(block 原因列表 + boost 布尔)。
+- `loadProfile`(L89-136)补充:`HealthStatus.lifestyle` 解析 `allergySymptoms` 并入 `allergies`(体症维度入画像),兼容 lifestyle 为 JSON 对象。解析失败静默(现有 try/catch 风格)。
+
+**A2. `FoodRecommendService.getCurrentFoods`(L283-317)接入调整**
+- 起始解析目标成员:入参 userId(可能为 memberId)经 `resolveFoodIndexUserId`(L78-95)归一为写侧键;`loadProfile(memberId, userId, age)` 取 `HealthProfile`;`dietPreferencesMapper` 按 familyMemberId 取 `DietPreferences`(同 `generateIngredientList` L643-647 取法)。memberId 需从 userId 反查(family_members.user_id → id),无则退回 userId 级画像。
+- 每个食材构建 Map 时(L300-314)追加:
+  - `adjustment`(normal/blocked/warning/boost,D1-D8 规则按序判定,blocked 优先)
+  - `adjustmentReason`(String,命中首条原因;多条用「;」连接)
+- 顺序:blocked(D1/D2-risk1/D3/D4/D5/D8)> warning(D2-risk2/3)> boost(D6)> normal。
+- **不删除食材**(保留前端「戒备」语义与报告快照完整),仅打标。
+
+**A3. `getCurrentFoodsAsFoodList`(L320-342)透传标记**
+- `Food` 实体加 `@TableField(exist = false)` 瞬态字段 `adjustment` / `adjustmentReason`(不动库表),此处 new Food 时同步 set。
+
+**A4. `HealthReportController.listFoods`(L1964-1981)**
+- **无改动**。有 `reportId` 分支(报告详情页展示,`foodService.listByReportId` L1972)保持「报告原始解析快照」不变——个性化调整只作用于用户「当前推荐」链(无 reportId 分支),避免报告详情与历史快照失真。
+
+**A5. 口径一致性(mouth一致性):`BeijingNutritionService.generateIngredientList`(L577-820)补齐**
+- L708 循环内补调 `checkDrugInteractions`(用药维度):食物名/`food.allergens` 命中交互 → risk1 剔除或降权、risk2/3 提示。
+- 宗教饮食过滤:`Food.dietType` vs 参与者 `DietPreferences.religiousDiet` 合并集(任一参与者不匹配即剔除;现有 L713 过敏/避免同款合并口径)。
+- 保持现有分类配额(L764-795)与 Top10-12 截断(L782-795)不变。
+
+### 前端(任务 B:`diet.food.healthAdjustUi`)
+
+**B1. `loadFoods`(recommendation.vue L511-516)**:map 透传 `adjustment` / `adjustmentReason`(旁置现有 scoreClass/statusText 映射 L512-514)。
+
+**B2. 食物卡片(L104-123)**:按 `item.adjustment` 渲染提示条:
+- `blocked` → 红条「⚠ 健康问卷不建议:{adjustmentReason}」
+- `warning` → 黄条「注意:{adjustmentReason}」
+- `boost` → 绿条「✓ 符合饮食习惯偏好」
+- `normal`/空 → 不渲染。
+
+**B3. `filteredFoods`(L285-334)**:
+- `suitable` Tab:`adjustment==='blocked'` 项排到列表末尾(保持可见但不置顶)。
+- `unsuitable` / `all`:位置不变,仅带提示条。
+- 排序逻辑(L322-331)不变,仅 stable 分组:非 blocked 在前。
+
+## 4. 验收判据(V/C)
+
+1. 成员在饮食偏好填过敏原「花生」→ `/api/health/foods` 返回食材「花生」`adjustment=blocked`、`adjustmentReason` 含「过敏原花生」,前端红条提示;`suitable` Tab 排末尾。
+2. 成员 health_status 在服「二甲双胍」且 foods/allergens 命中 `drug_interactions` 对应食物(如西柚)→ risk1 返回 `blocked`;risk3/2 返回 `warning` + 间隔分钟/咨询医生文案。
+3. 成员疾病史「痛风」命中 `disease_intake_limits.block_foods`(如动物内脏)→ `blocked` + 「疾病痛风禁忌食用」。
+4. 绝对避免「香菜」→ `blocked`「该成员明确不吃」;`must_eat` 含「鸡蛋」→ 鸡蛋 `boost`。
+5. 宗教饮食「素食」时,`foods.diet_type` 为荤食的食材 → `blocked`;`diet_type` 为 none 的食材不受影响。
+6. 前端三种提示条正确渲染;「适合」Tab 无红色 blocked 项置顶;菜品生成链不受影响。
+7. `reportId` 分支(报告详情页)返回与改造前一致(快照不动)。
+8. `--clean-check` 干净;实现/文档提交已推 origin/cfclub;`py_compile`(若涉编排脚本)通过。
+
+## 5. 任务拆解(TDD 池)
+
+| 键 | 覆盖 | 阶段 | 溯源 |
+|---|---|---|---|
+| `diet.food.healthAdjust` | 后端 A1-A5(HealthFilterService 扩展 + FoodRecommendService 接入 + Food 瞬态字段 + generateIngredientList 口径补齐 + 单测) | RED | requirement:本文档 §1-2;design:本文档 §3(方法/行号)+ `HealthFilterService.java` L39-320 + `FoodRecommendService.java` L283-342 + `BeijingNutritionService.java` L577-820 |
+| `diet.food.healthAdjustUi` | 前端 B1-B3(recommendation.vue 透传 + 提示条 + filteredFoods 分组) | RED | requirement:本文档 §3 前端;design:本文档 §3 B1-B3 + `cfc-frontend/pages/diet/recommendation.vue` L104-123/L285-334/L491-523 |
+
+> 派发顺序约束:Ui 依赖后端 `adjustment`/`adjustmentReason` 字段契约(A2/A3),**必须串行派发**(healthAdjust 先合入 origin/cfclub,healthAdjustUi 后派)。
+
+## 6. 备注
+
+- 调整层去重与语义:`blocked` 优先于 `warning`/`boost`;同一食材多原因合并且原因最长取 2 条展示。
+- 不新增库表、不迁移数据;`health_status.lifestyle` 结构保持 `{mood, sleepDuration, bedtime, bowelFrequency, bowelForm, antibioticHistory, allergySymptoms}`(实体 L49-50 既有注释)。
+- 数据敏感:health_status / diet_preferences 属健康隐私数据,仅本成员/家庭上下文读取,接口不新增越权面(沿用 controller 既有 userId/familyId 属性鉴权)。