|
|
@@ -1,24 +1,60 @@
|
|
|
package com.etotem.cfc.service;
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.dto.IngredientRecommendation;
|
|
|
import com.etotem.cfc.entity.DietRecommendation;
|
|
|
import com.etotem.cfc.entity.FamilyMember;
|
|
|
import com.etotem.cfc.entity.MealConfig;
|
|
|
import com.etotem.cfc.mapper.DietRecommendationMapper;
|
|
|
import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
import com.etotem.cfc.mapper.MealConfigMapper;
|
|
|
+import com.etotem.cfc.util.ParamUtils;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
import javax.annotation.Resource;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
import java.time.LocalDate;
|
|
|
-import java.util.*;
|
|
|
-import com.etotem.cfc.util.ParamUtils;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
|
|
|
+/**
|
|
|
+ * 今日食谱服务。
|
|
|
+ *
|
|
|
+ * <p>本轮增量(迁移466 / K1~K6):</p>
|
|
|
+ * <ul>
|
|
|
+ * <li>K1 数据隔离:{@code view_key} 不透明列参与 {@code /today} 查询键与 {@code /generate} 幂等键。
|
|
|
+ * 不传 {@code view_key} 时完全保持原查询(老客户端 / 存量 NULL 行兼容),后端不解析该串语义。</li>
|
|
|
+ * <li>K2 删除:{@link #updateRecommendation} 不再把 {@code status} 置为 {@code adjusted},
|
|
|
+ * 保持原值以留在 {@code /today} 白名单内;并对 {@code menu_json} 做合法性校验。</li>
|
|
|
+ * <li>K3 共餐人口径:优先读前端 {@code participant_member_ids}(字符串 / 数组双形态),
|
|
|
+ * {@code meal_config} 仅作兜底;落库为排序去重的紧凑 JSON。</li>
|
|
|
+ * <li>K4 菜数硬保证:唯一可信来源是 {@link #normalizeMenu},仅 lunch/dinner 生效,
|
|
|
+ * {@code D(N)=4+max(0,N-3)} 道菜 + 恒 1 道汤,靠 {@code dish.type=dish|soup} 区分,不靠菜名猜。</li>
|
|
|
+ * <li>K5 重新生成:走 {@link #generateRecommendation} 全链路,覆盖同视角记录,不再 insert 空菜单。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
@Slf4j
|
|
|
@Service
|
|
|
public class DietRecommendationService {
|
|
|
|
|
|
+ /** 共享 ObjectMapper:无状态,可安全复用,避免每个请求反复 new。 */
|
|
|
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
|
+
|
|
|
+ /** /today 状态白名单:保持原样,不因删除而扩大(不含 adjusted / skipped)。 */
|
|
|
+ private static final List<String> TODAY_STATUS_WHITELIST =
|
|
|
+ Collections.unmodifiableList(Arrays.asList("pending", "accepted", "completed"));
|
|
|
+
|
|
|
@Resource
|
|
|
private DietRecommendationMapper dietRecommendationMapper;
|
|
|
|
|
|
@@ -34,16 +70,39 @@ public class DietRecommendationService {
|
|
|
@Resource
|
|
|
private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
|
+ /**
|
|
|
+ * 兼容老客户端 / 其它存量调用方:不传 {@code view_key},走原查询。
|
|
|
+ *
|
|
|
+ * @param familyId 家庭 ID(拦截器注入)
|
|
|
+ * @param dateStr yyyy-MM-dd
|
|
|
+ * @return 今日食谱视图(无记录时返回空态 Map)
|
|
|
+ */
|
|
|
public Map<String, Object> getTodayRecommendation(Long familyId, String dateStr) {
|
|
|
+ return getTodayRecommendation(familyId, dateStr, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 今日食谱查询(K1 视角隔离)。
|
|
|
+ *
|
|
|
+ * @param familyId 家庭 ID
|
|
|
+ * @param dateStr yyyy-MM-dd
|
|
|
+ * @param viewKey 可选视角键;仅当非空时才追加 {@code .eq(viewKey)} + 按 version/id 倒序取最新
|
|
|
+ * @return 今日食谱视图;查不到返回空态,不回退展示其它视角(AC-04)
|
|
|
+ */
|
|
|
+ public Map<String, Object> getTodayRecommendation(Long familyId, String dateStr, String viewKey) {
|
|
|
LocalDate date = LocalDate.parse(dateStr);
|
|
|
-
|
|
|
+
|
|
|
LambdaQueryWrapper<DietRecommendation> wrapper = new LambdaQueryWrapper<>();
|
|
|
wrapper.eq(DietRecommendation::getFamilyId, familyId)
|
|
|
.eq(DietRecommendation::getRecommendationDate, date)
|
|
|
- .in(DietRecommendation::getStatus, Arrays.asList("pending", "accepted", "completed"));
|
|
|
-
|
|
|
+ .in(DietRecommendation::getStatus, TODAY_STATUS_WHITELIST);
|
|
|
+ if (hasText(viewKey)) {
|
|
|
+ wrapper.eq(DietRecommendation::getViewKey, viewKey)
|
|
|
+ .orderByDesc(DietRecommendation::getVersion, DietRecommendation::getId);
|
|
|
+ }
|
|
|
+
|
|
|
List<DietRecommendation> records = dietRecommendationMapper.selectList(wrapper);
|
|
|
-
|
|
|
+
|
|
|
Map<String, Object> result = new HashMap<>();
|
|
|
if (!records.isEmpty()) {
|
|
|
DietRecommendation record = records.get(0);
|
|
|
@@ -55,76 +114,110 @@ public class DietRecommendationService {
|
|
|
result.put("status", record.getStatus());
|
|
|
result.put("version", record.getVersion());
|
|
|
result.put("participantMemberIds", record.getParticipantMemberIds());
|
|
|
+ result.put("viewKey", record.getViewKey());
|
|
|
} else {
|
|
|
result.put("id", null);
|
|
|
result.put("date", dateStr);
|
|
|
result.put("message", "暂无推荐,请生成");
|
|
|
}
|
|
|
-
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
- public Map<String, Object> generateRecommendation(Long familyId, Map<String, String> request) {
|
|
|
- String dateStr = request.get("date");
|
|
|
- String mealType = request.get("meal_type");
|
|
|
+ /**
|
|
|
+ * 生成 / 重新生成食谱(AI 正常 → AI 失败降级 → 菜数归一化,三条路径统一收口于此)。
|
|
|
+ *
|
|
|
+ * @param familyId 家庭 ID
|
|
|
+ * @param request {@code date / meal_type / selected_foods / participant_member_ids / view_key}
|
|
|
+ * @return {@code id / menu / nutritionSummary / message / participantCount / dishCount}
|
|
|
+ */
|
|
|
+ public Map<String, Object> generateRecommendation(Long familyId, Map<String, Object> request) {
|
|
|
+ String dateStr = asString(request.get("date"));
|
|
|
+ if (!hasText(dateStr)) {
|
|
|
+ dateStr = LocalDate.now().toString();
|
|
|
+ }
|
|
|
LocalDate date = LocalDate.parse(dateStr);
|
|
|
+ String mealType = asString(request.get("meal_type"));
|
|
|
+ String viewKey = asString(request.get("view_key"));
|
|
|
+ boolean hasViewKey = hasText(viewKey);
|
|
|
|
|
|
- // 1. 解析用户所选食材(前端传 selected_foods: "[{\"food_id\":1,\"name\":\"西红柿\"}]")
|
|
|
- String selectedFoodsJson = request.get("selected_foods");
|
|
|
- if (selectedFoodsJson == null || selectedFoodsJson.trim().isEmpty()) {
|
|
|
+ // 1. 解析用户所选食材(selected_foods 兼容非 String 形态,asString 兜底)
|
|
|
+ String selectedFoodsJson = asString(request.get("selected_foods"));
|
|
|
+ if (!hasText(selectedFoodsJson)) {
|
|
|
selectedFoodsJson = "[]";
|
|
|
}
|
|
|
|
|
|
- // 2. 查询共餐参与者
|
|
|
- List<Long> participantIds = queryParticipantIds(familyId, date);
|
|
|
+ // 2. 共餐人:优先前端载荷,meal_config 仅兜底(K3 / D6①)
|
|
|
+ List<Long> participantIds = resolveParticipantIds(request, familyId, date);
|
|
|
+ int participantCount = participantIds.isEmpty() ? 1 : participantIds.size();
|
|
|
+ String participantsJson = participantIds.isEmpty() ? "[]" : safeWrite(participantIds);
|
|
|
+ String participantIdsJson = safeWrite(participantIds);
|
|
|
|
|
|
- // 3. 调用 AI 生成菜单(参与者数量至少 1)
|
|
|
- String participantsJson = "[]";
|
|
|
- if (!participantIds.isEmpty()) {
|
|
|
- try {
|
|
|
- participantsJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(participantIds);
|
|
|
- } catch (Exception ignore) {}
|
|
|
- }
|
|
|
- String menuJson = aiGateway.generateMenu(selectedFoodsJson, participantsJson, dateStr);
|
|
|
+ // 3. 菜数规则(K4):D(N) 仅作为 AI 的软引导;硬保证在 normalizeMenu
|
|
|
+ int dishCount = DishCountRule.dishCount(participantCount);
|
|
|
+ String menuJson = aiGateway.generateMenu(selectedFoodsJson, participantsJson, dateStr, dishCount);
|
|
|
|
|
|
- // 4. AI 失败时降级:用推荐食材拼一个最小菜单,避免空壳
|
|
|
- if (menuJson == null || menuJson.trim().isEmpty() || menuJson.trim().equals("{}")) {
|
|
|
- menuJson = buildFallbackMenu(familyId, date, participantIds.size());
|
|
|
+ // 4. AI 失败 / 熔断 / 超时 → 规则降级(同菜数规则造 D(N)+1 道)
|
|
|
+ List<String> ingredientNames = availableIngredientNames(familyId, date);
|
|
|
+ if (menuJson == null || menuJson.trim().isEmpty() || "{}".equals(menuJson.trim())) {
|
|
|
+ menuJson = buildFallbackMenu(familyId, date, participantCount, ingredientNames);
|
|
|
}
|
|
|
|
|
|
- // 5. 归一化 LangGraph 菜单结构 → 前端期望结构 + 聚合营养
|
|
|
- Map<String, Object> normalized = normalizeMenu(menuJson, participantIds.size());
|
|
|
+ // 5. 归一化(唯一硬保证点)+ 聚合营养
|
|
|
+ Map<String, Object> normalized = normalizeMenu(menuJson, participantCount, ingredientNames);
|
|
|
+ // CR-3 / AC-24·26·27:LangGraph 在 LLM 无 JSON / 异常时返回 HTTP 200 的 {"meals": []},
|
|
|
+ // 非法 JSON 也会被 normalizeMenu 的异常兜底吞掉,二者都产出空菜单;
|
|
|
+ // 步骤 4 只判 null / 空串 / "{}" 会漏掉这两种形态。
|
|
|
+ // 归一化后再校验一次结构可用性,不足则复用规则降级(绝不重新调用 aiGateway,禁止整份重试 AI)。
|
|
|
+ // 于是 AI 正常 / 熔断降级 / 空·非法输出 三条路径最终落库均为 D(N) 菜 + 1 汤;
|
|
|
+ // regenerate 复用本方法(L283),同样受保护。
|
|
|
+ if (!hasUsableLunchDinner(normalized)) {
|
|
|
+ normalized = normalizeMenu(
|
|
|
+ buildFallbackMenu(familyId, date, participantCount, ingredientNames),
|
|
|
+ participantCount, ingredientNames);
|
|
|
+ }
|
|
|
String normalizedMenuJson = safeWrite(normalized);
|
|
|
Map<String, Object> nutritionSummary = aggregateNutrition(normalized);
|
|
|
|
|
|
- // 6. 幂等保存:同日同 mealType 已存在则更新,否则新建
|
|
|
+ // 6. 幂等保存:family_id + date + meal_type + view_key(view_key 未传时不追加,等价旧行为)
|
|
|
+ Date dateValue = java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant());
|
|
|
LambdaQueryWrapper<DietRecommendation> existWrapper = new LambdaQueryWrapper<>();
|
|
|
existWrapper.eq(DietRecommendation::getFamilyId, familyId)
|
|
|
- .eq(DietRecommendation::getRecommendationDate, java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()))
|
|
|
+ .eq(DietRecommendation::getRecommendationDate, dateValue)
|
|
|
.eq(DietRecommendation::getMealType, mealType != null ? mealType : "all");
|
|
|
+ if (hasViewKey) {
|
|
|
+ existWrapper.eq(DietRecommendation::getViewKey, viewKey);
|
|
|
+ }
|
|
|
DietRecommendation existRecord = dietRecommendationMapper.selectOne(existWrapper);
|
|
|
if (existRecord != null) {
|
|
|
existRecord.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
|
|
|
existRecord.setNutritionSummary(safeWrite(nutritionSummary));
|
|
|
- existRecord.setParticipantMemberIds(participantIds.toString());
|
|
|
+ existRecord.setParticipantMemberIds(participantIdsJson);
|
|
|
+ // 只在传了 view_key 时写入,避免老客户端把已有视角键清成 NULL(AC-08 不串视角)
|
|
|
+ if (hasViewKey) {
|
|
|
+ existRecord.setViewKey(viewKey);
|
|
|
+ }
|
|
|
existRecord.setStatus("pending");
|
|
|
existRecord.setUpdatedAt(new Date());
|
|
|
dietRecommendationMapper.updateById(existRecord);
|
|
|
+
|
|
|
Map<String, Object> result = new HashMap<>();
|
|
|
result.put("id", existRecord.getId());
|
|
|
result.put("menu", existRecord.getMenuJson());
|
|
|
result.put("nutritionSummary", existRecord.getNutritionSummary());
|
|
|
+ result.put("participantCount", participantCount);
|
|
|
+ result.put("dishCount", dishCount);
|
|
|
result.put("message", "食谱推荐已更新");
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
DietRecommendation record = new DietRecommendation();
|
|
|
record.setFamilyId(familyId);
|
|
|
- record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));
|
|
|
+ record.setRecommendationDate(dateValue);
|
|
|
record.setMealType(mealType != null ? mealType : "all");
|
|
|
- record.setParticipantMemberIds(participantIds.toString());
|
|
|
+ record.setParticipantMemberIds(participantIdsJson);
|
|
|
record.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
|
|
|
record.setNutritionSummary(safeWrite(nutritionSummary));
|
|
|
+ record.setViewKey(hasViewKey ? viewKey : null);
|
|
|
record.setStatus("pending");
|
|
|
record.setVersion(1);
|
|
|
record.setCreatedAt(new Date());
|
|
|
@@ -135,66 +228,96 @@ public class DietRecommendationService {
|
|
|
result.put("id", record.getId());
|
|
|
result.put("menu", record.getMenuJson());
|
|
|
result.put("nutritionSummary", record.getNutritionSummary());
|
|
|
+ result.put("participantCount", participantCount);
|
|
|
+ result.put("dishCount", dishCount);
|
|
|
result.put("message", "食谱推荐已生成");
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 删除菜品复用 {@code POST /api/diet/recommendation/update}:整份 {@code menu_json} 覆盖。
|
|
|
+ *
|
|
|
+ * <p><b>R-02 规避</b>:绝不 {@code setStatus("adjusted")}(白名单不含该值会让删除后整页回空态),
|
|
|
+ * 保持原 {@code status} 不变;删除与补菜完全解耦,本路径<b>永不</b>调用 {@code normalizeMenu}。</p>
|
|
|
+ *
|
|
|
+ * @throws IllegalArgumentException {@code menu_json} 缺失 / 非法 JSON / 缺 meals 数组 →
|
|
|
+ * 由 {@code GlobalExceptionHandler} 映射为 Result.error(400, "menu_json格式非法")
|
|
|
+ */
|
|
|
public void updateRecommendation(Long familyId, Map<String, Object> request) {
|
|
|
Long id = ParamUtils.getLong(request.get("id"));
|
|
|
- String menuJson = request.get("menu_json").toString();
|
|
|
-
|
|
|
+ Object menuJsonObj = request.get("menu_json");
|
|
|
+ String menuJson = menuJsonObj == null ? null : menuJsonObj.toString();
|
|
|
+ validateMenuJson(menuJson);
|
|
|
+
|
|
|
LambdaQueryWrapper<DietRecommendation> wrapper = new LambdaQueryWrapper<>();
|
|
|
wrapper.eq(DietRecommendation::getId, id)
|
|
|
.eq(DietRecommendation::getFamilyId, familyId);
|
|
|
-
|
|
|
+
|
|
|
DietRecommendation record = dietRecommendationMapper.selectOne(wrapper);
|
|
|
if (record != null) {
|
|
|
record.setMenuJson(menuJson);
|
|
|
- record.setStatus("adjusted");
|
|
|
+ // status 保持原值(pending/accepted/completed),/today 白名单仍可查 → AC-18
|
|
|
record.setUpdatedAt(new Date());
|
|
|
dietRecommendationMapper.updateById(record);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 重新生成(K5 / R-03):按原记录的 view_key / 共餐人 / 日期走 generateRecommendation 全链路,
|
|
|
+ * 覆盖同视角记录,version = old + 1,status 回 pending;不再 insert 空菜单记录。
|
|
|
+ *
|
|
|
+ * @return {@code {id, version, message}} 字段与语义保持不变
|
|
|
+ */
|
|
|
public Map<String, Object> regenerateRecommendation(Long familyId, Map<String, Object> request) {
|
|
|
Long id = ParamUtils.getLong(request.get("id"));
|
|
|
-
|
|
|
+
|
|
|
LambdaQueryWrapper<DietRecommendation> wrapper = new LambdaQueryWrapper<>();
|
|
|
wrapper.eq(DietRecommendation::getId, id)
|
|
|
.eq(DietRecommendation::getFamilyId, familyId);
|
|
|
-
|
|
|
+
|
|
|
DietRecommendation record = dietRecommendationMapper.selectOne(wrapper);
|
|
|
- if (record != null) {
|
|
|
- // 复制当前记录,version+1
|
|
|
- DietRecommendation newRecord = new DietRecommendation();
|
|
|
- newRecord.setFamilyId(familyId);
|
|
|
- newRecord.setRecommendationDate(record.getRecommendationDate());
|
|
|
- newRecord.setMealType(record.getMealType());
|
|
|
- newRecord.setParticipantMemberIds(record.getParticipantMemberIds());
|
|
|
- newRecord.setMenuJson("{}"); // 重新生成
|
|
|
- newRecord.setStatus("pending");
|
|
|
- newRecord.setVersion(record.getVersion() + 1);
|
|
|
- newRecord.setCreatedAt(new Date());
|
|
|
- newRecord.setUpdatedAt(new Date());
|
|
|
- dietRecommendationMapper.insert(newRecord);
|
|
|
-
|
|
|
- Map<String, Object> result = new HashMap<>();
|
|
|
- result.put("id", newRecord.getId());
|
|
|
- result.put("version", newRecord.getVersion());
|
|
|
- result.put("message", "已重新生成食谱,版本号: " + newRecord.getVersion());
|
|
|
- return result;
|
|
|
+ if (record == null) {
|
|
|
+ return new HashMap<>();
|
|
|
}
|
|
|
-
|
|
|
- return new HashMap<>();
|
|
|
+
|
|
|
+ int oldVersion = record.getVersion() == null ? 1 : record.getVersion();
|
|
|
+ String dateStr = formatRecordDate(record.getRecommendationDate());
|
|
|
+
|
|
|
+ Map<String, Object> genRequest = new HashMap<>();
|
|
|
+ genRequest.put("date", dateStr);
|
|
|
+ genRequest.put("meal_type", record.getMealType() != null ? record.getMealType() : "all");
|
|
|
+ genRequest.put("selected_foods", "[]");
|
|
|
+ // 原记录共餐人(字符串或紧凑 JSON)→ resolveParticipantIds 复用 parseMemberIds 解析
|
|
|
+ genRequest.put("participant_member_ids", record.getParticipantMemberIds());
|
|
|
+ genRequest.put("view_key", record.getViewKey());
|
|
|
+
|
|
|
+ Map<String, Object> generated = generateRecommendation(familyId, genRequest);
|
|
|
+
|
|
|
+ // generateRecommendation 已按幂等键覆盖同 view_key 记录(menu_json 非空、status=pending);
|
|
|
+ // 此处仅覆写版本号,避免保留历史版本行 / 空菜单。
|
|
|
+ DietRecommendation fresh = dietRecommendationMapper.selectOne(wrapper);
|
|
|
+ if (fresh != null) {
|
|
|
+ fresh.setVersion(oldVersion + 1);
|
|
|
+ fresh.setStatus("pending");
|
|
|
+ fresh.setUpdatedAt(new Date());
|
|
|
+ dietRecommendationMapper.updateById(fresh);
|
|
|
+ }
|
|
|
+
|
|
|
+ Object generatedId = generated.get("id");
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ result.put("id", generatedId != null ? generatedId : id);
|
|
|
+ result.put("version", oldVersion + 1);
|
|
|
+ result.put("message", "已重新生成食谱,版本号: " + (oldVersion + 1));
|
|
|
+ return result;
|
|
|
}
|
|
|
|
|
|
public void completeRecommendation(Long familyId, Map<String, Object> request) {
|
|
|
Long id = ParamUtils.getLong(request.get("id"));
|
|
|
-
|
|
|
+
|
|
|
LambdaQueryWrapper<DietRecommendation> wrapper = new LambdaQueryWrapper<>();
|
|
|
wrapper.eq(DietRecommendation::getId, id)
|
|
|
.eq(DietRecommendation::getFamilyId, familyId);
|
|
|
-
|
|
|
+
|
|
|
DietRecommendation record = dietRecommendationMapper.selectOne(wrapper);
|
|
|
if (record != null) {
|
|
|
record.setStatus("completed");
|
|
|
@@ -204,11 +327,113 @@ public class DietRecommendationService {
|
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
|
- // 内部辅助
|
|
|
+ // 内部辅助:view_key / 参数归一
|
|
|
// ============================================================
|
|
|
|
|
|
+ /** 非空判定:null / 空串 / 全空白一律视为「未传」,走老客户端兼容路径。 */
|
|
|
+ private static boolean hasText(String value) {
|
|
|
+ return value != null && !value.trim().isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Map<String,Object> 下保持 date / meal_type / selected_foods 的原 String 语义(K3 / R-8)。 */
|
|
|
+ private static String asString(Object value) {
|
|
|
+ if (value == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (value instanceof String) {
|
|
|
+ return (String) value;
|
|
|
+ }
|
|
|
+ return String.valueOf(value);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
- * 查询共餐参与者
|
|
|
+ * participant_member_ids 双形态归一(K3):JSON 字符串 "[1,2]" 复用 parseMemberIds;
|
|
|
+ * 原生 List / 数组 / 单个数字 → Set<Long> 去重;其余形态返回空集合(回落兜底)。
|
|
|
+ */
|
|
|
+ private List<Long> asIds(Object value) {
|
|
|
+ Set<Long> ids = new HashSet<>();
|
|
|
+ if (value == null) {
|
|
|
+ return new ArrayList<>(ids);
|
|
|
+ }
|
|
|
+ if (value instanceof String) {
|
|
|
+ ids.addAll(parseMemberIds((String) value));
|
|
|
+ } else if (value instanceof Iterable) {
|
|
|
+ for (Object item : (Iterable<?>) value) {
|
|
|
+ Long parsed = toLong(item);
|
|
|
+ if (parsed != null) {
|
|
|
+ ids.add(parsed);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (value.getClass().isArray()) {
|
|
|
+ int len = java.lang.reflect.Array.getLength(value);
|
|
|
+ for (int i = 0; i < len; i++) {
|
|
|
+ Long parsed = toLong(java.lang.reflect.Array.get(value, i));
|
|
|
+ if (parsed != null) {
|
|
|
+ ids.add(parsed);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ Long parsed = toLong(value);
|
|
|
+ if (parsed != null) {
|
|
|
+ ids.add(parsed);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return new ArrayList<>(ids);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Long toLong(Object value) {
|
|
|
+ if (value == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (value instanceof Number) {
|
|
|
+ return ((Number) value).longValue();
|
|
|
+ }
|
|
|
+ return ParamUtils.getLong(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 共餐人 N 口径唯一入口:优先前端 participant_member_ids,为空才回落 meal_config 兜底;
|
|
|
+ * 结果排序去重,落库为紧凑 JSON(F-15 的 "[1, 2]" 带空格问题一并消除)。
|
|
|
+ */
|
|
|
+ private List<Long> resolveParticipantIds(Map<String, Object> request, Long familyId, LocalDate date) {
|
|
|
+ List<Long> ids = asIds(request.get("participant_member_ids"));
|
|
|
+ if (ids.isEmpty()) {
|
|
|
+ ids = queryParticipantIds(familyId, date);
|
|
|
+ }
|
|
|
+ Collections.sort(ids);
|
|
|
+ return ids;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String formatRecordDate(Date date) {
|
|
|
+ if (date == null) {
|
|
|
+ return LocalDate.now().toString();
|
|
|
+ }
|
|
|
+ return new SimpleDateFormat("yyyy-MM-dd").format(date);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** menu_json 合法性:必须是含 meals 数组的 JSON 对象,防止删除路径写坏数据(R-9)。 */
|
|
|
+ private static void validateMenuJson(String menuJson) {
|
|
|
+ if (!hasText(menuJson)) {
|
|
|
+ throw new IllegalArgumentException("menu_json格式非法");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ JsonNode root = OBJECT_MAPPER.readTree(menuJson);
|
|
|
+ if (root == null || !root.isObject() || !root.has("meals") || !root.get("meals").isArray()) {
|
|
|
+ throw new IllegalArgumentException("menu_json格式非法");
|
|
|
+ }
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ throw e;
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new IllegalArgumentException("menu_json格式非法");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 内部辅助:共餐人兜底 / 可用食材
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询共餐参与者(兜底,逻辑不动)
|
|
|
* 优先级:当天临时配置(config_date=当天) > 工作日/周末默认配置(config_date=NULL) > 全部家庭成员
|
|
|
*/
|
|
|
private List<Long> queryParticipantIds(Long familyId, LocalDate date) {
|
|
|
@@ -217,7 +442,6 @@ public class DietRecommendationService {
|
|
|
String dateType = weekend ? "weekend" : "weekday";
|
|
|
Set<Long> ids = new HashSet<>();
|
|
|
|
|
|
- // 优先查当天临时配置(config_date = 当天)
|
|
|
for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
|
|
|
LambdaQueryWrapper<MealConfig> w = new LambdaQueryWrapper<>();
|
|
|
w.eq(MealConfig::getFamilyId, familyId)
|
|
|
@@ -229,7 +453,6 @@ public class DietRecommendationService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 当天无临时配置时,降级到工作日/周末默认配置
|
|
|
if (ids.isEmpty()) {
|
|
|
for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
|
|
|
LambdaQueryWrapper<MealConfig> w = new LambdaQueryWrapper<>();
|
|
|
@@ -244,67 +467,171 @@ public class DietRecommendationService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 仍无配置时,降级为全部家庭成员
|
|
|
if (ids.isEmpty()) {
|
|
|
LambdaQueryWrapper<FamilyMember> w = new LambdaQueryWrapper<>();
|
|
|
w.eq(FamilyMember::getFamilyId, familyId);
|
|
|
List<FamilyMember> members = familyMemberMapper.selectList(w);
|
|
|
- for (FamilyMember m : members) ids.add(m.getId());
|
|
|
+ if (members != null) {
|
|
|
+ for (FamilyMember m : members) {
|
|
|
+ ids.add(m.getId());
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
return new ArrayList<>(ids);
|
|
|
}
|
|
|
|
|
|
private Set<Long> parseMemberIds(String json) {
|
|
|
Set<Long> ids = new HashSet<>();
|
|
|
- if (json == null || json.isEmpty()) return ids;
|
|
|
+ if (json == null || json.isEmpty()) {
|
|
|
+ return ids;
|
|
|
+ }
|
|
|
try {
|
|
|
- com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
|
|
|
+ JsonNode nodes = OBJECT_MAPPER.readTree(json);
|
|
|
if (nodes.isArray()) {
|
|
|
- for (com.fasterxml.jackson.databind.JsonNode node : nodes) ids.add(node.asLong());
|
|
|
+ for (JsonNode node : nodes) {
|
|
|
+ ids.add(node.asLong());
|
|
|
+ }
|
|
|
}
|
|
|
- } catch (Exception ignore) {}
|
|
|
+ } catch (Exception ignore) {
|
|
|
+ // 非法 JSON 视为空集合,由 resolveParticipantIds 回落兜底
|
|
|
+ }
|
|
|
return ids;
|
|
|
}
|
|
|
|
|
|
- /** 归一化 LangGraph 菜单 → 前端期望结构(dish.calories / ingredients[].amount / method) */
|
|
|
+ /** 读取北京营养服务的可用食材名,造补齐占位菜用;服务不可用时返回空列表(用通用占位名)。 */
|
|
|
+ private List<String> availableIngredientNames(Long familyId, LocalDate date) {
|
|
|
+ List<String> names = new ArrayList<>();
|
|
|
+ try {
|
|
|
+ List<IngredientRecommendation> ingredients =
|
|
|
+ beijingNutritionService.generateIngredientList(familyId, date);
|
|
|
+ if (ingredients != null) {
|
|
|
+ for (IngredientRecommendation ing : ingredients) {
|
|
|
+ if (ing != null && hasText(ing.getName()) && !names.contains(ing.getName())) {
|
|
|
+ names.add(ing.getName());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("读取可用食材失败,补齐改用通用占位菜: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ return names;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 内部辅助:菜数规则 / 归一化 / 降级菜单
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 归一化结果的可用性闸门(AC-24/26/27):至少存在 lunch 与 dinner 两餐,且各自带至少 1 道菜。
|
|
|
+ *
|
|
|
+ * <p>AI 返回 {@code {"meals": []}}、{@code {"meals":"x"}}、无 meals 字段、非法 JSON,
|
|
|
+ * 或只给了早餐时,{@code normalizeMenu} 会产出空 / 残缺菜单,这类结果不允许落库。
|
|
|
+ * 菜数是否足量由 {@code normalizeMenu} 的补齐 / 截断保证,此处只判断结构是否可用,
|
|
|
+ * 避免把「合法但菜数偏少」的 AI 输出误判为失败(那种情况应保留 AI 结果并由补齐逻辑处理)。</p>
|
|
|
+ *
|
|
|
+ * @param normalized normalizeMenu 的输出
|
|
|
+ * @return true 表示结构可用可直接落库;false 表示需走 buildFallbackMenu 规则降级
|
|
|
+ */
|
|
|
+ private static boolean hasUsableLunchDinner(Map<String, Object> normalized) {
|
|
|
+ if (normalized == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ Object mealsObj = normalized.get("meals");
|
|
|
+ if (!(mealsObj instanceof List)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ boolean hasLunch = false;
|
|
|
+ boolean hasDinner = false;
|
|
|
+ for (Object item : (List<?>) mealsObj) {
|
|
|
+ if (!(item instanceof Map)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Map<?, ?> meal = (Map<?, ?>) item;
|
|
|
+ Object dishesObj = meal.get("dishes");
|
|
|
+ if (!(dishesObj instanceof List) || ((List<?>) dishesObj).isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Object type = meal.get("type");
|
|
|
+ if ("lunch".equals(type)) {
|
|
|
+ hasLunch = true;
|
|
|
+ } else if ("dinner".equals(type)) {
|
|
|
+ hasDinner = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return hasLunch && hasDinner;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 兼容旧签名(类图约定):补齐时使用通用占位菜名。
|
|
|
+ *
|
|
|
+ * @param menuJson AI 或降级菜单 JSON
|
|
|
+ * @param participantCount 共餐人数 N(<=0 兜底 1)
|
|
|
+ * @return 归一化后的菜单 Map(meals[].dishes[] 每道带 type)
|
|
|
+ */
|
|
|
private Map<String, Object> normalizeMenu(String menuJson, int participantCount) {
|
|
|
+ return normalizeMenu(menuJson, participantCount, Collections.<String>emptyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 菜数硬保证唯一可信来源(K4 / AC-20~26)。
|
|
|
+ * 仅 lunch/dinner:按 dish.type 拆 mains(≠soup)/soups(=soup) → mains 不足补齐、超出截断到 D;
|
|
|
+ * soups 为 0 补 1 道、超过 1 截断到 1;输出 mains(D) + soups(1),菜在前汤在后。
|
|
|
+ * breakfast 原样透传(不改数量),仅补 type 便于前端识别。
|
|
|
+ */
|
|
|
+ private Map<String, Object> normalizeMenu(String menuJson, int participantCount, List<String> ingredientNames) {
|
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
|
List<Map<String, Object>> meals = new ArrayList<>();
|
|
|
result.put("meals", meals);
|
|
|
- if (menuJson == null || menuJson.trim().isEmpty()) return result;
|
|
|
+ if (menuJson == null || menuJson.trim().isEmpty()) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ int n = DishCountRule.normalizeN(participantCount);
|
|
|
+ List<String> names = ingredientNames == null ? Collections.<String>emptyList() : ingredientNames;
|
|
|
try {
|
|
|
- com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
|
|
|
- com.fasterxml.jackson.databind.JsonNode root = om.readTree(menuJson);
|
|
|
- com.fasterxml.jackson.databind.JsonNode mealsNode = root.get("meals");
|
|
|
+ JsonNode root = OBJECT_MAPPER.readTree(menuJson);
|
|
|
+ JsonNode mealsNode = root.get("meals");
|
|
|
if (mealsNode != null && mealsNode.isArray()) {
|
|
|
- for (com.fasterxml.jackson.databind.JsonNode mealNode : mealsNode) {
|
|
|
+ for (JsonNode mealNode : mealsNode) {
|
|
|
+ String mealType = mealNode.has("type") ? mealNode.get("type").asText() : "lunch";
|
|
|
Map<String, Object> meal = new LinkedHashMap<>();
|
|
|
- meal.put("type", mealNode.has("type") ? mealNode.get("type").asText() : "lunch");
|
|
|
+ meal.put("type", mealType);
|
|
|
meal.put("name", mealNode.has("name") ? mealNode.get("name").asText() : "");
|
|
|
- meal.put("participants", participantCount > 0 ? participantCount : 1);
|
|
|
- List<Map<String, Object>> dishes = new ArrayList<>();
|
|
|
- com.fasterxml.jackson.databind.JsonNode dishesNode = mealNode.get("dishes");
|
|
|
+ meal.put("participants", n);
|
|
|
+
|
|
|
+ List<Map<String, Object>> mains = new ArrayList<>();
|
|
|
+ List<Map<String, Object>> soups = new ArrayList<>();
|
|
|
+ JsonNode dishesNode = mealNode.get("dishes");
|
|
|
if (dishesNode != null && dishesNode.isArray()) {
|
|
|
- for (com.fasterxml.jackson.databind.JsonNode dishNode : dishesNode) {
|
|
|
- Map<String, Object> dish = new LinkedHashMap<>();
|
|
|
- dish.put("name", dishNode.has("name") ? dishNode.get("name").asText() : "");
|
|
|
- com.fasterxml.jackson.databind.JsonNode nutrition = dishNode.get("nutrition");
|
|
|
- dish.put("calories", nutrition != null && nutrition.has("calories") ? nutrition.get("calories").asInt() : 0);
|
|
|
- List<Map<String, Object>> ingredients = new ArrayList<>();
|
|
|
- com.fasterxml.jackson.databind.JsonNode ingNode = dishNode.get("ingredients");
|
|
|
- if (ingNode != null && ingNode.isArray()) {
|
|
|
- for (com.fasterxml.jackson.databind.JsonNode ing : ingNode) {
|
|
|
- Map<String, Object> item = new LinkedHashMap<>();
|
|
|
- item.put("name", ing.has("name") ? ing.get("name").asText() : "");
|
|
|
- item.put("amount", ing.has("grams") ? ing.get("grams").asInt() : 0);
|
|
|
- ingredients.add(item);
|
|
|
- }
|
|
|
+ for (JsonNode dishNode : dishesNode) {
|
|
|
+ Map<String, Object> dish = toDish(dishNode);
|
|
|
+ if ("soup".equals(dish.get("type"))) {
|
|
|
+ soups.add(dish);
|
|
|
+ } else {
|
|
|
+ mains.add(dish);
|
|
|
}
|
|
|
- dish.put("ingredients", ingredients);
|
|
|
- dish.put("method", dishNode.has("cooking_method") ? dishNode.get("cooking_method").asText() : "");
|
|
|
- dishes.add(dish);
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ List<Map<String, Object>> dishes = new ArrayList<>();
|
|
|
+ if (DishCountRule.appliesTo(mealType)) {
|
|
|
+ int expected = DishCountRule.dishCount(n);
|
|
|
+ while (mains.size() < expected) {
|
|
|
+ mains.add(placeholderDish(names, "dish", mains.size(), "营养配菜"));
|
|
|
+ }
|
|
|
+ if (mains.size() > expected) {
|
|
|
+ mains = new ArrayList<>(mains.subList(0, expected));
|
|
|
+ }
|
|
|
+ if (soups.isEmpty()) {
|
|
|
+ soups.add(placeholderDish(names, "soup", 0, "营养汤"));
|
|
|
+ }
|
|
|
+ if (soups.size() > DishCountRule.soupCount()) {
|
|
|
+ soups = new ArrayList<>(soups.subList(0, DishCountRule.soupCount()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 顺序约定:菜在前、汤在后(早餐亦保持原有相对顺序,仅分组)
|
|
|
+ dishes.addAll(mains);
|
|
|
+ dishes.addAll(soups);
|
|
|
meal.put("dishes", dishes);
|
|
|
meals.add(meal);
|
|
|
}
|
|
|
@@ -315,7 +642,73 @@ public class DietRecommendationService {
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
- /** 聚合营养汇总(目前聚合热量,其余字段置 0,供前端 4 宫格渲染) */
|
|
|
+ /** 单道菜归一:兼容 AI 的 cooking_method/grams/nutrition 与前端落库的 method/amount/calories。 */
|
|
|
+ private static Map<String, Object> toDish(JsonNode dishNode) {
|
|
|
+ Map<String, Object> dish = new LinkedHashMap<>();
|
|
|
+ dish.put("name", dishNode.has("name") ? dishNode.get("name").asText() : "");
|
|
|
+ dish.put("type", dishNode.has("type") ? dishNode.get("type").asText() : "dish");
|
|
|
+ JsonNode nutrition = dishNode.get("nutrition");
|
|
|
+ if (nutrition != null && nutrition.has("calories")) {
|
|
|
+ dish.put("calories", nutrition.get("calories").asInt());
|
|
|
+ } else if (dishNode.has("calories")) {
|
|
|
+ dish.put("calories", dishNode.get("calories").asInt());
|
|
|
+ } else {
|
|
|
+ dish.put("calories", 0);
|
|
|
+ }
|
|
|
+ List<Map<String, Object>> ingredients = new ArrayList<>();
|
|
|
+ JsonNode ingNode = dishNode.get("ingredients");
|
|
|
+ if (ingNode != null && ingNode.isArray()) {
|
|
|
+ for (JsonNode ing : ingNode) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("name", ing.has("name") ? ing.get("name").asText() : "");
|
|
|
+ if (ing.has("grams")) {
|
|
|
+ item.put("amount", ing.get("grams").asInt());
|
|
|
+ } else if (ing.has("amount")) {
|
|
|
+ item.put("amount", ing.get("amount").asInt());
|
|
|
+ } else {
|
|
|
+ item.put("amount", 0);
|
|
|
+ }
|
|
|
+ ingredients.add(item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ dish.put("ingredients", ingredients);
|
|
|
+ if (dishNode.has("cooking_method")) {
|
|
|
+ dish.put("method", dishNode.get("cooking_method").asText());
|
|
|
+ } else if (dishNode.has("method")) {
|
|
|
+ dish.put("method", dishNode.get("method").asText());
|
|
|
+ } else {
|
|
|
+ dish.put("method", "");
|
|
|
+ }
|
|
|
+ return dish;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 占位菜/汤:结构与 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<>();
|
|
|
+ String label;
|
|
|
+ if ("soup".equals(type)) {
|
|
|
+ label = names.isEmpty() ? "营养汤" : names.get(0) + "汤";
|
|
|
+ } else if (names.isEmpty()) {
|
|
|
+ label = defaultName;
|
|
|
+ } else {
|
|
|
+ label = names.get(seq % names.size()) + "·" + defaultName;
|
|
|
+ }
|
|
|
+ dish.put("name", label);
|
|
|
+ dish.put("type", type);
|
|
|
+ dish.put("calories", 0);
|
|
|
+ List<Map<String, Object>> ingredients = new ArrayList<>();
|
|
|
+ if (!names.isEmpty()) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("name", names.get(seq % names.size()));
|
|
|
+ item.put("amount", 100);
|
|
|
+ ingredients.add(item);
|
|
|
+ }
|
|
|
+ dish.put("ingredients", ingredients);
|
|
|
+ dish.put("method", "建议以清淡为主");
|
|
|
+ return dish;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 聚合营养汇总(仅回归:汤也计入热量;protein/carbs/fat 恒 0 不在本轮范围)。 */
|
|
|
private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
|
|
|
Map<String, Object> summary = new LinkedHashMap<>();
|
|
|
int calories = 0;
|
|
|
@@ -325,10 +718,14 @@ public class DietRecommendationService {
|
|
|
for (Map<String, Object> meal : meals) {
|
|
|
@SuppressWarnings("unchecked")
|
|
|
List<Map<String, Object>> dishes = (List<Map<String, Object>>) meal.get("dishes");
|
|
|
- if (dishes == null) continue;
|
|
|
+ if (dishes == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
for (Map<String, Object> dish : dishes) {
|
|
|
Object cal = dish.get("calories");
|
|
|
- if (cal instanceof Number) calories += ((Number) cal).intValue();
|
|
|
+ if (cal instanceof Number) {
|
|
|
+ calories += ((Number) cal).intValue();
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -339,10 +736,18 @@ public class DietRecommendationService {
|
|
|
return summary;
|
|
|
}
|
|
|
|
|
|
- /** AI 不可用时的规则降级:用推荐食材拼出三餐占位菜单 */
|
|
|
+ /** 兼容旧签名(类图约定):自行读取可用食材。 */
|
|
|
private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount) {
|
|
|
- List<com.etotem.cfc.dto.IngredientRecommendation> ingredients =
|
|
|
- beijingNutritionService.generateIngredientList(familyId, date);
|
|
|
+ return buildFallbackMenu(familyId, date, participantCount, availableIngredientNames(familyId, date));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * AI 不可用时的规则降级(K4 / AC-24):lunch/dinner 造 D(N) 道菜 + 1 道汤(带 type),
|
|
|
+ * 早餐维持现状 1 道「营养餐」。输出仍经 normalizeMenu 归一化,保证三条路径同规则。
|
|
|
+ */
|
|
|
+ private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount, List<String> ingredientNames) {
|
|
|
+ int n = DishCountRule.normalizeN(participantCount);
|
|
|
+ List<String> names = ingredientNames == null ? Collections.<String>emptyList() : ingredientNames;
|
|
|
String[] mealTypes = {"breakfast", "lunch", "dinner"};
|
|
|
String[] mealNames = {"早餐", "午餐", "晚餐"};
|
|
|
Map<String, Object> root = new LinkedHashMap<>();
|
|
|
@@ -351,21 +756,17 @@ public class DietRecommendationService {
|
|
|
Map<String, Object> meal = new LinkedHashMap<>();
|
|
|
meal.put("type", mealTypes[i]);
|
|
|
meal.put("name", mealNames[i]);
|
|
|
- meal.put("participants", participantCount > 0 ? participantCount : 1);
|
|
|
+ meal.put("participants", n);
|
|
|
List<Map<String, Object>> dishes = new ArrayList<>();
|
|
|
- Map<String, Object> dish = new LinkedHashMap<>();
|
|
|
- dish.put("name", "营养餐");
|
|
|
- dish.put("calories", 0);
|
|
|
- List<Map<String, Object>> dishIngredients = new ArrayList<>();
|
|
|
- for (int j = i * 3; j < Math.min(i * 3 + 3, ingredients.size()); j++) {
|
|
|
- Map<String, Object> item = new LinkedHashMap<>();
|
|
|
- item.put("name", ingredients.get(j).getName());
|
|
|
- item.put("amount", 100);
|
|
|
- dishIngredients.add(item);
|
|
|
+ if (DishCountRule.appliesTo(mealTypes[i])) {
|
|
|
+ int expected = DishCountRule.dishCount(n);
|
|
|
+ for (int k = 0; k < expected; k++) {
|
|
|
+ dishes.add(placeholderDish(names, "dish", k, "营养配菜"));
|
|
|
+ }
|
|
|
+ dishes.add(placeholderDish(names, "soup", 0, "营养汤"));
|
|
|
+ } else {
|
|
|
+ dishes.add(placeholderDish(names, "dish", 0, "营养餐"));
|
|
|
}
|
|
|
- dish.put("ingredients", dishIngredients);
|
|
|
- dish.put("method", "建议以清淡为主");
|
|
|
- dishes.add(dish);
|
|
|
meal.put("dishes", dishes);
|
|
|
meals.add(meal);
|
|
|
}
|
|
|
@@ -373,11 +774,40 @@ public class DietRecommendationService {
|
|
|
return safeWrite(root);
|
|
|
}
|
|
|
|
|
|
- private String safeWrite(Object obj) {
|
|
|
+ private static String safeWrite(Object obj) {
|
|
|
try {
|
|
|
- return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(obj);
|
|
|
+ return OBJECT_MAPPER.writeValueAsString(obj);
|
|
|
} catch (Exception e) {
|
|
|
return "{}";
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 菜数规则唯一常量源(CR-C / D3·D4)。
|
|
|
+ * 仅 lunch/dinner 适用;N<=0 兜底 1;D(N)=4+max(0,N-3);汤恒 1 且不计入菜数。
|
|
|
+ */
|
|
|
+ private static final class DishCountRule {
|
|
|
+ private DishCountRule() {
|
|
|
+ }
|
|
|
+
|
|
|
+ static int normalizeN(int n) {
|
|
|
+ return n <= 0 ? 1 : n;
|
|
|
+ }
|
|
|
+
|
|
|
+ static int dishCount(int n) {
|
|
|
+ return 4 + Math.max(0, normalizeN(n) - 3);
|
|
|
+ }
|
|
|
+
|
|
|
+ static int soupCount() {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ static int total(int n) {
|
|
|
+ return dishCount(n) + soupCount();
|
|
|
+ }
|
|
|
+
|
|
|
+ static boolean appliesTo(String mealType) {
|
|
|
+ return "lunch".equals(mealType) || "dinner".equals(mealType);
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|