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)
对营养健康模块全量审计,发现 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 候选集未按预算价格等级过滤 |
@PostMapping?.,用 && 代替)Foods.vue/Recipes.vue/SeasonalFoods.vue 已存在,无需重复实现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:
Recommended Agent Profile:
quickParallelization:
References:
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java:99-118 — createReport 中 indicators 解析循环Acceptance Criteria:
ind.setSymptoms(...) 在第 114 行后新增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
fix(controller): set symptoms field in createReport indicatorsParsedIndicator 新增 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:
Recommended Agent Profile:
quickParallelization:
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:
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)
fix: add symptoms field to ParsedIndicator and wire in uploadReportMealRecommendService 改用数据库偏好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.java 的 aggregateContext() 方法,将硬编码值替换为从数据库读取:
// 在 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:
Recommended Agent Profile:
quickParallelization:
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:
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
feat: add UserNutritionProfile CRUD and wire into MealRecommendServiceFiles: 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 }) 跳转关键代码模板:
<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 — 饮食偏好设置页
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.json 中 pages/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:
?.(小程序不支持)Recommended Agent Profile:
visual-engineeringParallelization:
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 APIAcceptance Criteria:
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
feat(miniapp): add recipe-detail and preference pages for meal modulefilterCandidates() Recipe 候选集增加预算过滤What to do:
cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java 的 filterCandidates() 方法当前代码(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 字段(参考 Food 有 priceLevel),则直接使用价格等级过滤:
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:
Recommended Agent Profile:
quickParallelization:
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:
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)
feat: filter recipe candidates by budget in MealRecommendService[ ] 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
| 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(前端)均需通过
# 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, 返回推荐结果