|
@@ -55,6 +55,19 @@ public class DietRecommendationService {
|
|
|
private static final List<String> TODAY_STATUS_WHITELIST =
|
|
private static final List<String> TODAY_STATUS_WHITELIST =
|
|
|
Collections.unmodifiableList(Arrays.asList("pending", "accepted", "completed"));
|
|
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
|
|
@Resource
|
|
|
private DietRecommendationMapper dietRecommendationMapper;
|
|
private DietRecommendationMapper dietRecommendationMapper;
|
|
|
|
|
|
|
@@ -131,6 +144,9 @@ public class DietRecommendationService {
|
|
|
* @return {@code id / menu / nutritionSummary / message / participantCount / dishCount}
|
|
* @return {@code id / menu / nutritionSummary / message / participantCount / dishCount}
|
|
|
*/
|
|
*/
|
|
|
public Map<String, Object> generateRecommendation(Long familyId, Map<String, Object> request) {
|
|
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"));
|
|
String dateStr = asString(request.get("date"));
|
|
|
if (!hasText(dateStr)) {
|
|
if (!hasText(dateStr)) {
|
|
|
dateStr = LocalDate.now().toString();
|
|
dateStr = LocalDate.now().toString();
|
|
@@ -335,6 +351,60 @@ public class DietRecommendationService {
|
|
|
return value != null && !value.trim().isEmpty();
|
|
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)。 */
|
|
/** Map<String,Object> 下保持 date / meal_type / selected_foods 的原 String 语义(K3 / R-8)。 */
|
|
|
private static String asString(Object value) {
|
|
private static String asString(Object value) {
|
|
|
if (value == null) {
|
|
if (value == null) {
|
|
@@ -655,6 +725,11 @@ public class DietRecommendationService {
|
|
|
} else {
|
|
} else {
|
|
|
dish.put("calories", 0);
|
|
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<>();
|
|
List<Map<String, Object>> ingredients = new ArrayList<>();
|
|
|
JsonNode ingNode = dishNode.get("ingredients");
|
|
JsonNode ingNode = dishNode.get("ingredients");
|
|
|
if (ingNode != null && ingNode.isArray()) {
|
|
if (ingNode != null && ingNode.isArray()) {
|
|
@@ -682,6 +757,17 @@ public class DietRecommendationService {
|
|
|
return dish;
|
|
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。 */
|
|
/** 占位菜/汤:结构与 toDish 输出一致(归一化后结构),calories=0,带可识别的 type。 */
|
|
|
private static Map<String, Object> placeholderDish(List<String> names, String type, int seq, String defaultName) {
|
|
private static Map<String, Object> placeholderDish(List<String> names, String type, int seq, String defaultName) {
|
|
|
Map<String, Object> dish = new LinkedHashMap<>();
|
|
Map<String, Object> dish = new LinkedHashMap<>();
|
|
@@ -708,10 +794,17 @@ public class DietRecommendationService {
|
|
|
return dish;
|
|
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) {
|
|
private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
|
|
|
Map<String, Object> summary = new LinkedHashMap<>();
|
|
Map<String, Object> summary = new LinkedHashMap<>();
|
|
|
int calories = 0;
|
|
int calories = 0;
|
|
|
|
|
+ int protein = 0;
|
|
|
|
|
+ int carbs = 0;
|
|
|
|
|
+ int fat = 0;
|
|
|
@SuppressWarnings("unchecked")
|
|
@SuppressWarnings("unchecked")
|
|
|
List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
|
|
List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
|
|
|
if (meals != null) {
|
|
if (meals != null) {
|
|
@@ -722,20 +815,25 @@ public class DietRecommendationService {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
for (Map<String, Object> dish : dishes) {
|
|
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("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;
|
|
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) {
|
|
private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount) {
|
|
|
return buildFallbackMenu(familyId, date, participantCount, availableIngredientNames(familyId, date));
|
|
return buildFallbackMenu(familyId, date, participantCount, availableIngredientNames(familyId, date));
|