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

feat(diet): S-cfc-235 teacher.nutrition.plan 营养目标校验 + 宏量营养素聚合

- toDish 保留 AI 菜品 protein/carbs/fat(占位菜/汤贡献 0)
- aggregateNutrition 真实聚合宏量营养素(此前恒置 0)
- generateRecommendation 读取 nutrition_goals,负/超上限目标返回 400 拒绝
  (Controller 捕获 IllegalArgumentException → Result.badRequest)

NutritionPlanTeacherContractTest: 3 红 → 4 绿
zxyj-langgraph 3 часов назад
Родитель
Сommit
a9c9dd8b05

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecommendationController.java

@@ -46,7 +46,13 @@ public class DietRecommendationController {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
         }
-        return Result.success(dietRecommendationService.generateRecommendation(familyId, request));
+        // [teacher.nutrition.plan / SC-T-26] 营养目标非法(负/超上限)→ 400 拒绝;
+        // 单测直调 Controller 不经 GlobalExceptionHandler,故在此捕获(与全局 400 语义一致)。
+        try {
+            return Result.success(dietRecommendationService.generateRecommendation(familyId, request));
+        } catch (IllegalArgumentException e) {
+            return Result.badRequest(e.getMessage());
+        }
     }
 
     @PostMapping("/update")

+ 106 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java

