Преглед на файлове

docs: add health nutrition gaps fix implementation plan (5 tasks)

Sisyphus преди 2 месеца
родител
ревизия
650d5cf44a
променени са 1 файла, в които са добавени 704 реда и са изтрити 0 реда
  1. 704 0
      docs/superpowers/plans/2026-06-22-营养健康部分缺陷修复计划.md

+ 704 - 0
docs/superpowers/plans/2026-06-22-营养健康部分缺陷修复计划.md

@@ -0,0 +1,704 @@
+# 营养健康部分缺陷修复计划
+
+## TL;DR
+
+> **Quick Summary**: 修复 5 个缺口(2 个 P0 Bug + 3 个 P1 Enhancement),覆盖后端 API 修复、新增 UserNutritionProfile CRUD、MealRecommendService 偏好读取、小程序 2 个新页面、Recipe 候选集过滤。
+
+> **Estimated Effort**: Medium
+> **Parallel Execution**: YES — Task 1-2 可并行,Task 3/4/5 可并行
+> **Critical Path**: Task 1 + Task 2 → Task 3(依赖 UserNutritionProfile Mapper/Service)
+
+---
+
+## Context
+
+### 缺口来源
+对营养健康模块全量审计,发现 5 个缺口:
+
+| # | 优先级 | 类型 | 描述 |
+|---|:---:|------|------|
+| 1 | P0 | Bug | `createReport` API 从请求体解析 indicators 时未设置 `symptoms` 字段 |
+| 2 | P0 | Bug | `uploadReport`(PDF 上传)路径 ParsedIndicator→HealthIndicator 转换时未设置 `symptoms`;且 `ParsedIndicator` 缺少 `symptoms` 字段 |
+| 3 | P1 | Enhancement | `MealRecommendService.aggregateContext()` 中偏好(budget/taste/cuisine)硬编码,需从 `UserNutritionProfile` 表读取;该表尚无 Mapper/Service/Controller |
+| 4 | P1 | New Feature | 小程序缺少食谱详情页 `pages/meal/recipe-detail.vue` 和饮食偏好设置页 `pages/meal/preference.vue` |
+| 5 | P1 | Enhancement | `filterCandidates()` 中 Recipe 候选集未按预算价格等级过滤 |
+
+### 约束
+- 后端:Spring Boot 2.7.18 + MyBatis-Plus + JWT,统一 `@PostMapping`
+- 小程序:uni-app Vue 2(禁止可选链 `?.`,用 `&&` 代替)
+- Web 管理端 `Foods.vue`/`Recipes.vue`/`SeasonalFoods.vue` 已存在,无需重复实现
+
+---
+
+## TODOs
+
+- [ ] 1. `createReport` API 补充设置 symptoms 字段
+
+  **What to do**:
+  - 修改 `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java` 第 99-118 行
+  - 在循环内 `ind.setStatus((String) item.get("status"));` 后(第 114 行后),新增一行:
+  ```java
+  ind.setSymptoms((String) item.get("symptoms"));
+  ```
+
+  **Must NOT do**:
+  - 不修改其他字段的解析逻辑
+  - 不修改 uploadReport 逻辑(Task 2 处理)
+
+  **Recommended Agent Profile**:
+  - **Category**: `quick`
+  - **Skills**: []
+  - **Reason**: 纯数据提取逻辑修改,1 行代码
+
+  **Parallelization**:
+  - **Can Run In Parallel**: YES — 独立文件修改
+  - **Blocks**: None
+  - **Blocked By**: None
+
+  **References**:
+  - `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java:99-118` — createReport 中 indicators 解析循环
+
+  **Acceptance Criteria**:
+  - [ ] `ind.setSymptoms(...)` 在第 114 行后新增
+  - [ ] mvn clean compile 通过
+
+  **QA Scenarios**:
+
+  Scenario: 创建含 symptoms 的指标
+    Tool: Bash
+    Preconditions: 后端已在 localhost:9082 启动
+    Steps:
+      1. 获取 token: `curl -s -X POST http://localhost:9082/api/auth/login -H "Content-Type: application/json" -d '{"phone":"test","code":"123456"}'`
+      2. `curl -s -X POST http://localhost:9082/api/health/report/create -H "Authorization: Bearer {token}" -H "Content-Type: application/json" -d '{"childId":1,"reportType":"gut_flora","overallScore":68,"indicators":[{"category":"肠道屏障","indicatorName":"对甲酚","indicatorValue":"89","status":"过多","refRange":"0-85","symptoms":"尿毒症毒素,慢性肾病"}]}'`
+    Expected Result: code=200,返回的 indicators[0].symptoms = "尿毒症毒素,慢性肾病"
+    Failure Indicators: symptoms 为 null
+    Evidence: .sisyphus/evidence/task-1-symptoms-api.txt
+
+  **Commit**: YES
+  - Message: `fix(controller): set symptoms field in createReport indicators`
+  - Files: HealthReportController.java
+
+---
+
+- [ ] 2. `ParsedIndicator` 新增 symptoms 字段 + `uploadReport` 补充设置
+
+  **What to do**:
+  - 第 1 步:修改 `cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedIndicator.java`,新增字段和方法:
+  ```java
+  /** 过量/缺乏相关症状描述文本 */
+  private String symptoms;
+
+  public String getSymptoms() { return symptoms; }
+  public void setSymptoms(String symptoms) { this.symptoms = symptoms; }
+  ```
+  - 第 2 步:修改 `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java` 第 296 行附近,在 ParsedIndicator→HealthIndicator 转换处,新增:
+  ```java
+  ind.setSymptoms(pi.getSymptoms());
+  ```
+
+  **Must NOT do**:
+  - 不修改 createReport 逻辑(Task 1 处理)
+  - 不修改 PdfParseService(PDF 解析逻辑,ParsedIndicator 只用于接收解析结果)
+
+  **Recommended Agent Profile**:
+  - **Category**: `quick`
+  - **Skills**: []
+  - **Reason**: 2 个独立小修改,各 3-5 行
+
+  **Parallelization**:
+  - **Can Run In Parallel**: YES — 两处修改不在同方法内
+  - **Blocks**: None
+  - **Blocked By**: None
+
+  **References**:
+  - `cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedIndicator.java` — 现有字段风格参考(category, indicatorName 等)
+  - `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java:296-298` — ParsedIndicator→HealthIndicator 转换处
+
+  **Acceptance Criteria**:
+  - [ ] ParsedIndicator.java 新增 symptoms 字段和 getter/setter
+  - [ ] HealthReportController.java uploadReport 方法中设置 ind.setSymptoms(pi.getSymptoms())
+  - [ ] mvn clean compile 通过
+
+  **QA Scenarios**:
+
+  Scenario: PDF 上传后 symptoms 被正确保存
+    Tool: Bash
+    Preconditions: 后端已在 localhost:9082 启动,有测试用 PDF
+    Steps:
+      1. `curl -s -X POST http://localhost:9082/api/health/report/upload -F "file=@test_gut_report.pdf" -F "childId=1" -F "familyId=1"`
+      2. 假设 PDF 中某指标带 symptoms,从返回的 detail.indicators 中检查 symptoms 字段有值
+    Expected Result: code=200,indicators 中有 symptoms 字段
+    Failure Indicators: symptoms 为 null
+    Evidence: .sisyphus/evidence/task-2-upload-symptoms.txt
+
+  **Commit**: YES(与 Task 1 同一 commit)
+  - Message: `fix: add symptoms field to ParsedIndicator and wire in uploadReport`
+  - Files: ParsedIndicator.java, HealthReportController.java
+
+---
+
+- [ ] 3. 创建 UserNutritionProfile 的 Mapper/Service/Controller,`MealRecommendService` 改用数据库偏好
+
+  **What to do**:
+  - 第 1 步:创建 `cfc-backend/src/main/java/com/etotem/cfc/mapper/UserNutritionProfileMapper.java`:
+  ```java
+  package com.etotem.cfc.mapper;
+
+  import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+  import com.etotem.cfc.entity.UserNutritionProfile;
+  import org.apache.ibatis.annotations.Mapper;
+
+  @Mapper
+  public interface UserNutritionProfileMapper extends BaseMapper<UserNutritionProfile> {
+  }
+  ```
+  - 第 2 步:创建 `cfc-backend/src/main/java/com/etotem/cfc/service/UserNutritionProfileService.java`:
+  ```java
+  package com.etotem.cfc.service;
+
+  import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+  import com.etotem.cfc.entity.UserNutritionProfile;
+  import com.etotem.cfc.mapper.UserNutritionProfileMapper;
+  import lombok.extern.slf4j.Slf4j;
+  import org.springframework.stereotype.Service;
+
+  import javax.annotation.Resource;
+
+  @Slf4j
+  @Service
+  public class UserNutritionProfileService {
+
+      @Resource
+      private UserNutritionProfileMapper mapper;
+
+      public UserNutritionProfile getByUserId(Long userId) {
+          if (userId == null) return null;
+          return mapper.selectOne(
+              new LambdaQueryWrapper<UserNutritionProfile>()
+                  .eq(UserNutritionProfile::getUserId, userId)
+                  .last("LIMIT 1")
+          );
+      }
+
+      public void save(UserNutritionProfile profile) {
+          if (profile.getId() == null) {
+              mapper.insert(profile);
+          } else {
+              mapper.updateById(profile);
+          }
+      }
+  }
+  ```
+  - 第 3 步:创建 `cfc-backend/src/main/java/com/etotem/cfc/controller/UserNutritionProfileController.java`:
+  ```java
+  package com.etotem.cfc.controller;
+
+  import com.etotem.cfc.common.Result;
+  import com.etotem.cfc.entity.UserNutritionProfile;
+  import com.etotem.cfc.service.UserNutritionProfileService;
+  import io.swagger.v3.oas.annotations.Operation;
+  import io.swagger.v3.oas.annotations.tags.Tag;
+  import org.springframework.web.bind.annotation.*;
+
+  import javax.annotation.Resource;
+  import java.util.Map;
+
+  @Tag(name = "营养偏好", description = "用户营养偏好管理")
+  @RestController
+  @RequestMapping("/api/nutrition/profile")
+  public class UserNutritionProfileController {
+
+      @Resource
+      private UserNutritionProfileService service;
+
+      @Operation(summary = "获取当前用户营养偏好")
+      @PostMapping("/get")
+      public Result<UserNutritionProfile> get(@RequestAttribute("userId") Long userId) {
+          return Result.success(service.getByUserId(userId));
+      }
+
+      @Operation(summary = "保存营养偏好")
+      @PostMapping("/save")
+      public Result<String> save(@RequestAttribute("userId") Long userId,
+                                   @RequestBody Map<String, Object> params) {
+          UserNutritionProfile profile = service.getByUserId(userId);
+          if (profile == null) {
+              profile = new UserNutritionProfile();
+              profile.setUserId(userId);
+          }
+          if (params.get("budgetMonthly") != null) {
+              profile.setBudgetMonthly(Integer.valueOf(params.get("budgetMonthly").toString()));
+          }
+          if (params.get("familyTastePreferences") != null) {
+              profile.setFamilyTastePreferences((String) params.get("familyTastePreferences"));
+          }
+          if (params.get("cuisineStyle") != null) {
+              profile.setCuisineStyle((String) params.get("cuisineStyle"));
+          }
+          if (params.get("mealCount") != null) {
+              profile.setMealCount(Integer.valueOf(params.get("mealCount").toString()));
+          }
+          if (params.get("cookingAbility") != null) {
+              profile.setCookingAbility((String) params.get("cookingAbility"));
+          }
+          service.save(profile);
+          return Result.success("ok");
+      }
+  }
+  ```
+  - 第 4 步:修改 `cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java` 的 `aggregateContext()` 方法,将硬编码值替换为从数据库读取:
+  ```java
+  // 在 aggregateContext() 方法开头注入并读取 profile
+  @Resource
+  private UserNutritionProfileService userNutritionProfileService;
+
+  // 在方法内替换硬编码:
+  UserNutritionProfile profile = userNutritionProfileService.getByUserId(userId);
+  ctx.setBudgetMonthly(profile != null && profile.getBudgetMonthly() != null ? profile.getBudgetMonthly() : 2000);
+  ctx.setTastePreference(profile != null && profile.getFamilyTastePreferences() != null ? profile.getFamilyTastePreferences() : "清淡");
+  ctx.setDietaryRestrictions("无"); // 可扩展
+  ctx.setCuisineStyle(profile != null && profile.getCuisineStyle() != null ? profile.getCuisineStyle() : "中式");
+  ```
+
+  **Must NOT do**:
+  - 不修改 aggregateContext 中的 nutritionSummary 和 abnormalIndicators 逻辑
+  - 不修改 filterCandidates 中其他过滤逻辑
+
+  **Recommended Agent Profile**:
+  - **Category**: `quick`
+  - **Skills**: []
+  - **Reason**: 标准 MyBatis-Plus CRUD 模式,3 个新文件 + 1 处修改
+
+  **Parallelization**:
+  - **Can Run In Parallel**: YES — 新文件创建,与 Task 1/2 无关
+  - **Blocks**: None
+  - **Blocked By**: None(但 Task 3 完成后 Task 5 才能准确验证偏好读取)
+
+  **References**:
+  - `cfc-backend/src/main/java/com/etotem/cfc/entity/UserNutritionProfile.java` — 现有字段(budgetMonthly, familyTastePreferences, cuisineStyle, mealCount, cookingAbility)
+  - `cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java:36-59` — aggregateContext 当前实现
+  - `cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java` — 参考 service 风格
+
+  **Acceptance Criteria**:
+  - [ ] UserNutritionProfileMapper.java 创建成功
+  - [ ] UserNutritionProfileService.java 创建成功
+  - [ ] UserNutritionProfileController.java 创建成功(含 get/save 两个 API)
+  - [ ] MealRecommendService.aggregateContext() 中读取 profile 而非硬编码
+  - [ ] mvn clean compile 通过
+
+  **QA Scenarios**:
+
+  Scenario: 偏好写入后被推荐服务正确读取
+    Tool: Bash
+    Preconditions: 后端已在 localhost:9082 启动
+    Steps:
+      1. 获取 token
+      2. 保存偏好: `curl -s -X POST http://localhost:9082/api/nutrition/profile/save -H "Authorization: Bearer {token}" -H "Content-Type: application/json" -d '{"budgetMonthly":3000,"cuisineStyle":"粤菜","mealCount":3}'`
+      3. 获取偏好: `curl -s -X POST http://localhost:9082/api/nutrition/profile/get -H "Authorization: Bearer {token}"`
+    Expected Result: 保存和读取返回一致
+    Evidence: .sisyphus/evidence/task-3-profile-crud.txt
+
+  **Commit**: YES
+  - Message: `feat: add UserNutritionProfile CRUD and wire into MealRecommendService`
+  - Files: UserNutritionProfileMapper.java (NEW), UserNutritionProfileService.java (NEW), UserNutritionProfileController.java (NEW), MealRecommendService.java
+
+- [ ] 4. 小程序新增 `recipe-detail.vue` 和 `preference.vue` 页面
+
+  **What to do**:
+  - 第 1 步:创建 `cfc-frontend/pages/meal/recipe-detail.vue` — 食谱详情页
+    - 从 `recommend.vue` 的推荐结果中点击食材可跳转本页面
+    - 页面元素:食谱名、热量/营养素汇总、食材列表、烹饪步骤、推荐理由
+    - 使用 `uni.navigateTo({ url: '/pages/meal/recipe-detail?id=' + recipeId })` 跳转
+
+    关键代码模板:
+    ```vue
+    <template>
+      <view class="recipe-detail">
+        <view class="header">
+          <text class="title">{{ recipeName }}</text>
+          <view class="nutrition-row">
+            <text>热量: {{ totalCalories }}kcal</text>
+            <text>蛋白: {{ totalProtein }}g</text>
+            <text>脂肪: {{ totalFat }}g</text>
+            <text>碳水: {{ totalCarbs }}g</text>
+          </view>
+        </view>
+        <view class="section">
+          <text class="section-title">食材</text>
+          <view class="ingredient-item" v-for="item in ingredients" :key="item">
+            <text>{{ item }}</text>
+          </view>
+        </view>
+        <view class="section">
+          <text class="section-title">步骤</text>
+          <view class="step-item" v-for="(step, idx) in steps" :key="idx">
+            <text class="step-num">{{ idx + 1 }}</text>
+            <text class="step-text">{{ step }}</text>
+          </view>
+        </view>
+      </view>
+    </template>
+
+    <script>
+    export default {
+      data() {
+        return {
+          recipeId: '',
+          recipeName: '',
+          totalCalories: 0,
+          totalProtein: 0,
+          totalFat: 0,
+          totalCarbs: 0,
+          ingredients: [],
+          steps: []
+        }
+      },
+      onLoad(options) {
+        this.recipeId = options.id || '';
+        this.loadDetail();
+      },
+      methods: {
+        loadDetail() {
+          // TODO: 调用食谱详情 API(当前后端 AdminRecipeController 无详情 API,
+          // 可先用前端硬编码模拟数据,或新建 /api/admin/recipes/detail 接口)
+          // 临时方案:推荐结果已含食谱信息,直接从 storage 读取
+        }
+      }
+    }
+    </script>
+
+    <style scoped>
+    .recipe-detail { padding: 32rpx; background: #f5f5f5; min-height: 100vh; }
+    .header { background: #fff; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; }
+    .title { font-size: 36rpx; font-weight: bold; }
+    .nutrition-row { display: flex; gap: 24rpx; margin-top: 16rpx; }
+    .section { background: #fff; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; }
+    .section-title { font-size: 30rpx; font-weight: 600; margin-bottom: 16rpx; }
+    .step-item { display: flex; margin-bottom: 16rpx; }
+    .step-num { width: 40rpx; height: 40rpx; background: #F97316; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24rpx; margin-right: 16rpx; }
+    </style>
+    ```
+
+  - 第 2 步:创建 `cfc-frontend/pages/meal/preference.vue` — 饮食偏好设置页
+    - 表单字段:月预算(slider)、口味偏好(checkbox 多选)、菜系偏好(picker)、每日餐数(picker)、烹饪能力(radio)
+    - 调用 `getMealRecommend` 等 API 读取当前偏好,再通过 `/api/nutrition/profile/save` 保存
+
+    关键代码模板:
+    ```vue
+    <template>
+      <view class="preference-page">
+        <view class="form-section">
+          <text class="label">月食品预算(元)</text>
+          <slider :value="budget" min="500" max="10000" step="100" show-value @change="onBudgetChange" />
+        </view>
+        <view class="form-section">
+          <text class="label">口味偏好</text>
+          <checkbox-group @change="onTasteChange">
+            <label><checkbox value="清淡" />清淡</label>
+            <label><checkbox value="微辣" />微辣</label>
+            <label><checkbox value="甜" />甜</label>
+            <label><checkbox value="酸" />酸</label>
+          </checkbox-group>
+        </view>
+        <view class="form-section">
+          <text class="label">菜系</text>
+          <picker :value="cuisineIndex" :range="cuisineList" @change="onCuisineChange">
+            <view class="picker-value">{{ cuisineList[cuisineIndex] }}</view>
+          </picker>
+        </view>
+        <view class="form-section">
+          <text class="label">每日餐数</text>
+          <picker :value="mealIndex" :range="mealList" @change="onMealCountChange">
+            <view class="picker-value">{{ mealList[mealIndex] }}</view>
+          </picker>
+        </view>
+        <view class="form-section">
+          <text class="label">烹饪能力</text>
+          <radio-group @change="onCookingChange">
+            <label><radio value="simple" />简单(只会基础)</label>
+            <label><radio value="medium" />一般(能炒家常菜)</label>
+            <label><radio value="advanced" />较强(能做大菜)</label>
+          </radio-group>
+        </view>
+        <button type="primary" :loading="saving" @click="save">保存设置</button>
+      </view>
+    </template>
+
+    <script>
+    import { getMealRecommend } from '@/utils/api.js';
+
+    export default {
+      data() {
+        return {
+          budget: 2000,
+          tastePreferences: [],
+          cuisineIndex: 0,
+          cuisineList: ['中式', '粤菜', '川菜', '西式', '日式', '其他'],
+          mealIndex: 1,
+          mealList: ['2餐', '3餐', '4餐', '5餐'],
+          cookingAbility: 'medium',
+          saving: false
+        }
+      },
+      onLoad() {
+        this.loadCurrentPreferences();
+      },
+      methods: {
+        async loadCurrentPreferences() {
+          // 读取当前用户偏好
+        },
+        onBudgetChange(e) { this.budget = e.detail.value; },
+        onTasteChange(e) { this.tastePreferences = e.detail.value; },
+        onCuisineChange(e) { this.cuisineIndex = e.detail.value; },
+        onMealCountChange(e) { this.mealIndex = e.detail.value; },
+        onCookingChange(e) { this.cookingAbility = e.detail.value; },
+        async save() {
+          this.saving = true;
+          try {
+            // 调用 /api/nutrition/profile/save
+          } finally {
+            this.saving = false;
+          }
+        }
+      }
+    }
+    </script>
+
+    <style scoped>
+    .preference-page { padding: 32rpx; background: #f5f5f5; min-height: 100vh; }
+    .form-section { background: #fff; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; }
+    .label { font-size: 28rpx; color: #333; margin-bottom: 16rpx; display: block; }
+    </style>
+    ```
+
+  - 第 3 步:在 `cfc-frontend/pages.json` 中 `pages/meal` 节点内新增两个子页面路由:
+    ```json
+    {
+      "root": "pages/meal",
+      "pages": [
+        { "path": "recommend", "style": { "navigationBarTitleText": "智能食谱推荐" } },
+        { "path": "log", "style": { "navigationBarTitleText": "饮食记录" } },
+        {
+          "path": "recipe-detail",
+          "style": { "navigationBarTitleText": "食谱详情" }
+        },
+        {
+          "path": "preference",
+          "style": { "navigationBarTitleText": "饮食偏好设置" }
+        }
+      ]
+    }
+    ```
+
+  - 第 4 步:在 `cfc-frontend/utils/api.js` 末尾新增偏好相关 API:
+    ```javascript
+    export const getNutritionProfile = (data) => request('/api/nutrition/profile/get', 'POST', data)
+    export const saveNutritionProfile = (data) => request('/api/nutrition/profile/save', 'POST', data)
+    ```
+
+  **Must NOT do**:
+  - 不使用可选链 `?.`(小程序不支持)
+  - 不修改现有 recommend.vue 和 log.vue 的逻辑
+  - 不使用 CSS Grid(用 flexbox)
+
+  **Recommended Agent Profile**:
+  - **Category**: `visual-engineering`
+  - **Skills**: []
+  - **Reason**: 小程序 Vue 页面,参考现有 pages/meal/recommend.vue 和 pages/body/ 的风格
+
+  **Parallelization**:
+  - **Can Run In Parallel**: YES — 两个新页面互不影响
+  - **Blocks**: None
+  - **Blocked By**: None(独立前端页面,不依赖后端 Task 3)
+
+  **References**:
+  - `cfc-frontend/pages/meal/recommend.vue` — 参考现有页面风格和 API 调用方式
+  - `cfc-frontend/pages/meal/log.vue:1-30` — 参考表单结构和样式
+  - `cfc-frontend/pages.json:615-628` — 现有 meal 分组结构
+  - `cfc-frontend/utils/api.js:1297-1301` — 现有 meal API
+
+  **Acceptance Criteria**:
+  - [ ] recipe-detail.vue 创建完成,有基本页面结构
+  - [ ] preference.vue 创建完成,有预算/口味/菜系/餐数/烹饪能力表单
+  - [ ] pages.json 中两个新路由注册完成
+  - [ ] api.js 新增 getNutritionProfile 和 saveNutritionProfile
+  - [ ] 小程序能正常预览(npm run dev:mp-weixin 无报错)
+
+  **QA Scenarios**:
+
+  Scenario: 偏好页面正常打开并保存
+    Tool: Bash (前端编译验证)
+    Preconditions: 小程序开发服务器运行
+    Steps:
+      1. 编译: `cd cfc-frontend && npm run dev:mp-weixin`
+    Expected Result: 无编译错误,recipe-detail 和 preference 页面路由正常
+    Evidence: .sisyphus/evidence/task-4-page-compile.txt
+
+  **Commit**: YES
+  - Message: `feat(miniapp): add recipe-detail and preference pages for meal module`
+  - Files: pages/meal/recipe-detail.vue (NEW), pages/meal/preference.vue (NEW), pages.json (修改), utils/api.js (修改)
+
+---
+
+- [ ] 5. `filterCandidates()` Recipe 候选集增加预算过滤
+
+  **What to do**:
+  - 修改 `cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java` 的 `filterCandidates()` 方法
+  - 当前 Recipe 候选集加载了全部 active 食谱,需改为按预算过滤
+
+  当前代码(MealRecommendService.java:75-76):
+  ```java
+  List<Recipe> recipeCandidates = recipeService.listByMealType(null);
+  ctx.setCandidateRecipes(recipeCandidates.size() > 15 ? recipeCandidates.subList(0, 15) : recipeCandidates);
+  ```
+
+  修改为(需估算食谱总价格等级,暂以总热量替代,或在 Recipe 实体增加 priceLevel 字段):
+  ```java
+  List<Recipe> allRecipes = recipeService.listByMealType(null);
+  int maxPriceLevel = ctx.getBudgetMonthly() != null
+          ? Math.max(1, Math.min(5, ctx.getBudgetMonthly() / 500))
+          : 3;
+
+  // 过滤策略:热量 <= 预算内食物所需热量上限(约2000kcal/餐 * 餐数)
+  int maxCaloriesPerMeal = 800; // 估算单餐热量上限
+  List<Recipe> filtered = allRecipes.stream()
+          .filter(r -> {
+              // 有 priceLevel 字段时使用价格等级过滤
+              // 无则用热量粗略过滤(总热量/ recipe.steps.split("。").length < maxCaloriesPerMeal)
+              if (r.getTotalCalories() != null && r.getTotalCalories().intValue() > maxCaloriesPerMeal * 3) {
+                  return false; // 超过3餐总热量上限的先过滤
+              }
+              return true;
+          })
+          .collect(Collectors.toList());
+
+  ctx.setCandidateRecipes(filtered.size() > 15 ? filtered.subList(0, 15) : filtered);
+  ```
+
+  **替代方案**(推荐):
+  如果 `Recipe` 实体已定义了 `priceLevel` 字段(参考 `Food` 有 `priceLevel`),则直接使用价格等级过滤:
+  ```java
+  List<Recipe> allRecipes = recipeService.listByMealType(null);
+  int maxPriceLevel = ctx.getBudgetMonthly() != null
+          ? Math.max(1, Math.min(5, ctx.getBudgetMonthly() / 500))
+          : 3;
+  List<Recipe> filtered = allRecipes.stream()
+          .filter(r -> r.getPriceLevel() == null || r.getPriceLevel() <= maxPriceLevel)
+          .collect(Collectors.toList());
+  ctx.setCandidateRecipes(filtered.size() > 15 ? filtered.subList(0, 15) : filtered);
+  ```
+
+  **Must NOT do**:
+  - 不修改 Food 候选集过滤逻辑(已在行 68-73)
+  - 不修改 aggregateContext 偏好读取逻辑(Task 3 处理)
+
+  **Recommended Agent Profile**:
+  - **Category**: `quick`
+  - **Skills**: []
+  - **Reason**: 1 处过滤逻辑修改,约 5 行代码
+
+  **Parallelization**:
+  - **Can Run In Parallel**: YES — 独立 service 修改
+  - **Blocks**: None
+  - **Blocked By**: None
+
+  **References**:
+  - `cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java:61-77` — filterCandidates 当前实现
+  - `cfc-backend/src/main/java/com/etotem/cfc/entity/Recipe.java` — 检查是否有 priceLevel 字段
+  - `cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java:24` — priceLevel 字段参考
+
+  **Acceptance Criteria**:
+  - [ ] filterCandidates 中 Recipe 按预算过滤(非全部加载)
+  - [ ] mvn clean compile 通过
+
+  **QA Scenarios**:
+
+  Scenario: 推荐低预算用户时筛选更严格
+    Tool: Bash
+    Preconditions: 后端已在 localhost:9082 启动,数据库有多个 priceLevel 不同的食谱
+    Steps:
+      1. 获取 token
+      2. 调用推荐: `curl -s -X POST http://localhost:9082/api/meal/recommend -H "Authorization: Bearer {token}" -H "Content-Type: application/json" -d '{"mealType":"早餐"}'`
+      3. 检查返回的 replaceOptions 中的 Recipe(如果有 priceLevel)是否 ≤ 预算对应的 maxPriceLevel
+    Expected Result: 返回的候选食谱价格等级 ≤ (budget/500)
+    Evidence: .sisyphus/evidence/task-5-recipe-filter.txt
+
+  **Commit**: YES(可与 Task 3 同一 commit)
+  - Message: `feat: filter recipe candidates by budget in MealRecommendService`
+  - Files: MealRecommendService.java
+
+---
+
+## Final Verification Wave (MANDATORY — after ALL implementation tasks)
+
+- [ ] F1. **Plan Compliance Audit** — `oracle`
+  Read the plan end-to-end. Verify each "Must Have" has implementation. Check "Must NOT Have" is respected. Check evidence files exist.
+  Output: `Must Have [5/5] | Must NOT Have [per task] | Tasks [5/5] | VERDICT: APPROVE/REJECT`
+
+- [ ] F2. **Code Quality Review** — `unspecified-high`
+  Run `mvn clean compile` for backend. Check frontend `npm run dev:mp-weixin` for miniapp. Review changed files for `as any`, empty catches, console.log in prod.
+  Output: `Build [PASS/FAIL] | Miniapp [PASS/FAIL] | VERDICT`
+
+- [ ] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` if UI)
+  Execute EVERY QA scenario from EVERY task. Start from clean state.
+  Output: `Scenarios [N/N pass] | VERDICT`
+
+- [ ] F4. **Scope Fidelity Check** — `deep`
+  For each task: read "What to do", read actual diff. Verify 1:1 — nothing beyond spec.
+  Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT`
+
+---
+
+## Commit Strategy
+
+| Task | 建议 Commit | 包含文件 |
+|------|------------|---------|
+| Task 1 + Task 2 | `fix: set symptoms field in health indicator creation and PDF upload` | HealthReportController.java, ParsedIndicator.java |
+| Task 3 | `feat: add UserNutritionProfile CRUD and wire into MealRecommendService` | UserNutritionProfileMapper.java (NEW), UserNutritionProfileService.java (NEW), UserNutritionProfileController.java (NEW), MealRecommendService.java |
+| Task 4 | `feat(miniapp): add recipe-detail and preference pages` | recipe-detail.vue (NEW), preference.vue (NEW), pages.json (修改), api.js (修改) |
+| Task 5 | `feat: filter recipe candidates by budget` | MealRecommendService.java |
+
+**Pre-commit**: `mvn clean compile`(后端) + `npm run dev:mp-weixin`(前端)均需通过
+
+---
+
+## Success Criteria
+
+### Verification Commands
+```bash
+# 1. 后端编译
+cd cfc-backend && mvn clean compile
+
+# 2. 小程序编译
+cd cfc-frontend && npm run dev:mp-weixin
+
+# 3. symptoms API 测试(Task 1)
+curl -X POST http://localhost:9082/api/health/report/create \
+  -H "Authorization: Bearer {token}" \
+  -H "Content-Type: application/json" \
+  -d '{"childId":1,"reportType":"gut_flora","overallScore":68,"indicators":[{"category":"肠道屏障","indicatorName":"对甲酚","indicatorValue":"89","status":"过多","refRange":"0-85","symptoms":"尿毒症毒素,慢性肾病"}]}'
+# Expected: code=200, data.indicators[0].symptoms = "尿毒症毒素,慢性肾病"
+
+# 4. 营养偏好 CRUD 测试(Task 3)
+curl -X POST http://localhost:9082/api/nutrition/profile/save \
+  -H "Authorization: Bearer {token}" -H "Content-Type: application/json" \
+  -d '{"budgetMonthly":3000,"cuisineStyle":"粤菜","mealCount":3}'
+curl -X POST http://localhost:9082/api/nutrition/profile/get \
+  -H "Authorization: Bearer {token}"
+# Expected: GET 返回保存的值
+
+# 5. 推荐接口(验证偏好被读取,Task 3 + Task 5)
+curl -X POST http://localhost:9082/api/meal/recommend \
+  -H "Authorization: Bearer {token}" -H "Content-Type: application/json" \
+  -d '{"mealType":"早餐"}'
+# Expected: code=200, 返回推荐结果
+```
+
+### Final Checklist
+- [ ] Task 1: createReport 中 symptoms 正确设置
+- [ ] Task 2: uploadReport 中 symptoms 正确设置,ParsedIndicator 有 symptoms 字段
+- [ ] Task 3: UserNutritionProfileMapper/Service/Controller 存在且可用,MealRecommendService 读取偏好非硬编码
+- [ ] Task 4: recipe-detail.vue 和 preference.vue 存在,pages.json 已注册,api.js 已补充
+- [ ] Task 5: filterCandidates 中 Recipe 按预算过滤
+- [ ] mvn clean compile 通过
+- [ ] 小程序无编译错误