|
|
@@ -0,0 +1,1922 @@
|
|
|
+# P1 营养师核心功能实现计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 完成 P1 四大功能——PDF食材解析扩展、微量营养素食材库+RDI、购物清单生成、营养师-家庭沟通闭环——的数据库层、服务层、API层和前端交互实现。
|
|
|
+
|
|
|
+**Architecture:** 分层实现:Wave 1 建表+实体+SEED数据 → Wave 2 解析扩展+食材适宜度 → Wave 3 营养素查询+RDI计算 → Wave 4 购物清单生成逻辑+前端 → Wave 5 沟通闭环+前端
|
|
|
+
|
|
|
+**Tech Stack:** Spring Boot 2.7.18 / MyBatis-Plus / Java 8 / MySQL 8.0 / uni-app Vue 2 (小程序)
|
|
|
+
|
|
|
+**设计文档:** `docs/superpowers/specs/2026-06-24-P1营养师功能设计方案.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File Structure Overview
|
|
|
+
|
|
|
+### New Files (创建)
|
|
|
+
|
|
|
+| 层 | 路径 | 说明 |
|
|
|
+|---|------|------|
|
|
|
+| DTO | `cfc-backend/.../dto/ParsedFoodSuitability.java` | PDF食材数据DTO |
|
|
|
+| Entity | `cfc-backend/.../entity/ReportFoodSuitability.java` | 报告食材适宜度 |
|
|
|
+| Entity | `cfc-backend/.../entity/UserFoodPreference.java` | 用户食材偏好 |
|
|
|
+| Entity | `cfc-backend/.../entity/FoodNutrient.java` | 微量营养素 |
|
|
|
+| Entity | `cfc-backend/.../entity/RdiStandard.java` | RDI标准 |
|
|
|
+| Entity | `cfc-backend/.../entity/ShoppingList.java` | 购物清单主表 |
|
|
|
+| Entity | `cfc-backend/.../entity/ShoppingListItem.java` | 购物清单明细 |
|
|
|
+| Entity | `cfc-backend/.../entity/FollowUp.java` | 随访记录 |
|
|
|
+| Mapper | `cfc-backend/.../mapper/ReportFoodSuitabilityMapper.java` | |
|
|
|
+| Mapper | `cfc-backend/.../mapper/UserFoodPreferenceMapper.java` | |
|
|
|
+| Mapper | `cfc-backend/.../mapper/FoodNutrientMapper.java` | |
|
|
|
+| Mapper | `cfc-backend/.../mapper/RdiStandardMapper.java` | |
|
|
|
+| Mapper | `cfc-backend/.../mapper/ShoppingListMapper.java` | |
|
|
|
+| Mapper | `cfc-backend/.../mapper/ShoppingListItemMapper.java` | |
|
|
|
+| Mapper | `cfc-backend/.../mapper/FollowUpMapper.java` | |
|
|
|
+| Service | `cfc-backend/.../service/ReportFoodSuitabilityService.java` | |
|
|
|
+| Service | `cfc-backend/.../service/ReportFoodSuitabilityServiceInterface.java` | |
|
|
|
+| Service | `cfc-backend/.../service/UserFoodPreferenceService.java` | |
|
|
|
+| Service | `cfc-backend/.../service/FoodNutrientService.java` | |
|
|
|
+| Service | `cfc-backend/.../service/RdiStandardService.java` | |
|
|
|
+| Service | `cfc-backend/.../service/ShoppingListService.java` | |
|
|
|
+| Service | `cfc-backend/.../service/ShoppingListServiceInterface.java` | |
|
|
|
+| Service | `cfc-backend/.../service/FollowUpService.java` | |
|
|
|
+| Service | `cfc-backend/.../service/FollowUpServiceInterface.java` | |
|
|
|
+| Controller | `cfc-backend/.../controller/ShoppingListController.java` | |
|
|
|
+| Controller | `cfc-backend/.../controller/guide/FollowUpController.java` | |
|
|
|
+| Util | `cfc-backend/.../common/NutritionCalculator.java` | RDI计算工具 |
|
|
|
+
|
|
|
+### Modified Files (修改)
|
|
|
+
|
|
|
+| 文件 | 修改内容 |
|
|
|
+|------|---------|
|
|
|
+| `ParsedReportResult.java` | 新增 foodSuitability 字段 |
|
|
|
+| `PdfParseService.java` | 新增 parseFoodSuitability() |
|
|
|
+| `HealthReportController.java` | 新增食材适宜度查询端点 |
|
|
|
+| `DatabaseInitializer.java` | 新增7张表DDL + ALTER TABLE foods + SEED数据 |
|
|
|
+| `TeacherMessage.java` | 新增 sender_role, read_at, relates_to等字段 |
|
|
|
+| `TeacherMessageService.java` | 扩展双向发送+已读标记 |
|
|
|
+| `SendMessageDTO.java` | 扩展 sender_role 等 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 1: 数据库层 + SEED 数据
|
|
|
+
|
|
|
+### Task 1: foods 表扩展 + 新Entity创建
|
|
|
+
|
|
|
+- [ ] **Step 1: 修改 DatabaseInitializer.java — foods 表扩展**
|
|
|
+
|
|
|
+在 DatabaseInitializer.java 的 DDL 区域找到 foods 表创建语句,在其后添加 ALTER TABLE:
|
|
|
+
|
|
|
+```java
|
|
|
+// 在 DatabaseInitializer.run() 的 foods 表创建之后添加
|
|
|
+jdbcTemplate.execute("ALTER TABLE foods "
|
|
|
+ + "ADD COLUMN IF NOT EXISTS energy_kj DECIMAL(8,2) COMMENT '能量(kJ/100g)' AFTER calories, "
|
|
|
+ + "ADD COLUMN IF NOT EXISTS starch DECIMAL(8,2) COMMENT '淀粉(g/100g)' AFTER fiber, "
|
|
|
+ + "ADD COLUMN IF NOT EXISTS cholesterol DECIMAL(8,2) COMMENT '胆固醇(mg/100g)' AFTER starch");
|
|
|
+```
|
|
|
+
|
|
|
+> 注意:MySQL 不支持 `ADD COLUMN IF NOT EXISTS`,需要用存储过程或先检查列是否存在。改用 JDBC `DatabaseMetaData` 检查列是否存在后再执行 ALTER。
|
|
|
+
|
|
|
+检查列是否存在的方法:
|
|
|
+```java
|
|
|
+private boolean columnExists(JdbcTemplate jdbcTemplate, String table, String column) {
|
|
|
+ List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
|
|
+ "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
|
|
|
+ table, column);
|
|
|
+ return !rows.isEmpty();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+然后:
|
|
|
+```java
|
|
|
+if (!columnExists(jdbcTemplate, "foods", "energy_kj")) {
|
|
|
+ jdbcTemplate.execute("ALTER TABLE foods ADD COLUMN energy_kj DECIMAL(8,2) ...");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 Entity — ReportFoodSuitability.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.*;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@TableName("report_food_suitability")
|
|
|
+public class ReportFoodSuitability {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long reportId;
|
|
|
+ private Long foodId;
|
|
|
+ private String foodName;
|
|
|
+ private String category;
|
|
|
+ private Integer score;
|
|
|
+ @TableField(fill = FieldFill.INSERT)
|
|
|
+ private Date createdAt;
|
|
|
+
|
|
|
+ // getters/setters — 用 @Data 或 Lombok,无则手动生成
|
|
|
+ public Long getId() { return id; }
|
|
|
+ public void setId(Long id) { this.id = id; }
|
|
|
+ // ... 手动生成所有 getter/setter
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 Entity — UserFoodPreference.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+@TableName("user_food_preference")
|
|
|
+public class UserFoodPreference {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long userId;
|
|
|
+ private Long foodId;
|
|
|
+ private String foodName;
|
|
|
+ private Integer score;
|
|
|
+ private String reason;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+ // getters/setters
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 创建 Entity — FoodNutrient.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@TableName("food_nutrients")
|
|
|
+public class FoodNutrient {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long foodId;
|
|
|
+ private String nutrientCode;
|
|
|
+ private BigDecimal amountPer100g;
|
|
|
+ private String unit;
|
|
|
+ private String source;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+ // getters/setters
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 创建 Entity — RdiStandard.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@TableName("rdi_standards")
|
|
|
+public class RdiStandard {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private String nutrientCode;
|
|
|
+ private String ageGroup;
|
|
|
+ private String gender;
|
|
|
+ private String rdiType;
|
|
|
+ private BigDecimal amount;
|
|
|
+ private String unit;
|
|
|
+ private String source;
|
|
|
+ private Date createdAt;
|
|
|
+ // getters/setters
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 创建 Entity — ShoppingList.java / ShoppingListItem.java**
|
|
|
+
|
|
|
+**ShoppingList.java:**
|
|
|
+```java
|
|
|
+@TableName("shopping_lists")
|
|
|
+public class ShoppingList {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long userId;
|
|
|
+ private Long childId;
|
|
|
+ private Long reportId;
|
|
|
+ private String title;
|
|
|
+ private String source; // report/deficiency/manual/mixed
|
|
|
+ private String status; // active/completed/archived
|
|
|
+ private Integer totalItems;
|
|
|
+ private Integer purchasedItems;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+ // getters/setters
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**ShoppingListItem.java:**
|
|
|
+```java
|
|
|
+@TableName("shopping_list_items")
|
|
|
+public class ShoppingListItem {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long listId;
|
|
|
+ private Long foodId;
|
|
|
+ private String foodName;
|
|
|
+ private String category;
|
|
|
+ private BigDecimal quantity;
|
|
|
+ private String unit;
|
|
|
+ private String priority; // high/medium/low
|
|
|
+ private String reason;
|
|
|
+ private Integer score;
|
|
|
+ private String source; // report/deficiency/manual
|
|
|
+ private Boolean purchased;
|
|
|
+ private Date purchasedAt;
|
|
|
+ private Integer sortOrder;
|
|
|
+ private Date createdAt;
|
|
|
+ // getters/setters
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: 创建 Entity — FollowUp.java**
|
|
|
+
|
|
|
+```java
|
|
|
+@TableName("follow_up")
|
|
|
+public class FollowUp {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long familyId;
|
|
|
+ private Long childId;
|
|
|
+ private Long teacherId;
|
|
|
+ private Long deficiencyId;
|
|
|
+ private Long messageId;
|
|
|
+ private String status; // pending/in_progress/completed/ignored
|
|
|
+ private String priority; // high/medium/low
|
|
|
+ private String description;
|
|
|
+ private String parentFeedback;
|
|
|
+ private String teacherReview;
|
|
|
+ private Date dueDate;
|
|
|
+ private Date completedAt;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+ // getters/setters
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 8: DatabaseInitializer.java — 新建表DDL**
|
|
|
+
|
|
|
+在 DatabaseInitializer.run() 中添加7张表的 CREATE TABLE(按依赖顺序:先 foods 扩展,再独立表):
|
|
|
+
|
|
|
+```java
|
|
|
+// report_food_suitability
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_food_suitability ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "report_id BIGINT NOT NULL, "
|
|
|
+ + "food_id BIGINT, "
|
|
|
+ + "food_name VARCHAR(100) NOT NULL, "
|
|
|
+ + "category VARCHAR(50), "
|
|
|
+ + "score INT NOT NULL, "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_report (report_id), "
|
|
|
+ + "INDEX idx_food (food_id)"
|
|
|
+ + ")");
|
|
|
+
|
|
|
+// user_food_preference
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_food_preference ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "user_id BIGINT NOT NULL, "
|
|
|
+ + "food_id BIGINT, "
|
|
|
+ + "food_name VARCHAR(100) NOT NULL, "
|
|
|
+ + "score INT NOT NULL DEFAULT 0, "
|
|
|
+ + "reason VARCHAR(500), "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_user (user_id)"
|
|
|
+ + ")");
|
|
|
+
|
|
|
+// food_nutrients
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS food_nutrients ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "food_id BIGINT NOT NULL, "
|
|
|
+ + "nutrient_code VARCHAR(20) NOT NULL, "
|
|
|
+ + "amount_per_100g DECIMAL(10,4) NOT NULL, "
|
|
|
+ + "unit VARCHAR(20) NOT NULL, "
|
|
|
+ + "source VARCHAR(50) DEFAULT 'importer', "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "UNIQUE KEY uk_food_nutrient (food_id, nutrient_code), "
|
|
|
+ + "INDEX idx_nutrient (nutrient_code)"
|
|
|
+ + ")");
|
|
|
+
|
|
|
+// rdi_standards
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS rdi_standards ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "nutrient_code VARCHAR(20) NOT NULL, "
|
|
|
+ + "age_group VARCHAR(10) NOT NULL, "
|
|
|
+ + "gender VARCHAR(6) DEFAULT 'all', "
|
|
|
+ + "rdi_type VARCHAR(10) NOT NULL, "
|
|
|
+ + "amount DECIMAL(10,4) NOT NULL, "
|
|
|
+ + "unit VARCHAR(20) NOT NULL, "
|
|
|
+ + "source VARCHAR(100), "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "UNIQUE KEY uk_rdi (nutrient_code, age_group, gender, rdi_type), "
|
|
|
+ + "INDEX idx_age (age_group)"
|
|
|
+ + ")");
|
|
|
+
|
|
|
+// shopping_lists
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS shopping_lists ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "user_id BIGINT NOT NULL, "
|
|
|
+ + "child_id BIGINT, "
|
|
|
+ + "report_id BIGINT, "
|
|
|
+ + "title VARCHAR(200), "
|
|
|
+ + "source VARCHAR(20) NOT NULL, "
|
|
|
+ + "status VARCHAR(20) DEFAULT 'active', "
|
|
|
+ + "total_items INT DEFAULT 0, "
|
|
|
+ + "purchased_items INT DEFAULT 0, "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_user (user_id), "
|
|
|
+ + "INDEX idx_report (report_id), "
|
|
|
+ + "INDEX idx_status (status)"
|
|
|
+ + ")");
|
|
|
+
|
|
|
+// shopping_list_items
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS shopping_list_items ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "list_id BIGINT NOT NULL, "
|
|
|
+ + "food_id BIGINT, "
|
|
|
+ + "food_name VARCHAR(100) NOT NULL, "
|
|
|
+ + "category VARCHAR(50), "
|
|
|
+ + "quantity DECIMAL(8,2) DEFAULT 1, "
|
|
|
+ + "unit VARCHAR(20) DEFAULT '份', "
|
|
|
+ + "priority VARCHAR(10) DEFAULT 'medium', "
|
|
|
+ + "reason VARCHAR(200), "
|
|
|
+ + "score INT, "
|
|
|
+ + "source VARCHAR(20), "
|
|
|
+ + "purchased TINYINT DEFAULT 0, "
|
|
|
+ + "purchased_at DATETIME, "
|
|
|
+ + "sort_order INT DEFAULT 0, "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_list (list_id), "
|
|
|
+ + "INDEX idx_purchased (purchased)"
|
|
|
+ + ")");
|
|
|
+
|
|
|
+// follow_up
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS follow_up ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "family_id BIGINT NOT NULL, "
|
|
|
+ + "child_id BIGINT, "
|
|
|
+ + "teacher_id BIGINT, "
|
|
|
+ + "deficiency_id BIGINT, "
|
|
|
+ + "message_id BIGINT, "
|
|
|
+ + "status VARCHAR(20) DEFAULT 'pending', "
|
|
|
+ + "priority VARCHAR(10) DEFAULT 'medium', "
|
|
|
+ + "description VARCHAR(500), "
|
|
|
+ + "parent_feedback TEXT, "
|
|
|
+ + "teacher_review VARCHAR(500), "
|
|
|
+ + "due_date DATETIME, "
|
|
|
+ + "completed_at DATETIME, "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_family (family_id), "
|
|
|
+ + "INDEX idx_teacher (teacher_id), "
|
|
|
+ + "INDEX idx_status (status), "
|
|
|
+ + "INDEX idx_due (due_date)"
|
|
|
+ + ")");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 9: 创建 7 个 Mapper 文件(全部 BaseMapper 继承)**
|
|
|
+
|
|
|
+每个 mapper 格式如下,以 ReportFoodSuitabilityMapper.java 为例:
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.ReportFoodSuitability;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface ReportFoodSuitabilityMapper extends BaseMapper<ReportFoodSuitability> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+同样的模式创建:
|
|
|
+- UserFoodPreferenceMapper.java
|
|
|
+- FoodNutrientMapper.java
|
|
|
+- RdiStandardMapper.java
|
|
|
+- ShoppingListMapper.java
|
|
|
+- ShoppingListItemMapper.java
|
|
|
+- FollowUpMapper.java
|
|
|
+
|
|
|
+- [ ] **Step 10: 验证编译通过**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS (or 无编译错误)
|
|
|
+
|
|
|
+- [ ] **Step 11: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ReportFoodSuitability.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/UserFoodPreference.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/FoodNutrient.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/RdiStandard.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ShoppingList.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ShoppingListItem.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/FollowUp.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/*Mapper.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
|
|
|
+git commit -m "feat(p1): Wave1 数据库层 — 7张新表+实体+Mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 2: PDF 解析扩展 + 食材适宜度
|
|
|
+
|
|
|
+### Task 2.1: ParsedFoodSuitability DTO + ParsedReportResult 扩展
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ParsedFoodSuitability.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+
|
|
|
+public class ParsedFoodSuitability {
|
|
|
+ private String foodName;
|
|
|
+ private String category;
|
|
|
+ private Integer score; // 推荐指数 -100~+100
|
|
|
+ private BigDecimal energyKj;
|
|
|
+ private BigDecimal protein;
|
|
|
+ private BigDecimal fat;
|
|
|
+ private BigDecimal carbs;
|
|
|
+ private BigDecimal starch;
|
|
|
+ private BigDecimal fiber;
|
|
|
+ private BigDecimal cholesterol;
|
|
|
+
|
|
|
+ // getters/setters
|
|
|
+ public String getFoodName() { return foodName; }
|
|
|
+ public void setFoodName(String foodName) { this.foodName = foodName; }
|
|
|
+ // ... 剩余 getter/setter
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 修改 ParsedReportResult.java — 新增字段**
|
|
|
+
|
|
|
+找到文件末尾 getter/setter 之前,添加:
|
|
|
+```java
|
|
|
+private List<ParsedFoodSuitability> foodSuitability;
|
|
|
+
|
|
|
+public List<ParsedFoodSuitability> getFoodSuitability() { return foodSuitability; }
|
|
|
+public void setFoodSuitability(List<ParsedFoodSuitability> foodSuitability) { this.foodSuitability = foodSuitability; }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedFoodSuitability.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportResult.java
|
|
|
+git commit -m "feat(p1): Wave2.1 — ParsedFoodSuitability DTO"
|
|
|
+```
|
|
|
+
|
|
|
+### Task 2.2: PdfParseService 扩展 — parseFoodSuitability()
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 PdfParseService.java 中添加 parseFoodSuitability()**
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 解析PDF中的食材推荐章节
|
|
|
+ * PDF格式:每行一个食材,格式为: 食材名称,分类,推荐指数,能量KJ,蛋白质g,脂肪g,碳水化合物g,淀粉g,膳食纤维g,胆固醇mg
|
|
|
+ */
|
|
|
+public List<ParsedFoodSuitability> parseFoodSuitability() {
|
|
|
+ List<ParsedFoodSuitability> result = new ArrayList<>();
|
|
|
+ // 定位食材章节
|
|
|
+ String foodSection = findSection("食材推荐", "食材评分", "食物适宜度");
|
|
|
+ if (foodSection == null || foodSection.trim().isEmpty()) {
|
|
|
+ return result; // 空列表,非错误
|
|
|
+ }
|
|
|
+
|
|
|
+ String[] lines = foodSection.split("\n");
|
|
|
+ for (String line : lines) {
|
|
|
+ line = line.trim();
|
|
|
+ if (line.isEmpty()) continue;
|
|
|
+ String[] parts = line.split(",");
|
|
|
+ if (parts.length < 4) continue; // 最少要有名称+分类+分数
|
|
|
+
|
|
|
+ try {
|
|
|
+ ParsedFoodSuitability item = new ParsedFoodSuitability();
|
|
|
+ item.setFoodName(parts[0].trim());
|
|
|
+ item.setCategory(parts[1].trim());
|
|
|
+ item.setScore(Integer.parseInt(parts[2].trim()));
|
|
|
+
|
|
|
+ // 后续字段可选
|
|
|
+ if (parts.length > 3) item.setEnergyKj(new BigDecimal(parts[3].trim()));
|
|
|
+ if (parts.length > 4) item.setProtein(new BigDecimal(parts[4].trim()));
|
|
|
+ if (parts.length > 5) item.setFat(new BigDecimal(parts[5].trim()));
|
|
|
+ if (parts.length > 6) item.setCarbs(new BigDecimal(parts[6].trim()));
|
|
|
+ if (parts.length > 7) item.setStarch(new BigDecimal(parts[7].trim()));
|
|
|
+ if (parts.length > 8) item.setFiber(new BigDecimal(parts[8].trim()));
|
|
|
+ if (parts.length > 9) item.setCholesterol(new BigDecimal(parts[9].trim()));
|
|
|
+
|
|
|
+ result.add(item);
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ // 跳过解析失败的食材行
|
|
|
+ log.warn("Failed to parse food suitability line: {}", line);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+/** 查找PDF中包含指定关键词的章节内容 */
|
|
|
+private String findSection(String... keywords) {
|
|
|
+ // 遍历 PDF 文本行,找到第一个包含任一关键词的区域
|
|
|
+ // 返回其后续行直到下一个章节标题
|
|
|
+ // 实现取决于 PDF 文本提取方式(如 PDFBox、iText 等)
|
|
|
+ return null; // 占位,需要根据实际PDF解析库实现
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 注意:实际实现需要根据 PdfParseService 当前使用的 PDF 解析库(如 PDFBox/iText)来实现文本区域定位。上述代码为逻辑框架,具体 findSection 方法需适配现有解析流程。
|
|
|
+
|
|
|
+- [ ] **Step 2: 修改 parse() 主方法,在解析流程末尾调用 parseFoodSuitability()**
|
|
|
+
|
|
|
+```java
|
|
|
+// 在 PdfParseService.parse() 方法的 return 之前添加
|
|
|
+parsedResult.setFoodSuitability(parseFoodSuitability());
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 修改 HealthReportService 的保存逻辑,将解析出的食材数据持久化**
|
|
|
+
|
|
|
+在 HealthReportService.createReport() 或对应的 PDF 保存方法中,解析完成后:
|
|
|
+```java
|
|
|
+// 在 report 保存之后,遍历 foodSuitability
|
|
|
+List<ParsedFoodSuitability> foods = parsedResult.getFoodSuitability();
|
|
|
+if (foods != null && !foods.isEmpty()) {
|
|
|
+ for (ParsedFoodSuitability pf : foods) {
|
|
|
+ ReportFoodSuitability entity = new ReportFoodSuitability();
|
|
|
+ entity.setReportId(reportId);
|
|
|
+ entity.setFoodName(pf.getFoodName());
|
|
|
+ entity.setCategory(pf.getCategory());
|
|
|
+ entity.setScore(pf.getScore());
|
|
|
+
|
|
|
+ // 匹配 foods 表
|
|
|
+ Food matchedFood = foodService.findByName(pf.getFoodName());
|
|
|
+ if (matchedFood != null) {
|
|
|
+ entity.setFoodId(matchedFood.getId());
|
|
|
+ // 首次采集:更新食物的营养数据(如果缺失)
|
|
|
+ updateFoodNutritionIfNeeded(matchedFood, pf);
|
|
|
+ }
|
|
|
+
|
|
|
+ reportFoodSuitabilityService.save(entity);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 实现 updateFoodNutritionIfNeeded()**
|
|
|
+
|
|
|
+```java
|
|
|
+private void updateFoodNutritionIfNeeded(Food food, ParsedFoodSuitability pf) {
|
|
|
+ boolean needsUpdate = false;
|
|
|
+ if (pf.getEnergyKj() != null && food.getEnergyKj() == null) {
|
|
|
+ food.setEnergyKj(pf.getEnergyKj());
|
|
|
+ needsUpdate = true;
|
|
|
+ }
|
|
|
+ if (pf.getStarch() != null && food.getStarch() == null) {
|
|
|
+ food.setStarch(pf.getStarch());
|
|
|
+ needsUpdate = true;
|
|
|
+ }
|
|
|
+ if (pf.getCholesterol() != null && food.getCholesterol() == null) {
|
|
|
+ food.setCholesterol(pf.getCholesterol());
|
|
|
+ needsUpdate = true;
|
|
|
+ }
|
|
|
+ if (needsUpdate) {
|
|
|
+ foodService.update(food);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/PdfParseService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
|
|
|
+git commit -m "feat(p1): Wave2.2 — PdfParseService 食材解析扩展"
|
|
|
+```
|
|
|
+
|
|
|
+### Task 2.3: 食材适宜度 Service 层 + Controller 端点
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ReportFoodSuitabilityServiceInterface.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service.api;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.ReportFoodSuitability;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+public interface ReportFoodSuitabilityServiceInterface {
|
|
|
+ List<ReportFoodSuitability> getByReportId(Long reportId);
|
|
|
+ List<ReportFoodSuitability> getByReportIdWithScoreAbove(Long reportId, int minScore);
|
|
|
+ ReportFoodSuitability save(ReportFoodSuitability entity);
|
|
|
+ List<ReportFoodSuitability> getHistoryByFoodId(Long foodId);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 ReportFoodSuitabilityService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.ReportFoodSuitability;
|
|
|
+import com.etotem.cfc.mapper.ReportFoodSuitabilityMapper;
|
|
|
+import com.etotem.cfc.service.api.ReportFoodSuitabilityServiceInterface;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class ReportFoodSuitabilityService implements ReportFoodSuitabilityServiceInterface {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ReportFoodSuitabilityMapper reportFoodSuitabilityMapper;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<ReportFoodSuitability> getByReportId(Long reportId) {
|
|
|
+ LambdaQueryWrapper<ReportFoodSuitability> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(ReportFoodSuitability::getReportId, reportId);
|
|
|
+ wrapper.orderByDesc(ReportFoodSuitability::getScore);
|
|
|
+ return reportFoodSuitabilityMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<ReportFoodSuitability> getByReportIdWithScoreAbove(Long reportId, int minScore) {
|
|
|
+ LambdaQueryWrapper<ReportFoodSuitability> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(ReportFoodSuitability::getReportId, reportId);
|
|
|
+ wrapper.ge(ReportFoodSuitability::getScore, minScore);
|
|
|
+ wrapper.orderByDesc(ReportFoodSuitability::getScore);
|
|
|
+ return reportFoodSuitabilityMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public ReportFoodSuitability save(ReportFoodSuitability entity) {
|
|
|
+ reportFoodSuitabilityMapper.insert(entity);
|
|
|
+ return entity;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<ReportFoodSuitability> getHistoryByFoodId(Long foodId) {
|
|
|
+ LambdaQueryWrapper<ReportFoodSuitability> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(ReportFoodSuitability::getFoodId, foodId);
|
|
|
+ wrapper.orderByDesc(ReportFoodSuitability::getCreatedAt);
|
|
|
+ return reportFoodSuitabilityMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 UserFoodPreferenceService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.UserFoodPreference;
|
|
|
+import com.etotem.cfc.mapper.UserFoodPreferenceMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class UserFoodPreferenceService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserFoodPreferenceMapper userFoodPreferenceMapper;
|
|
|
+
|
|
|
+ public List<UserFoodPreference> getByUserId(Long userId) {
|
|
|
+ LambdaQueryWrapper<UserFoodPreference> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(UserFoodPreference::getUserId, userId);
|
|
|
+ return userFoodPreferenceMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ public UserFoodPreference save(UserFoodPreference preference) {
|
|
|
+ // UPSERT: 同一用户+食材只保留一条
|
|
|
+ LambdaQueryWrapper<UserFoodPreference> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(UserFoodPreference::getUserId, preference.getUserId());
|
|
|
+ wrapper.eq(UserFoodPreference::getFoodId, preference.getFoodId());
|
|
|
+ UserFoodPreference existing = userFoodPreferenceMapper.selectOne(wrapper);
|
|
|
+ if (existing != null) {
|
|
|
+ preference.setId(existing.getId());
|
|
|
+ userFoodPreferenceMapper.updateById(preference);
|
|
|
+ } else {
|
|
|
+ userFoodPreferenceMapper.insert(preference);
|
|
|
+ }
|
|
|
+ return preference;
|
|
|
+ }
|
|
|
+
|
|
|
+ public void delete(Long id) {
|
|
|
+ userFoodPreferenceMapper.deleteById(id);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 在 HealthReportController 中新増食材适宜度查询端点**
|
|
|
+
|
|
|
+```java
|
|
|
+// POST /api/health/report/{reportId}/food-suitability
|
|
|
+@PostMapping("/report/{reportId}/food-suitability")
|
|
|
+public Result<List<ReportFoodSuitability>> getFoodSuitability(
|
|
|
+ @PathVariable Long reportId) {
|
|
|
+ List<ReportFoodSuitability> list =
|
|
|
+ reportFoodSuitabilityService.getByReportId(reportId);
|
|
|
+ return Result.success(list);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/api/ReportFoodSuitabilityServiceInterface.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ReportFoodSuitabilityService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/UserFoodPreferenceService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
|
|
|
+git commit -m "feat(p1): Wave2.3 — 食材适宜度Service+Controller"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 3: 微量营养素查询 + RDI 计算
|
|
|
+
|
|
|
+### Task 3.1: FoodNutrientService + RdiStandardService
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 FoodNutrientService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.FoodNutrient;
|
|
|
+import com.etotem.cfc.mapper.FoodNutrientMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class FoodNutrientService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FoodNutrientMapper foodNutrientMapper;
|
|
|
+
|
|
|
+ /** 获取某食材的所有微量营养素 */
|
|
|
+ public List<FoodNutrient> getByFoodId(Long foodId) {
|
|
|
+ LambdaQueryWrapper<FoodNutrient> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(FoodNutrient::getFoodId, foodId);
|
|
|
+ return foodNutrientMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按营养素编码查询含量最高的食材 */
|
|
|
+ public List<FoodNutrient> getTopFoodsByNutrient(String nutrientCode, int limit) {
|
|
|
+ LambdaQueryWrapper<FoodNutrient> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(FoodNutrient::getNutrientCode, nutrientCode);
|
|
|
+ wrapper.orderByDesc(FoodNutrient::getAmountPer100g);
|
|
|
+ wrapper.last("LIMIT " + limit);
|
|
|
+ return foodNutrientMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 批量保存(用于PDF首次解析后的数据导入) */
|
|
|
+ public void batchSave(List<FoodNutrient> nutrients) {
|
|
|
+ nutrients.forEach(n -> {
|
|
|
+ LambdaQueryWrapper<FoodNutrient> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(FoodNutrient::getFoodId, n.getFoodId());
|
|
|
+ wrapper.eq(FoodNutrient::getNutrientCode, n.getNutrientCode());
|
|
|
+ FoodNutrient existing = foodNutrientMapper.selectOne(wrapper);
|
|
|
+ if (existing == null) {
|
|
|
+ foodNutrientMapper.insert(n);
|
|
|
+ } else {
|
|
|
+ n.setId(existing.getId());
|
|
|
+ foodNutrientMapper.updateById(n);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 RdiStandardService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.RdiStandard;
|
|
|
+import com.etotem.cfc.mapper.RdiStandardMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class RdiStandardService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private RdiStandardMapper rdiStandardMapper;
|
|
|
+
|
|
|
+ /** 查询某年龄段+性别+营养素的推荐摄入量 */
|
|
|
+ public RdiStandard getRdi(String nutrientCode, String ageGroup, String gender) {
|
|
|
+ LambdaQueryWrapper<RdiStandard> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(RdiStandard::getNutrientCode, nutrientCode);
|
|
|
+ wrapper.eq(RdiStandard::getAgeGroup, ageGroup);
|
|
|
+ wrapper.and(w -> w.eq(RdiStandard::getGender, gender)
|
|
|
+ .or().eq(RdiStandard::getGender, "all"));
|
|
|
+ wrapper.eq(RdiStandard::getRdiType, "RNI");
|
|
|
+ return rdiStandardMapper.selectOne(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 查询UL上限值 */
|
|
|
+ public RdiStandard getUl(String nutrientCode, String ageGroup, String gender) {
|
|
|
+ LambdaQueryWrapper<RdiStandard> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(RdiStandard::getNutrientCode, nutrientCode);
|
|
|
+ wrapper.eq(RdiStandard::getAgeGroup, ageGroup);
|
|
|
+ wrapper.and(w -> w.eq(RdiStandard::getGender, gender)
|
|
|
+ .or().eq(RdiStandard::getGender, "all"));
|
|
|
+ wrapper.eq(RdiStandard::getRdiType, "UL");
|
|
|
+ return rdiStandardMapper.selectOne(wrapper);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 NutritionCalculator.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.common;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.RdiStandard;
|
|
|
+import com.etotem.cfc.service.RdiStandardService;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+
|
|
|
+@Component
|
|
|
+public class NutritionCalculator {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private RdiStandardService rdiStandardService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算某食物某营养素占RDI的百分比
|
|
|
+ * @param amountPer100g 每100g含量
|
|
|
+ * @param nutrientCode 营养素编码
|
|
|
+ * @param ageGroup 年龄段
|
|
|
+ * @param gender 性别
|
|
|
+ * @param servingSizeGrams 实际食用克数
|
|
|
+ * @return 百分比(如 0.85 表示85%)
|
|
|
+ */
|
|
|
+ public BigDecimal calculateRdiPercentage(BigDecimal amountPer100g,
|
|
|
+ String nutrientCode,
|
|
|
+ String ageGroup,
|
|
|
+ String gender,
|
|
|
+ BigDecimal servingSizeGrams) {
|
|
|
+ if (amountPer100g == null || servingSizeGrams == null) {
|
|
|
+ return BigDecimal.ZERO;
|
|
|
+ }
|
|
|
+ RdiStandard rdi = rdiStandardService.getRdi(nutrientCode, ageGroup, gender);
|
|
|
+ if (rdi == null || rdi.getAmount() == null) {
|
|
|
+ return null; // 无RDI标准
|
|
|
+ }
|
|
|
+ // 实际摄入 = 每100g含量 * (食用量/100)
|
|
|
+ BigDecimal actualIntake = amountPer100g
|
|
|
+ .multiply(servingSizeGrams)
|
|
|
+ .divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
|
|
|
+ // 占RDI百分比
|
|
|
+ return actualIntake.divide(rdi.getAmount(), 4, RoundingMode.HALF_UP);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: DatabaseInitializer.java — SEED RDI 参考数据**
|
|
|
+
|
|
|
+在 run() 方法末尾添加 RDI seed 数据(示例:部分数据,完整数据需参考 DRIs 2023 手册):
|
|
|
+
|
|
|
+```java
|
|
|
+// 仅当 rdi_standards 表为空时插入 seed
|
|
|
+Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM rdi_standards", Integer.class);
|
|
|
+if (count == null || count == 0) {
|
|
|
+ Object[][] rdiData = {
|
|
|
+ // nutrient_code, age_group, gender, rdi_type, amount, unit
|
|
|
+ {"IRON", "1-3", "all", "RNI", 9.0, "mg"},
|
|
|
+ {"IRON", "4-6", "all", "RNI", 10.0, "mg"},
|
|
|
+ {"IRON", "7-10", "all", "RNI", 13.0, "mg"},
|
|
|
+ {"IRON", "11-13", "male", "RNI", 15.0, "mg"},
|
|
|
+ {"IRON", "11-13", "female", "RNI", 18.0, "mg"},
|
|
|
+ {"IRON", "14-17", "male", "RNI", 16.0, "mg"},
|
|
|
+ {"IRON", "14-17", "female", "RNI", 20.0, "mg"},
|
|
|
+ {"CALCIUM", "1-3", "all", "RNI", 600.0, "mg"},
|
|
|
+ {"CALCIUM", "4-6", "all", "RNI", 800.0, "mg"},
|
|
|
+ {"CALCIUM", "7-10", "all", "RNI", 1000.0, "mg"},
|
|
|
+ {"CALCIUM", "11-13", "all", "RNI", 1200.0, "mg"},
|
|
|
+ {"CALCIUM", "14-17", "all", "RNI", 1000.0, "mg"},
|
|
|
+ {"ZINC", "1-3", "all", "RNI", 4.0, "mg"},
|
|
|
+ {"ZINC", "4-6", "all", "RNI", 5.5, "mg"},
|
|
|
+ {"ZINC", "7-10", "all", "RNI", 7.0, "mg"},
|
|
|
+ {"ZINC", "11-13", "male", "RNI", 10.0, "mg"},
|
|
|
+ {"ZINC", "11-13", "female", "RNI", 9.0, "mg"},
|
|
|
+ {"VIT_D", "1-17", "all", "RNI", 10.0, "μg"},
|
|
|
+ {"VIT_A", "1-3", "all", "RNI", 310.0, "μg"},
|
|
|
+ {"VIT_A", "4-6", "all", "RNI", 360.0, "μg"},
|
|
|
+ {"VIT_A", "7-10", "all", "RNI", 500.0, "μg"},
|
|
|
+ {"VIT_A", "11-13", "male", "RNI", 670.0, "μg"},
|
|
|
+ {"VIT_A", "11-13", "female", "RNI", 630.0, "μg"},
|
|
|
+ {"VIT_C", "1-3", "all", "RNI", 40.0, "mg"},
|
|
|
+ {"VIT_C", "4-6", "all", "RNI", 50.0, "mg"},
|
|
|
+ {"VIT_C", "7-10", "all", "RNI", 65.0, "mg"},
|
|
|
+ {"VIT_C", "11-13", "all", "RNI", 90.0, "mg"},
|
|
|
+ {"VIT_C", "14-17", "all", "RNI", 100.0, "mg"},
|
|
|
+ // ... 完整 DRIs 2023 数据
|
|
|
+ };
|
|
|
+ for (Object[] row : rdiData) {
|
|
|
+ jdbcTemplate.update(
|
|
|
+ "INSERT IGNORE INTO rdi_standards (nutrient_code, age_group, gender, rdi_type, amount, unit, source) VALUES (?, ?, ?, ?, ?, ?, 'DRIs2023')",
|
|
|
+ row[0], row[1], row[2], row[3], row[4], row[5]
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FoodNutrientService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/RdiStandardService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/common/NutritionCalculator.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
|
|
|
+git commit -m "feat(p1): Wave3 — FoodNutrientService+RdiStandardService+NutritionCalculator"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 4: 购物清单生成
|
|
|
+
|
|
|
+### Task 4.1: ShoppingListService — 生成逻辑 + Controller
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ShoppingListServiceInterface.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service.api;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.ShoppingList;
|
|
|
+import com.etotem.cfc.entity.ShoppingListItem;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+public interface ShoppingListServiceInterface {
|
|
|
+ /** 基于菌群报告生成购物清单 */
|
|
|
+ ShoppingList createFromReport(Long userId, Long childId, Long reportId);
|
|
|
+ /** 基于缺乏分析生成购物清单 */
|
|
|
+ ShoppingList createFromDeficiency(Long userId, Long childId, Long deficiencyId);
|
|
|
+ /** 获取用户的活跃清单 */
|
|
|
+ List<ShoppingList> getUserActiveLists(Long userId);
|
|
|
+ /** 标记已购 */
|
|
|
+ void markItemPurchased(Long itemId);
|
|
|
+ /** 手动添加食材 */
|
|
|
+ ShoppingListItem addItemManually(Long listId, Long foodId, String foodName, Integer score, String reason);
|
|
|
+ /** 归档清单 */
|
|
|
+ void archiveList(Long listId);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 ShoppingListService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
+import com.etotem.cfc.mapper.ShoppingListMapper;
|
|
|
+import com.etotem.cfc.mapper.ShoppingListItemMapper;
|
|
|
+import com.etotem.cfc.service.api.ShoppingListServiceInterface;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class ShoppingListService implements ShoppingListServiceInterface {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ShoppingListMapper shoppingListMapper;
|
|
|
+ @Resource
|
|
|
+ private ShoppingListItemMapper shoppingListItemMapper;
|
|
|
+ @Resource
|
|
|
+ private ReportFoodSuitabilityService reportFoodSuitabilityService;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ @Transactional
|
|
|
+ public ShoppingList createFromReport(Long userId, Long childId, Long reportId) {
|
|
|
+ // 1. 创建清单主记录
|
|
|
+ ShoppingList list = new ShoppingList();
|
|
|
+ list.setUserId(userId);
|
|
|
+ list.setChildId(childId);
|
|
|
+ list.setReportId(reportId);
|
|
|
+ list.setTitle("营养购物清单");
|
|
|
+ list.setSource("report");
|
|
|
+ list.setStatus("active");
|
|
|
+ list.setTotalItems(0);
|
|
|
+ list.setPurchasedItems(0);
|
|
|
+ shoppingListMapper.insert(list);
|
|
|
+
|
|
|
+ // 2. 从报告食材适宜度取 score >= 50 的食材
|
|
|
+ List<ReportFoodSuitability> foods = reportFoodSuitabilityService
|
|
|
+ .getByReportIdWithScoreAbove(reportId, 50);
|
|
|
+
|
|
|
+ int itemCount = 0;
|
|
|
+ for (ReportFoodSuitability rf : foods) {
|
|
|
+ ShoppingListItem item = new ShoppingListItem();
|
|
|
+ item.setListId(list.getId());
|
|
|
+ item.setFoodId(rf.getFoodId());
|
|
|
+ item.setFoodName(rf.getFoodName());
|
|
|
+ item.setCategory(rf.getCategory());
|
|
|
+ item.setQuantity(BigDecimal.ONE);
|
|
|
+ item.setUnit("份");
|
|
|
+ item.setPriority(rf.getScore() >= 80 ? "high" : "medium");
|
|
|
+ item.setReason("菌群报告推荐指数 " + rf.getScore());
|
|
|
+ item.setScore(rf.getScore());
|
|
|
+ item.setSource("report");
|
|
|
+ item.setPurchased(false);
|
|
|
+ item.setSortOrder(-rf.getScore()); // score越高越靠前
|
|
|
+ shoppingListItemMapper.insert(item);
|
|
|
+ itemCount++;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 更新总数
|
|
|
+ list.setTotalItems(itemCount);
|
|
|
+ shoppingListMapper.updateById(list);
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ @Transactional
|
|
|
+ public ShoppingList createFromDeficiency(Long userId, Long childId, Long deficiencyId) {
|
|
|
+ // 简化:创建清单 + 根据缺乏分析推荐食材
|
|
|
+ ShoppingList list = new ShoppingList();
|
|
|
+ list.setUserId(userId);
|
|
|
+ list.setChildId(childId);
|
|
|
+ list.setSource("deficiency");
|
|
|
+ list.setTitle("营养补充购物清单");
|
|
|
+ list.setStatus("active");
|
|
|
+ shoppingListMapper.insert(list);
|
|
|
+ // 具体缺乏分析→食材映射逻辑(整合 P0 NutritionDeficiencyService)
|
|
|
+ // 暂留 TODO,待集成时完善
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<ShoppingList> getUserActiveLists(Long userId) {
|
|
|
+ LambdaQueryWrapper<ShoppingList> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(ShoppingList::getUserId, userId);
|
|
|
+ wrapper.eq(ShoppingList::getStatus, "active");
|
|
|
+ wrapper.orderByDesc(ShoppingList::getCreatedAt);
|
|
|
+ return shoppingListMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void markItemPurchased(Long itemId) {
|
|
|
+ ShoppingListItem item = shoppingListItemMapper.selectById(itemId);
|
|
|
+ if (item == null) return;
|
|
|
+ item.setPurchased(true);
|
|
|
+ item.setPurchasedAt(new Date());
|
|
|
+ shoppingListItemMapper.updateById(item);
|
|
|
+
|
|
|
+ // 更新清单已购数
|
|
|
+ ShoppingList list = shoppingListMapper.selectById(item.getListId());
|
|
|
+ if (list != null) {
|
|
|
+ LambdaQueryWrapper<ShoppingListItem> countWrapper = new LambdaQueryWrapper<>();
|
|
|
+ countWrapper.eq(ShoppingListItem::getListId, list.getId());
|
|
|
+ countWrapper.eq(ShoppingListItem::getPurchased, true);
|
|
|
+ Long purchasedCount = shoppingListItemMapper.selectCount(countWrapper);
|
|
|
+ list.setPurchasedItems(purchasedCount.intValue());
|
|
|
+ shoppingListMapper.updateById(list);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public ShoppingListItem addItemManually(Long listId, Long foodId, String foodName, Integer score, String reason) {
|
|
|
+ ShoppingListItem item = new ShoppingListItem();
|
|
|
+ item.setListId(listId);
|
|
|
+ item.setFoodId(foodId);
|
|
|
+ item.setFoodName(foodName);
|
|
|
+ item.setQuantity(BigDecimal.ONE);
|
|
|
+ item.setUnit("份");
|
|
|
+ item.setPriority(score != null && score >= 50 ? "high" : "medium");
|
|
|
+ item.setReason(reason);
|
|
|
+ item.setScore(score);
|
|
|
+ item.setSource("manual");
|
|
|
+ item.setPurchased(false);
|
|
|
+ shoppingListItemMapper.insert(item);
|
|
|
+
|
|
|
+ // 更新清单总数
|
|
|
+ ShoppingList list = shoppingListMapper.selectById(listId);
|
|
|
+ if (list != null) {
|
|
|
+ list.setTotalItems(list.getTotalItems() + 1);
|
|
|
+ shoppingListMapper.updateById(list);
|
|
|
+ }
|
|
|
+ return item;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void archiveList(Long listId) {
|
|
|
+ ShoppingList list = shoppingListMapper.selectById(listId);
|
|
|
+ if (list != null) {
|
|
|
+ list.setStatus("archived");
|
|
|
+ shoppingListMapper.updateById(list);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 ShoppingListController.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.ShoppingList;
|
|
|
+import com.etotem.cfc.entity.ShoppingListItem;
|
|
|
+import com.etotem.cfc.service.ShoppingListService;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/shopping-list")
|
|
|
+public class ShoppingListController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ShoppingListService shoppingListService;
|
|
|
+
|
|
|
+ @PostMapping("/create-from-report")
|
|
|
+ public Result<ShoppingList> createFromReport(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestParam Long childId,
|
|
|
+ @RequestParam Long reportId) {
|
|
|
+ ShoppingList list = shoppingListService.createFromReport(userId, childId, reportId);
|
|
|
+ return Result.success(list);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<List<ShoppingList>> list(@RequestAttribute("userId") Long userId) {
|
|
|
+ return Result.success(shoppingListService.getUserActiveLists(userId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/{id}/items")
|
|
|
+ public Result<List<ShoppingListItem>> getItems(@PathVariable Long id) {
|
|
|
+ LambdaQueryWrapper<ShoppingListItem> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(ShoppingListItem::getListId, id);
|
|
|
+ wrapper.orderByAsc(ShoppingListItem::getSortOrder);
|
|
|
+ // 需要注入 mapper
|
|
|
+ return Result.success(/* items */);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/item/{id}/purchase")
|
|
|
+ public Result<Void> markPurchased(@PathVariable Long id) {
|
|
|
+ shoppingListService.markItemPurchased(id);
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/item/add")
|
|
|
+ public Result<ShoppingListItem> addItem(@RequestBody AddItemRequest request,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ ShoppingListItem item = shoppingListService.addItemManually(
|
|
|
+ request.getListId(), request.getFoodId(), request.getFoodName(),
|
|
|
+ request.getScore(), request.getReason());
|
|
|
+ return Result.success(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/{id}/archive")
|
|
|
+ public Result<Void> archive(@PathVariable Long id) {
|
|
|
+ shoppingListService.archiveList(id);
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 内部 DTO
|
|
|
+ public static class AddItemRequest {
|
|
|
+ private Long listId;
|
|
|
+ private Long foodId;
|
|
|
+ private String foodName;
|
|
|
+ private Integer score;
|
|
|
+ private String reason;
|
|
|
+ // getters/setters
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/api/ShoppingListServiceInterface.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ShoppingListService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ShoppingListController.java
|
|
|
+git commit -m "feat(p1): Wave4.1 — ShoppingListService+Controller"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 5: 营养师-家庭沟通闭环
|
|
|
+
|
|
|
+### Task 5.1: TeacherMessage 扩展
|
|
|
+
|
|
|
+- [ ] **Step 1: DatabaseInitializer.java — ALTER TABLE teacher_messages**
|
|
|
+
|
|
|
+```java
|
|
|
+// 检查列是否存在后ALTER
|
|
|
+if (!columnExists(jdbcTemplate, "teacher_messages", "sender_role")) {
|
|
|
+ jdbcTemplate.execute("ALTER TABLE teacher_messages "
|
|
|
+ + "ADD COLUMN sender_role VARCHAR(20) DEFAULT 'parent' AFTER status, "
|
|
|
+ + "ADD COLUMN read_at DATETIME AFTER sender_role, "
|
|
|
+ + "ADD COLUMN relates_to_type VARCHAR(50) AFTER read_at, "
|
|
|
+ + "ADD COLUMN relates_to_id BIGINT AFTER relates_to_type, "
|
|
|
+ + "ADD COLUMN message_type VARCHAR(20) DEFAULT 'text' AFTER relates_to_id");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 修改 TeacherMessage.java** — 新增字段
|
|
|
+
|
|
|
+```java
|
|
|
+private String senderRole; // parent/teacher/nutritionist
|
|
|
+private Date readAt;
|
|
|
+private String relatesToType; // deficiency_report/shopping_list/meal_plan/general
|
|
|
+private Long relatesToId;
|
|
|
+private String messageType; // text/recommendation/system
|
|
|
+// getters/setters
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 扩展 SendMessageDTO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+public class SendMessageDTO {
|
|
|
+ private String content;
|
|
|
+ private Long familyId;
|
|
|
+ // 新增
|
|
|
+ private String senderRole;
|
|
|
+ private String relatesToType;
|
|
|
+ private Long relatesToId;
|
|
|
+ private String messageType;
|
|
|
+ // getters/setters
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 扩展 TeacherMessageService.java — 双向发送 + 已读**
|
|
|
+
|
|
|
+```java
|
|
|
+@Override
|
|
|
+public Result<TeacherMessage> send(SendMessageDTO dto) {
|
|
|
+ TeacherMessage msg = new TeacherMessage();
|
|
|
+ msg.setFamilyId(dto.getFamilyId());
|
|
|
+ msg.setContent(dto.getContent());
|
|
|
+ msg.setSenderRole(dto.getSenderRole() != null ? dto.getSenderRole() : "parent");
|
|
|
+ msg.setStatus("pending");
|
|
|
+ msg.setRelatesToType(dto.getRelatesToType());
|
|
|
+ msg.setRelatesToId(dto.getRelatesToId());
|
|
|
+ msg.setMessageType(dto.getMessageType() != null ? dto.getMessageType() : "text");
|
|
|
+ teacherMessageMapper.insert(msg);
|
|
|
+ return Result.success(msg);
|
|
|
+}
|
|
|
+
|
|
|
+// 新增:标记已读
|
|
|
+@Override
|
|
|
+public Result<Void> markAsRead(Long messageId) {
|
|
|
+ TeacherMessage msg = teacherMessageMapper.selectById(messageId);
|
|
|
+ if (msg != null && msg.getReadAt() == null) {
|
|
|
+ msg.setReadAt(new Date());
|
|
|
+ teacherMessageMapper.updateById(msg);
|
|
|
+ }
|
|
|
+ return Result.success();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/TeacherMessage.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/SendMessageDTO.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/TeacherMessageService.java
|
|
|
+git commit -m "feat(p1): Wave5.1 — TeacherMessage扩展双向+已读"
|
|
|
+```
|
|
|
+
|
|
|
+### Task 5.2: FollowUp Service + Controller
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 FollowUpServiceInterface.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service.api;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.FollowUp;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+public interface FollowUpServiceInterface {
|
|
|
+ FollowUp create(FollowUp followUp);
|
|
|
+ List<FollowUp> getByTeacherId(Long teacherId);
|
|
|
+ List<FollowUp> getByFamilyId(Long familyId);
|
|
|
+ List<FollowUp> getDueToday(Long teacherId);
|
|
|
+ void updateStatus(Long id, String status);
|
|
|
+ void submitFeedback(Long id, String feedback);
|
|
|
+ void submitReview(Long id, String review);
|
|
|
+ void complete(Long id);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 FollowUpService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.FollowUp;
|
|
|
+import com.etotem.cfc.mapper.FollowUpMapper;
|
|
|
+import com.etotem.cfc.service.api.FollowUpServiceInterface;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class FollowUpService implements FollowUpServiceInterface {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FollowUpMapper followUpMapper;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public FollowUp create(FollowUp followUp) {
|
|
|
+ followUp.setStatus("pending");
|
|
|
+ followUpMapper.insert(followUp);
|
|
|
+ return followUp;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<FollowUp> getByTeacherId(Long teacherId) {
|
|
|
+ LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(FollowUp::getTeacherId, teacherId);
|
|
|
+ wrapper.orderByDesc(FollowUp::getCreatedAt);
|
|
|
+ return followUpMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<FollowUp> getByFamilyId(Long familyId) {
|
|
|
+ LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(FollowUp::getFamilyId, familyId);
|
|
|
+ wrapper.orderByDesc(FollowUp::getCreatedAt);
|
|
|
+ return followUpMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<FollowUp> getDueToday(Long teacherId) {
|
|
|
+ // 使用MySQL DATE函数获取当天到期
|
|
|
+ LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(FollowUp::getTeacherId, teacherId);
|
|
|
+ wrapper.apply("DATE(due_date) = CURDATE()");
|
|
|
+ wrapper.ne(FollowUp::getStatus, "completed");
|
|
|
+ wrapper.ne(FollowUp::getStatus, "ignored");
|
|
|
+ wrapper.orderByDesc(FollowUp::getPriority);
|
|
|
+ return followUpMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void updateStatus(Long id, String status) {
|
|
|
+ FollowUp followUp = followUpMapper.selectById(id);
|
|
|
+ if (followUp != null) {
|
|
|
+ followUp.setStatus(status);
|
|
|
+ followUpMapper.updateById(followUp);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void submitFeedback(Long id, String feedback) {
|
|
|
+ FollowUp followUp = followUpMapper.selectById(id);
|
|
|
+ if (followUp != null) {
|
|
|
+ followUp.setParentFeedback(feedback);
|
|
|
+ if ("pending".equals(followUp.getStatus())) {
|
|
|
+ followUp.setStatus("in_progress");
|
|
|
+ }
|
|
|
+ followUpMapper.updateById(followUp);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void submitReview(Long id, String review) {
|
|
|
+ FollowUp followUp = followUpMapper.selectById(id);
|
|
|
+ if (followUp != null) {
|
|
|
+ followUp.setTeacherReview(review);
|
|
|
+ followUpMapper.updateById(followUp);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void complete(Long id) {
|
|
|
+ FollowUp followUp = followUpMapper.selectById(id);
|
|
|
+ if (followUp != null) {
|
|
|
+ followUp.setStatus("completed");
|
|
|
+ followUp.setCompletedAt(new Date());
|
|
|
+ followUpMapper.updateById(followUp);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 FollowUpController.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.guide;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.FollowUp;
|
|
|
+import com.etotem.cfc.service.FollowUpService;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/follow-up")
|
|
|
+public class FollowUpController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FollowUpService followUpService;
|
|
|
+
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<FollowUp> create(@RequestBody FollowUp followUp,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ followUp.setTeacherId(userId);
|
|
|
+ return Result.success(followUpService.create(followUp));
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<List<FollowUp>> list(@RequestAttribute("userId") Long userId,
|
|
|
+ @RequestParam(required = false) String status) {
|
|
|
+ List<FollowUp> list = followUpService.getByTeacherId(userId);
|
|
|
+ if (status != null && !status.isEmpty()) {
|
|
|
+ list.removeIf(f -> !status.equals(f.getStatus()));
|
|
|
+ }
|
|
|
+ return Result.success(list);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/{id}/status")
|
|
|
+ public Result<Void> updateStatus(@PathVariable Long id,
|
|
|
+ @RequestParam String status) {
|
|
|
+ followUpService.updateStatus(id, status);
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/{id}/feedback")
|
|
|
+ public Result<Void> submitFeedback(@PathVariable Long id,
|
|
|
+ @RequestBody Map<String, String> body) {
|
|
|
+ followUpService.submitFeedback(id, body.get("feedback"));
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/{id}/review")
|
|
|
+ public Result<Void> submitReview(@PathVariable Long id,
|
|
|
+ @RequestBody Map<String, String> body) {
|
|
|
+ followUpService.submitReview(id, body.get("review"));
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/{id}/complete")
|
|
|
+ public Result<Void> complete(@PathVariable Long id) {
|
|
|
+ followUpService.complete(id);
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/due-today")
|
|
|
+ public Result<List<FollowUp>> dueToday(@RequestAttribute("userId") Long userId) {
|
|
|
+ return Result.success(followUpService.getDueToday(userId));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/api/FollowUpServiceInterface.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FollowUpService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/guide/FollowUpController.java
|
|
|
+git commit -m "feat(p1): Wave5.2 — FollowUp Service+Controller"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 6: 前端集成(小程序 + Web 管理端)
|
|
|
+
|
|
|
+### Task 6.1: 小程序 — 购物清单页面
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 api.js 中添加购物清单 API**
|
|
|
+
|
|
|
+找到 cfc-frontend/utils/api.js,在文件末尾或其他 API 函数附近添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 购物清单 API
|
|
|
+export const createShoppingListFromReport = (childId, reportId) => {
|
|
|
+ return request('/api/shopping-list/create-from-report', { childId, reportId })
|
|
|
+}
|
|
|
+
|
|
|
+export const getShoppingLists = () => {
|
|
|
+ return request('/api/shopping-list/list', {})
|
|
|
+}
|
|
|
+
|
|
|
+export const getShoppingListItems = (id) => {
|
|
|
+ return request(`/api/shopping-list/${id}/items`, {})
|
|
|
+}
|
|
|
+
|
|
|
+export const markItemPurchased = (itemId) => {
|
|
|
+ return request('/api/shopping-list/item/' + itemId + '/purchase', {})
|
|
|
+}
|
|
|
+
|
|
|
+export const addShoppingItem = (data) => {
|
|
|
+ return request('/api/shopping-list/item/add', data)
|
|
|
+}
|
|
|
+
|
|
|
+// 随访 API
|
|
|
+export const getFollowUpList = (status) => {
|
|
|
+ return request('/api/follow-up/list', { status })
|
|
|
+}
|
|
|
+
|
|
|
+export const submitFollowUpFeedback = (id, feedback) => {
|
|
|
+ return request('/api/follow-up/' + id + '/feedback', { feedback })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建小程序购物清单页面**
|
|
|
+
|
|
|
+创建 `cfc-frontend/pages/shopping-list/index.vue`(Vue 2 Options API,禁止可选链 `?.` 和 CSS Grid):
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="shopping-list-page">
|
|
|
+ <view class="page-header">
|
|
|
+ <text class="page-title">购物清单</text>
|
|
|
+ <button class="generate-btn" @click="generateFromReport" v-if="hasReport">
|
|
|
+ 基于报告生成
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="loading" class="loading-container">
|
|
|
+ <text>加载中...</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-else-if="lists.length === 0" class="empty-state">
|
|
|
+ <text class="empty-icon">📋</text>
|
|
|
+ <text class="empty-text">暂无购物清单</text>
|
|
|
+ <text class="empty-hint">上传菌群报告后可自动生成购物清单</text>
|
|
|
+ <button class="goto-report-btn" @click="gotoReport">
|
|
|
+ 去上传报告 →
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <scroll-view v-else scroll-y class="list-scroll">
|
|
|
+ <view v-for="list in lists" :key="list.id" class="list-card">
|
|
|
+ <view class="list-header" @click="toggleExpand(list.id)">
|
|
|
+ <text class="list-title">{{ list.title }}</text>
|
|
|
+ <text class="list-progress">{{ list.purchasedItems }}/{{ list.totalItems }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="progress-bar">
|
|
|
+ <view class="progress-fill" :style="'width: ' + getProgress(list) + '%'"></view>
|
|
|
+ </view>
|
|
|
+ <view v-if="expandedList === list.id" class="list-items">
|
|
|
+ <view v-for="item in getItems(list.id)" :key="item.id" class="item-row">
|
|
|
+ <checkbox :checked="item.purchased" @click="toggleItem(item)" />
|
|
|
+ <view class="item-info">
|
|
|
+ <text class="item-name" :class="{ purchased: item.purchased }">{{ item.foodName }}</text>
|
|
|
+ <text class="item-reason" v-if="item.reason">{{ item.reason }}</text>
|
|
|
+ </view>
|
|
|
+ <text class="item-priority" :class="'priority-' + item.priority">
|
|
|
+ {{ item.score ? item.score + '分' : '' }}
|
|
|
+ </text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getShoppingLists, getShoppingListItems, markItemPurchased,
|
|
|
+ createShoppingListFromReport } from '../../utils/api.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ lists: [],
|
|
|
+ itemsMap: {},
|
|
|
+ expandedList: null,
|
|
|
+ loading: true,
|
|
|
+ hasReport: false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onShow() {
|
|
|
+ this.loadLists()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ loadLists() {
|
|
|
+ this.loading = true
|
|
|
+ getShoppingLists().then(res => {
|
|
|
+ this.lists = res.data || []
|
|
|
+ this.loading = false
|
|
|
+ })
|
|
|
+ },
|
|
|
+ getItems(listId) {
|
|
|
+ return this.itemsMap[listId] || []
|
|
|
+ },
|
|
|
+ toggleExpand(listId) {
|
|
|
+ if (this.expandedList === listId) {
|
|
|
+ this.expandedList = null
|
|
|
+ } else {
|
|
|
+ this.expandedList = listId
|
|
|
+ if (!this.itemsMap[listId]) {
|
|
|
+ getShoppingListItems(listId).then(res => {
|
|
|
+ this.$set(this.itemsMap, listId, res.data || [])
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ toggleItem(item) {
|
|
|
+ markItemPurchased(item.id).then(() => {
|
|
|
+ item.purchased = !item.purchased
|
|
|
+ this.loadLists()
|
|
|
+ })
|
|
|
+ },
|
|
|
+ generateFromReport() {
|
|
|
+ // 简化:从最近的报告生成
|
|
|
+ uni.showLoading({ title: '生成中...' })
|
|
|
+ createShoppingListFromReport(childId, reportId).then(res => {
|
|
|
+ uni.hideLoading()
|
|
|
+ this.loadLists()
|
|
|
+ })
|
|
|
+ },
|
|
|
+ getProgress(list) {
|
|
|
+ if (list.totalItems === 0) return 0
|
|
|
+ return Math.round(list.purchasedItems / list.totalItems * 100)
|
|
|
+ },
|
|
|
+ gotoReport() {
|
|
|
+ uni.navigateTo({ url: '/pages/health/report-list' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.shopping-list-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #f5f5f5;
|
|
|
+ padding: 20rpx;
|
|
|
+}
|
|
|
+.page-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+.page-title {
|
|
|
+ font-size: 36rpx;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.generate-btn {
|
|
|
+ background: #07c160;
|
|
|
+ color: white;
|
|
|
+ padding: 10rpx 24rpx;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+}
|
|
|
+.empty-state {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ padding-top: 200rpx;
|
|
|
+}
|
|
|
+.empty-icon {
|
|
|
+ font-size: 80rpx;
|
|
|
+}
|
|
|
+.empty-text {
|
|
|
+ font-size: 32rpx;
|
|
|
+ color: #999;
|
|
|
+ margin-top: 20rpx;
|
|
|
+}
|
|
|
+.empty-hint {
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #ccc;
|
|
|
+ margin-top: 10rpx;
|
|
|
+}
|
|
|
+.goto-report-btn {
|
|
|
+ margin-top: 40rpx;
|
|
|
+ background: #07c160;
|
|
|
+ color: white;
|
|
|
+ padding: 20rpx 60rpx;
|
|
|
+ border-radius: 12rpx;
|
|
|
+}
|
|
|
+.list-card {
|
|
|
+ background: white;
|
|
|
+ border-radius: 16rpx;
|
|
|
+ padding: 24rpx;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
|
|
|
+}
|
|
|
+.list-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.list-title {
|
|
|
+ font-size: 30rpx;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.list-progress {
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #07c160;
|
|
|
+}
|
|
|
+.progress-bar {
|
|
|
+ height: 8rpx;
|
|
|
+ background: #eee;
|
|
|
+ border-radius: 4rpx;
|
|
|
+ margin: 16rpx 0;
|
|
|
+}
|
|
|
+.progress-fill {
|
|
|
+ height: 100%;
|
|
|
+ background: #07c160;
|
|
|
+ border-radius: 4rpx;
|
|
|
+}
|
|
|
+.list-items {
|
|
|
+ border-top: 1rpx solid #f0f0f0;
|
|
|
+ padding-top: 16rpx;
|
|
|
+}
|
|
|
+.item-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12rpx 0;
|
|
|
+ border-bottom: 1rpx solid #f8f8f8;
|
|
|
+}
|
|
|
+.item-info {
|
|
|
+ flex: 1;
|
|
|
+ margin-left: 12rpx;
|
|
|
+}
|
|
|
+.item-name {
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+.item-name.purchased {
|
|
|
+ text-decoration: line-through;
|
|
|
+ color: #ccc;
|
|
|
+}
|
|
|
+.item-reason {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #999;
|
|
|
+ margin-top: 4rpx;
|
|
|
+}
|
|
|
+.item-priority {
|
|
|
+ font-size: 22rpx;
|
|
|
+ padding: 4rpx 12rpx;
|
|
|
+ border-radius: 6rpx;
|
|
|
+}
|
|
|
+.priority-high {
|
|
|
+ background: #ffebee;
|
|
|
+ color: #e53935;
|
|
|
+}
|
|
|
+.priority-medium {
|
|
|
+ background: #fff8e1;
|
|
|
+ color: #f9a825;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 pages.json 中注册页面**
|
|
|
+
|
|
|
+在 `cfc-frontend/pages.json` 中找到合适的分包或主包,添加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "pages/shopping-list/index",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "购物清单"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-frontend && npx vue-cli-service lint --no-fix 2>/dev/null || true` (无明显错误即可)
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js
|
|
|
+git add cfc-frontend/pages/shopping-list/index.vue
|
|
|
+git add cfc-frontend/pages.json
|
|
|
+git commit -m "feat(p1): Wave6.1 — 小程序购物清单页面"
|
|
|
+```
|
|
|
+
|
|
|
+### Task 6.2: 小程序 — 消息+随访页面扩展
|
|
|
+
|
|
|
+- [ ] **Step 1: 扩展消息页面,显示 sender_role 标识**
|
|
|
+
|
|
|
+在 `cfc-frontend/pages/profile/messages.vue` 或类似消息列表中,增加角色标识:
|
|
|
+
|
|
|
+```vue
|
|
|
+<view class="message-item" v-for="msg in messages" :key="msg.id">
|
|
|
+ <view class="msg-header">
|
|
|
+ <text class="sender-role-tag" :class="'role-' + (msg.senderRole || 'parent')">
|
|
|
+ {{ formatRole(msg.senderRole) }}
|
|
|
+ </text>
|
|
|
+ <text class="msg-time">{{ msg.createdAt }}</text>
|
|
|
+ </view>
|
|
|
+ <text class="msg-content">{{ msg.content }}</text>
|
|
|
+ <text class="read-status" v-if="msg.readAt">已读</text>
|
|
|
+ <text class="read-status unread" v-else>未读</text>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+script 中添加:
|
|
|
+```javascript
|
|
|
+formatRole(role) {
|
|
|
+ var map = { parent: '家长', teacher: '规划师', nutritionist: '营养师' }
|
|
|
+ return map[role] || '系统'
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/profile/messages.vue
|
|
|
+git commit -m "feat(p1): Wave6.2 — 消息页面扩展 sender_role 展示"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Verification
|
|
|
+
|
|
|
+### 编译验证
|
|
|
+
|
|
|
+```bash
|
|
|
+# 后端编译
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+
|
|
|
+# 前端语法检查
|
|
|
+cd cfc-frontend && npx vue-cli-service lint --no-fix 2>/dev/null || true
|
|
|
+```
|
|
|
+
|
|
|
+### 功能验证
|
|
|
+
|
|
|
+| 检查项 | 预期 |
|
|
|
+|--------|------|
|
|
|
+| PDF上传后,食材数据被正确解析存储到 report_food_suitability | report_food_suitability 表有数据 |
|
|
|
+| 同食材第二次不覆盖营养数据 | foods 表 energy_kj 不变 |
|
|
|
+| 通过 API 查询食材的 15+ 微量营养素 | food_nutrients 返回正确数据 |
|
|
|
+| RDI 计算接口返回各年龄段推荐值 | rdi_standards seed 数据存在 |
|
|
|
+| 从报告生成购物清单,score>=50 食材纳入 | shopping_list_items 有数据 |
|
|
|
+| 购物清单勾选已购后进度更新 | purchased 状态变更为 true |
|
|
|
+| 营养师发送消息给家庭 | sender_role=nutritionist |
|
|
|
+| 家长阅读后 read_at 更新 | teacher_messages.read_at 不为空 |
|
|
|
+| 创建随访 → 家长反馈 → 复查完成 | follow_up 状态正确流转 |
|
|
|
+| 小程序购物清单页面正常渲染 | 页面显示清单列表 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 边界场景
|
|
|
+
|
|
|
+| 场景 | 处理 |
|
|
|
+|------|------|
|
|
|
+| PDF 无食材章节 | `foodSuitability` 为空列表,跳过 |
|
|
|
+| PDF 食材名匹配不到 foods 表 | `food_id=null`,web端人工匹配 |
|
|
|
+| 同一食材同一报告多次出现 | 合并最后一条 |
|
|
|
+| 用户无任何菌群报告 | 仅 user_food_preference 生成空清单 |
|
|
|
+| 缺乏分析生成时已有购物清单 | 追加到 active 清单 |
|
|
|
+| 随访长期未读 | `read_at=null`,due 提醒标出 |
|