@@ -55,6 +55,19 @@ public class DietRecommendationService {
     private static final List<String> TODAY_STATUS_WHITELIST =
             Collections.unmodifiableList(Arrays.asList("pending", "accepted", "completed"));
 
+    /**
+     * 营养目标上限(teacher.nutrition.plan / SC-T-26 精准营养方案制定)。
+     * 日口径:热量 kcal、宏量营养素 g;任一字段超出视为非法目标(超上限拦截)。
+     * 取值远高于正常人体日需(热量 ~1800-3000 kcal),仅作「不可能值」闸门而非医学建议。
+     */
+    private static final Map<String, Integer> NUTRITION_GOAL_MAX = Collections.unmodifiableMap(
+            new HashMap<String, Integer>() {{
+                put("calories", 20000);
+                put("protein", 1000);
+                put("carbs", 2000);
+                put("fat", 500);
+            }});
+
     @Resource
     private DietRecommendationMapper dietRecommendationMapper;
 
@@ -131,6 +144,9 @@ public class DietRecommendationService {
      * @return {@code id / menu / nutritionSummary / message / participantCount / dishCount}
      */
     public Map<String, Object> generateRecommendation(Long familyId, Map<String, Object> request) {
+        // [teacher.nutrition.plan / SC-T-26] 营养目标(热量/宏量营养素)先校验:非法(负/超上限)→ 拒绝
+        validateNutritionGoals(request == null ? null : request.get("nutrition_goals"));
+
         String dateStr = asString(request.get("date"));
         if (!hasText(dateStr)) {
             dateStr = LocalDate.now().toString();
@@ -335,6 +351,60 @@ public class DietRecommendationService {
         return value != null && !value.trim().isEmpty();
     }
 
+    /**
+     * 营养目标校验(teacher.nutrition.plan / SC-T-26):{@code nutrition_goals} 形如
+     * {@code {calories, protein, carbs, fat}},支持原生 Map 与 JSON 字符串双形态。
+     * 未传视为未设目标(放行);任一字段为负 / 超上限 / 非数字 → IllegalArgumentException
+     * (Controller 映射为 badRequest 400),而非静默忽略。
+     */
+    private static void validateNutritionGoals(Object goalsObj) {
+        if (goalsObj == null) {
+            return;
+        }
+        Map<?, ?> goals;
+        if (goalsObj instanceof Map) {
+            goals = (Map<?, ?>) goalsObj;
+        } else {
+            String json = String.valueOf(goalsObj);
+            if (!hasText(json) || "null".equals(json)) {
+                return;
+            }
+            try {
+                JsonNode node = OBJECT_MAPPER.readTree(json);
+                if (node == null || !node.isObject()) {
+                    throw new IllegalArgumentException("nutrition_goals 格式错误");
+                }
+                goals = OBJECT_MAPPER.convertValue(node, Map.class);
+            } catch (IllegalArgumentException e) {
+                throw e;
+            } catch (Exception e) {
+                throw new IllegalArgumentException("nutrition_goals 格式错误");
+            }
+        }
+        for (Map.Entry<String, Integer> limit : NUTRITION_GOAL_MAX.entrySet()) {
+            Object raw = goals.get(limit.getKey());
+            if (raw == null) {
+                continue;
+            }
+            long val;
+            if (raw instanceof Number) {
+                val = ((Number) raw).longValue();
+            } else {
+                try {
+                    val = Long.parseLong(String.valueOf(raw).trim());
+                } catch (NumberFormatException e) {
+                    throw new IllegalArgumentException("营养目标[" + limit.getKey() + "]必须是数字");
+                }
+            }
+            if (val < 0) {
+                throw new IllegalArgumentException("营养目标[" + limit.getKey() + "]不能为负数");
+            }
+            if (val > limit.getValue()) {
+                throw new IllegalArgumentException("营养目标[" + limit.getKey() + "]超上限(" + limit.getValue() + ")");
+            }
+        }
+    }
+
     /** Map<String,Object> 下保持 date / meal_type / selected_foods 的原 String 语义(K3 / R-8)。 */
     private static String asString(Object value) {
         if (value == null) {
@@ -655,6 +725,11 @@ public class DietRecommendationService {
         } else {
             dish.put("calories", 0);
         }
+        // [teacher.nutrition.plan / SC-T-26] 保留 AI 菜品的宏量营养素(缺 nutrition 的占位菜/汤贡献 0),
+        // 使 aggregateNutrition 能真实聚合、方案 nutritionSummary 可承载「热量/宏量营养素」目标。
+        putNutrient(dish, "protein", nutrition, dishNode);
+        putNutrient(dish, "carbs", nutrition, dishNode);
+        putNutrient(dish, "fat", nutrition, dishNode);
         List<Map<String, Object>> ingredients = new ArrayList<>();
         JsonNode ingNode = dishNode.get("ingredients");
         if (ingNode != null && ingNode.isArray()) {
@@ -682,6 +757,17 @@ public class DietRecommendationService {
         return dish;
     }
 
+    /** 从 nutrition(或 dishNode 顶层)读取单个宏量营养素字段;缺失时置 0。 */
+    private static void putNutrient(Map<String, Object> dish, String key, JsonNode nutrition, JsonNode dishNode) {
+        if (nutrition != null && nutrition.has(key)) {
+            dish.put(key, nutrition.get(key).asInt());
+        } else if (dishNode.has(key)) {
+            dish.put(key, dishNode.get(key).asInt());
+        } else {
+            dish.put(key, 0);
+        }
+    }
+
     /** 占位菜/汤:结构与 toDish 输出一致(归一化后结构),calories=0,带可识别的 type。 */
     private static Map<String, Object> placeholderDish(List<String> names, String type, int seq, String defaultName) {
         Map<String, Object> dish = new LinkedHashMap<>();
@@ -708,10 +794,17 @@ public class DietRecommendationService {
         return dish;
     }
 
-    /** 聚合营养汇总(仅回归:汤也计入热量;protein/carbs/fat 恒 0 不在本轮范围)。 */
+    /**
+     * 聚合营养汇总(仅回归:汤也计入热量)。
+     * [teacher.nutrition.plan / SC-T-26] protein/carbs/fat 由 {@link #toDish} 保留后在此真实聚合;
+     * 缺营养数据的占位菜/汤贡献 0。
+     */
     private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
         Map<String, Object> summary = new LinkedHashMap<>();
         int calories = 0;
+        int protein = 0;
+        int carbs = 0;
+        int fat = 0;
         @SuppressWarnings("unchecked")
         List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
         if (meals != null) {
@@ -722,20 +815,25 @@ public class DietRecommendationService {
                     continue;
                 }
                 for (Map<String, Object> dish : dishes) {
-                    Object cal = dish.get("calories");
-                    if (cal instanceof Number) {
-                        calories += ((Number) cal).intValue();
-                    }
+                    calories += asInt(dish.get("calories"));
+                    protein += asInt(dish.get("protein"));
+                    carbs += asInt(dish.get("carbs"));
+                    fat += asInt(dish.get("fat"));
                 }
             }
         }
         summary.put("calories", calories);
-        summary.put("protein", 0);
-        summary.put("carbs", 0);
-        summary.put("fat", 0);
+        summary.put("protein", protein);
+        summary.put("carbs", carbs);
+        summary.put("fat", fat);
         return summary;
     }
 
+    /** Number → int,非数字(含占位菜缺失字段)按 0 计。 */
+    private static int asInt(Object v) {
+        return v instanceof Number ? ((Number) v).intValue() : 0;
+    }
+
     /** 兼容旧签名(类图约定):自行读取可用食材。 */
     private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount) {
         return buildFallbackMenu(familyId, date, participantCount, availableIngredientNames(familyId, date));