2026-06-22-营养健康部分缺陷修复计划.md 30 KB

营养健康部分缺陷修复计划

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 行后),新增一行:

    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,新增字段和方法:

    /** 过量/缺乏相关症状描述文本 */
    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 转换处,新增:

    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

    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

    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

    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.javaaggregateContext() 方法,将硬编码值替换为从数据库读取:

    // 在 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.vuepreference.vue 页面

What to do:

  • 第 1 步:创建 cfc-frontend/pages/meal/recipe-detail.vue — 食谱详情页

    • recommend.vue 的推荐结果中点击食材可跳转本页面
    • 页面元素:食谱名、热量/营养素汇总、食材列表、烹饪步骤、推荐理由
    • 使用 uni.navigateTo({ url: '/pages/meal/recipe-detail?id=' + recipeId }) 跳转

    关键代码模板:

    <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 保存

    关键代码模板:

    <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.jsonpages/meal 节点内新增两个子页面路由:

    {
      "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:

    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.javafilterCandidates() 方法
  • 当前 Recipe 候选集加载了全部 active 食谱,需改为按预算过滤

当前代码(MealRecommendService.java:75-76):

  List<Recipe> recipeCandidates = recipeService.listByMealType(null);
  ctx.setCandidateRecipes(recipeCandidates.size() > 15 ? recipeCandidates.subList(0, 15) : recipeCandidates);

修改为(需估算食谱总价格等级,暂以总热量替代,或在 Recipe 实体增加 priceLevel 字段):

  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 字段(参考 FoodpriceLevel),则直接使用价格等级过滤:

  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 Auditoracle 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 Reviewunspecified-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 QAunspecified-high (+ playwright if UI) Execute EVERY QA scenario from EVERY task. Start from clean state. Output: Scenarios [N/N pass] | VERDICT

  • [ ] F4. Scope Fidelity Checkdeep 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

# 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 通过
  • 小程序无编译错误