feat: T10 回归测试 Bug 修复与功能完善
修复 BUG-001 至 BUG-009 及 T10-1 至 T10-6 相关问题: - 打卡积分显示与累加逻辑优化 - 食谱计算器 Tab 选中样式修复 - 食物百科列表图片与简介展示修复 - 食物详情页数据加载修复 - AI营养师差异化回复优化 - 健康知识/营养知识名称统一 - 饮食指南/科普文章详情页内容展示修复 - 帖子营养统计数据展示修复 - 社区帖子类型中文命名统一 - 帖子详情标签中文显示修复 - 食谱营养AI填充功能完善 - 食谱收藏/点赞功能修复 新增: - ToolNutritionFillService 营养填充服务 - T10 回归测试用例 (Playwright) - 知识文章数据 SQL 脚本 涉及模块: - crmeb-common: VO/Request/Response 优化 - crmeb-service: 业务逻辑完善 - crmeb-front: API 接口扩展 - msh_single_uniapp: 前端页面修复 - tests/e2e: 回归测试用例
This commit is contained in:
@@ -163,7 +163,9 @@ public class SystemConfigServiceImpl extends ServiceImpl<SystemConfigDao, System
|
||||
boolean result;
|
||||
SystemConfig systemConfig;
|
||||
if (CollUtil.isEmpty(systemConfigs)) {
|
||||
systemConfig = new SystemConfig().setName(name).setValue(value);
|
||||
systemConfig = new SystemConfig();
|
||||
systemConfig.setName(name);
|
||||
systemConfig.setValue(value);
|
||||
result = save(systemConfig);
|
||||
} else {
|
||||
systemConfig = systemConfigs.get(0);
|
||||
|
||||
@@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.zbkj.common.config.KieAIConfig;
|
||||
import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.article.Article;
|
||||
import com.zbkj.common.model.tool.V2CommunityPost;
|
||||
@@ -16,7 +17,10 @@ import com.zbkj.service.dao.ArticleDao;
|
||||
import com.zbkj.service.dao.UserSignDao;
|
||||
import com.zbkj.service.dao.tool.V2CommunityPostDao;
|
||||
import com.zbkj.service.dao.tool.V2UserPointsDao;
|
||||
import com.zbkj.service.service.SystemConfigService;
|
||||
import com.zbkj.service.service.tool.ToolCheckinService;
|
||||
import com.zbkj.service.service.tool.ToolGrokService;
|
||||
import com.zbkj.service.service.tool.ToolSora2Service;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -51,6 +55,18 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
|
||||
@Autowired
|
||||
private FrontTokenComponent frontTokenComponent;
|
||||
|
||||
@Autowired
|
||||
private ToolGrokService toolGrokService;
|
||||
|
||||
@Autowired
|
||||
private ToolSora2Service toolSora2Service;
|
||||
|
||||
@Autowired
|
||||
private SystemConfigService systemConfigService;
|
||||
|
||||
@Autowired
|
||||
private KieAIConfig kieAIConfig;
|
||||
|
||||
/**
|
||||
* 提交打卡记录
|
||||
*
|
||||
@@ -129,18 +145,15 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
|
||||
userSign.setVoiceUrl((String) data.get("voiceUrl"));
|
||||
}
|
||||
|
||||
// AI开关标记(存储到实体字段)
|
||||
// AI开关标记(存储到实体字段),兼容 true/"true"/1/"1"
|
||||
if (data.containsKey("enableAIVideo") && ObjectUtil.isNotNull(data.get("enableAIVideo"))) {
|
||||
boolean enableAIVideo = Boolean.parseBoolean(data.get("enableAIVideo").toString());
|
||||
Object v = data.get("enableAIVideo");
|
||||
boolean enableAIVideo = Boolean.TRUE.equals(v)
|
||||
|| "true".equalsIgnoreCase(v.toString())
|
||||
|| "1".equals(v.toString());
|
||||
userSign.setEnableAiVideo(enableAIVideo ? 1 : 0);
|
||||
// #region agent log
|
||||
try { java.nio.file.Files.write(java.nio.file.Paths.get("/Users/apple/scott2026/msh-system/.cursor/debug.log"), (new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(new java.util.HashMap<String,Object>(){{put("id","log_"+System.currentTimeMillis()+"_b");put("timestamp",System.currentTimeMillis());put("location","ToolCheckinServiceImpl.java:135");put("message","enableAIVideo parsed");put("data",new java.util.HashMap<String,Object>(){{put("rawValue",data.get("enableAIVideo"));put("parsedBoolean",enableAIVideo);put("setToUserSign",enableAIVideo ? 1 : 0);}});put("hypothesisId","E");}}) + "\n").getBytes(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); } catch(Exception e) {}
|
||||
// #endregion
|
||||
} else {
|
||||
userSign.setEnableAiVideo(0);
|
||||
// #region agent log
|
||||
try { java.nio.file.Files.write(java.nio.file.Paths.get("/Users/apple/scott2026/msh-system/.cursor/debug.log"), (new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(new java.util.HashMap<String,Object>(){{put("id","log_"+System.currentTimeMillis()+"_c");put("timestamp",System.currentTimeMillis());put("location","ToolCheckinServiceImpl.java:138");put("message","enableAIVideo NOT present or null");put("data",new java.util.HashMap<String,Object>(){{put("containsKey",data.containsKey("enableAIVideo"));put("isNotNull",data.get("enableAIVideo")!=null);}});put("hypothesisId","E");}}) + "\n").getBytes(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); } catch(Exception e) {}
|
||||
// #endregion
|
||||
}
|
||||
if (data.containsKey("enableAIAnalysis") && ObjectUtil.isNotNull(data.get("enableAIAnalysis"))) {
|
||||
boolean enableAIAnalysis = Boolean.parseBoolean(data.get("enableAIAnalysis").toString());
|
||||
@@ -176,24 +189,68 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
|
||||
|
||||
userSignDao.insert(userSign);
|
||||
|
||||
// 如果有taskId,尝试关联已存在的article记录(由Sora2服务创建的)
|
||||
// enableAiVideo==1 且无 taskId 时,由后端主动调用 createImageToVideoTask 并回写 taskId
|
||||
if (userSign.getEnableAiVideo() != null && userSign.getEnableAiVideo() == 1
|
||||
&& StrUtil.isNotBlank(userSign.getPhotosJson())
|
||||
&& StrUtil.isBlank(userSign.getTaskId())) {
|
||||
try {
|
||||
String field103 = null;
|
||||
try {
|
||||
field103 = systemConfigService.getValueByKey("field103");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
boolean useGrok = StrUtil.isNotBlank(field103) && "grok".equalsIgnoreCase(field103.trim());
|
||||
|
||||
String firstImageUrl = parseFirstImage(userSign.getPhotosJson());
|
||||
if (StrUtil.isBlank(firstImageUrl)) {
|
||||
log.warn("submit 内触发视频任务跳过:photosJson 解析不到有效图片");
|
||||
} else {
|
||||
String[] imageUrls = new String[]{firstImageUrl};
|
||||
String mealTypeLabel = getMealTypeLabel(userSign.getMealType());
|
||||
String videoPrompt = StrUtil.isNotBlank(userSign.getNotes())
|
||||
? userSign.getNotes()
|
||||
: "健康" + mealTypeLabel + "打卡";
|
||||
String videoTitle = videoPrompt;
|
||||
|
||||
String taskId = useGrok
|
||||
? toolGrokService.createImageToVideoTask(
|
||||
null, videoTitle, videoPrompt, imageUrls,
|
||||
"9:16", null, true,
|
||||
kieAIConfig.getApiCallbackUrl(), null,
|
||||
String.valueOf(userId), null)
|
||||
: toolSora2Service.createImageToVideoTask(
|
||||
null, videoTitle, videoPrompt, imageUrls,
|
||||
"9:16", null, true,
|
||||
kieAIConfig.getApiCallbackUrl(), null,
|
||||
String.valueOf(userId), null);
|
||||
|
||||
if (StrUtil.isNotBlank(taskId)) {
|
||||
userSign.setTaskId(taskId);
|
||||
userSignDao.updateById(userSign);
|
||||
log.info("submit 内触发视频任务成功,taskId: {}, checkInRecordId: {}", taskId, userSign.getId());
|
||||
} else {
|
||||
log.warn("submit 内触发视频任务返回空 taskId,checkInRecordId: {}", userSign.getId());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("submit 内触发视频任务失败,不影响打卡流程: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有 taskId,尝试关联已存在的 article 记录(由前端或上文创建的视频任务)
|
||||
if (StrUtil.isNotBlank(userSign.getTaskId())) {
|
||||
try {
|
||||
LambdaQueryWrapper<Article> articleQuery = new LambdaQueryWrapper<>();
|
||||
articleQuery.eq(Article::getTaskId, userSign.getTaskId());
|
||||
articleQuery.isNull(Article::getCheckInRecordId); // 只更新未关联的
|
||||
|
||||
articleQuery.isNull(Article::getCheckInRecordId);
|
||||
Article existingArticle = articleDao.selectOne(articleQuery);
|
||||
|
||||
if (existingArticle != null) {
|
||||
existingArticle.setCheckInRecordId(userSign.getId());
|
||||
articleDao.updateById(existingArticle);
|
||||
log.info("关联article记录成功,articleId: {}, checkInRecordId: {}",
|
||||
existingArticle.getId(), userSign.getId());
|
||||
log.info("关联article记录成功,articleId: {}, checkInRecordId: {}", existingArticle.getId(), userSign.getId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("关联article记录失败", e);
|
||||
// 不影响打卡流程
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,11 +344,12 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("id", userSign.getId());
|
||||
result.put("message", "打卡成功");
|
||||
result.put("taskId", userSign.getTaskId() != null ? userSign.getTaskId() : "");
|
||||
result.put("enableAIVideo", userSign.getEnableAiVideo() != null && userSign.getEnableAiVideo() == 1);
|
||||
result.put("enableAIAnalysis", userSign.getEnableAiAnalysis() != null && userSign.getEnableAiAnalysis() == 1);
|
||||
|
||||
|
||||
// TODO: 如果启用AI分析,可以在这里触发异步AI识别任务
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -571,6 +629,23 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
|
||||
* @param mealType 餐次类型
|
||||
* @return 中文标签
|
||||
*/
|
||||
/**
|
||||
* 从 photosJson 解析第一张图片 URL。若为 JSON 数组取第一个元素,否则整体当作单张 URL。
|
||||
*/
|
||||
private String parseFirstImage(String photosJson) {
|
||||
if (StrUtil.isBlank(photosJson)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
if (photosJson.trim().startsWith("[")) {
|
||||
com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSON.parseArray(photosJson);
|
||||
return (arr != null && !arr.isEmpty()) ? arr.getString(0) : "";
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return photosJson.trim();
|
||||
}
|
||||
|
||||
private String getMealTypeLabel(String mealType) {
|
||||
if (StrUtil.isBlank(mealType)) {
|
||||
return "饮食";
|
||||
|
||||
@@ -22,11 +22,14 @@ import com.zbkj.service.dao.tool.V2CommunityPostDao;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.UserSignService;
|
||||
import com.zbkj.service.service.tool.ToolCommunityService;
|
||||
import com.zbkj.service.service.tool.ToolNutritionFillService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -65,6 +68,9 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
|
||||
@Resource
|
||||
private UserSignService userSignService;
|
||||
|
||||
@Resource
|
||||
private ToolNutritionFillService toolNutritionFillService;
|
||||
|
||||
/**
|
||||
* 获取社区内容列表
|
||||
* @param pageParamRequest 分页参数
|
||||
@@ -80,8 +86,7 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
|
||||
lqw.eq(V2CommunityPost::getStatus, "published");
|
||||
|
||||
if ("latest".equals(tab)) {
|
||||
lqw.isNotNull(V2CommunityPost::getCheckInRecordId); // 只显示打卡帖子
|
||||
lqw.orderByDesc(V2CommunityPost::getPostId); // 按ID降序
|
||||
lqw.orderByDesc(V2CommunityPost::getCreatedAt);
|
||||
} else if ("hot".equals(tab)) {
|
||||
lqw.orderByDesc(V2CommunityPost::getHotScore);
|
||||
} else if ("recommend".equals(tab)) {
|
||||
@@ -585,4 +590,33 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
|
||||
result.put("message", "分享成功");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据帖子内容用 AI 填充营养数据并更新帖子
|
||||
* @param postId 帖子ID
|
||||
* @return 填充后的营养数据
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> fillNutrition(Long postId) {
|
||||
Integer userId = frontTokenComponent.getUserId();
|
||||
if (userId == null) throw new CrmebException("请登录");
|
||||
|
||||
V2CommunityPost post = v2CommunityPostDao.selectById(postId);
|
||||
if (post == null) throw new CrmebException("帖子不存在");
|
||||
if (userId.longValue() != post.getUserId()) throw new CrmebException("只能为自己的帖子填充营养数据");
|
||||
|
||||
StringBuilder text = new StringBuilder();
|
||||
if (StrUtil.isNotBlank(post.getTitle())) text.append(post.getTitle()).append(" ");
|
||||
if (StrUtil.isNotBlank(post.getContent())) text.append(post.getContent());
|
||||
if (text.length() == 0) throw new CrmebException("帖子标题和内容为空,无法估算营养");
|
||||
|
||||
Map<String, Object> nutrition = toolNutritionFillService.fillFromText(text.toString());
|
||||
if (nutrition.isEmpty()) throw new CrmebException("AI 未能估算出营养数据");
|
||||
|
||||
post.setNutritionDataJson(JSON.toJSONString(nutrition));
|
||||
post.setUpdatedAt(new Date());
|
||||
v2CommunityPostDao.updateById(post);
|
||||
return nutrition;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,22 +13,55 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
|
||||
/**
|
||||
* KieAI Grok 视频生成服务实现类
|
||||
* 对接 https://kie.ai/grok-imagine
|
||||
* URL: /text-to-video 和 /image-to-video
|
||||
* Model: grok-imagine-text-to-video 和 grok-imagine-image-to-video
|
||||
* aspect_ratio 仅支持: 2:3, 3:2, 1:1, 16:9, 9:16,未传或非法时默认 9:16
|
||||
*/
|
||||
@Service
|
||||
public class ToolGrokServiceImpl implements ToolGrokService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ToolGrokServiceImpl.class);
|
||||
|
||||
private static final String MODEL_TEXT_TO_VIDEO = "grok-imagine-text-to-video";
|
||||
private static final String MODEL_IMAGE_TO_VIDEO = "grok-imagine-image-to-video";
|
||||
private static final String MODEL_TEXT_TO_VIDEO = "grok-imagine/text-to-video";
|
||||
private static final String MODEL_IMAGE_TO_VIDEO = "grok-imagine/image-to-video";
|
||||
|
||||
/** Grok 支持的 aspect_ratio,未传或非法时默认使用 9:16 */
|
||||
private static final String DEFAULT_ASPECT_RATIO = "9:16";
|
||||
private static final Set<String> ALLOWED_ASPECT_RATIOS = new HashSet<>(Arrays.asList("2:3", "3:2", "1:1", "16:9", "9:16"));
|
||||
|
||||
/**
|
||||
* 规范化为 Grok 支持的 aspect_ratio(2:3, 3:2, 1:1, 16:9, 9:16),无参或非法时返回 9:16
|
||||
*/
|
||||
private static String normalizeAspectRatio(String aspectRatio) {
|
||||
if (StrUtil.isBlank(aspectRatio)) {
|
||||
return DEFAULT_ASPECT_RATIO;
|
||||
}
|
||||
String trimmed = aspectRatio.trim();
|
||||
if (ALLOWED_ASPECT_RATIOS.contains(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
switch (trimmed.toLowerCase()) {
|
||||
case "portrait":
|
||||
return "9:16";
|
||||
case "landscape":
|
||||
return "16:9";
|
||||
case "square":
|
||||
return "1:1";
|
||||
default:
|
||||
return DEFAULT_ASPECT_RATIO;
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private ArticleDao articleDao;
|
||||
@@ -40,15 +73,11 @@ public class ToolGrokServiceImpl implements ToolGrokService {
|
||||
public String createTextToVideoTask(String tenantId, String title, String prompt, String aspectRatio,
|
||||
Integer nFrames, Boolean removeWatermark, String callBackUrl, String apiKey, String uid, String nickname) {
|
||||
try {
|
||||
String baseUrl = kieAIConfig.getGrokBaseUrl();
|
||||
if (baseUrl == null || baseUrl.isEmpty()) {
|
||||
baseUrl = "https://kie.ai/grok-imagine";
|
||||
}
|
||||
String url = baseUrl + "/text-to-video";
|
||||
String url = kieAIConfig.getBaseUrl() + "/api/v1/jobs/createTask";
|
||||
|
||||
Map<String, Object> input = new HashMap<>();
|
||||
input.put("prompt", prompt);
|
||||
if (aspectRatio != null) input.put("aspect_ratio", aspectRatio);
|
||||
input.put("aspect_ratio", normalizeAspectRatio(aspectRatio));
|
||||
if (removeWatermark != null) input.put("remove_watermark", removeWatermark);
|
||||
input.put("n_frames", "15");
|
||||
|
||||
@@ -68,6 +97,18 @@ public class ToolGrokServiceImpl implements ToolGrokService {
|
||||
String response = HttpRequestUtils.postWithHeaders(url, jsonPayload, null, headers);
|
||||
logger.info("Grok创建文生视频任务响应: {}", response);
|
||||
|
||||
if (response != null) {
|
||||
String trimmed = response.trim();
|
||||
if (trimmed.startsWith("<")) {
|
||||
logger.error("Grok文生视频接口返回非JSON(可能404),URL: {}", url);
|
||||
throw new IllegalArgumentException("Grok文生视频接口返回异常(非JSON,可能404): 请检查 KieAI 配置中的 Grok baseUrl 或 API 路径是否正确");
|
||||
}
|
||||
}
|
||||
if (response == null || response.isEmpty()) {
|
||||
logger.error("Grok文生视频接口返回为空, URL: {}", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject result = JSON.parseObject(response);
|
||||
if (result != null && result.containsKey("data")) {
|
||||
JSONObject data = result.getJSONObject("data");
|
||||
@@ -99,6 +140,8 @@ public class ToolGrokServiceImpl implements ToolGrokService {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Grok创建文生视频任务失败: {}", e.getMessage(), e);
|
||||
return null;
|
||||
@@ -109,16 +152,12 @@ public class ToolGrokServiceImpl implements ToolGrokService {
|
||||
public String createImageToVideoTask(String tenantId, String title, String prompt, String[] imageUrls,
|
||||
String aspectRatio, Integer nFrames, Boolean removeWatermark, String callBackUrl, String apiKey, String uid, String nickname) {
|
||||
try {
|
||||
String baseUrl = kieAIConfig.getGrokBaseUrl();
|
||||
if (baseUrl == null || baseUrl.isEmpty()) {
|
||||
baseUrl = "https://kie.ai/grok-imagine";
|
||||
}
|
||||
String url = baseUrl + "/image-to-video";
|
||||
String url = kieAIConfig.getBaseUrl() + "/api/v1/jobs/createTask";
|
||||
|
||||
Map<String, Object> input = new HashMap<>();
|
||||
input.put("prompt", prompt);
|
||||
input.put("image_urls", imageUrls);
|
||||
if (aspectRatio != null) input.put("aspect_ratio", aspectRatio);
|
||||
input.put("aspect_ratio", normalizeAspectRatio(aspectRatio));
|
||||
if (removeWatermark != null) input.put("remove_watermark", removeWatermark);
|
||||
input.put("n_frames", "15");
|
||||
|
||||
@@ -138,6 +177,19 @@ public class ToolGrokServiceImpl implements ToolGrokService {
|
||||
String response = HttpRequestUtils.postWithHeaders(url, jsonPayload, null, headers);
|
||||
logger.info("Grok创建图生视频任务响应: {}", response);
|
||||
|
||||
// 远程返回 404 等时可能是 HTML,直接 parseObject 会抛 JSONException
|
||||
if (response != null) {
|
||||
String trimmed = response.trim();
|
||||
if (trimmed.startsWith("<")) {
|
||||
logger.error("Grok图生视频接口返回非JSON(可能404),URL: {}, 响应前200字符: {}", url, trimmed.length() > 200 ? trimmed.substring(0, 200) + "..." : trimmed);
|
||||
throw new IllegalArgumentException("Grok图生视频接口返回异常(非JSON,可能404): 请检查 KieAI 配置中的 Grok baseUrl 或 API 路径是否正确");
|
||||
}
|
||||
}
|
||||
if (response == null || response.isEmpty()) {
|
||||
logger.error("Grok图生视频接口返回为空, URL: {}", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject result = JSON.parseObject(response);
|
||||
if (result != null && result.containsKey("data")) {
|
||||
JSONObject data = result.getJSONObject("data");
|
||||
@@ -169,6 +221,8 @@ public class ToolGrokServiceImpl implements ToolGrokService {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Grok创建图生视频任务失败: {}", e.getMessage(), e);
|
||||
return null;
|
||||
|
||||
@@ -149,4 +149,29 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
|
||||
}
|
||||
return "健康营养主题配图,标题:" + title + "。简洁现代风格,1:1 方图。";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
// 总数
|
||||
long total = v2KnowledgeDao.selectCount(null);
|
||||
stats.put("total", total);
|
||||
|
||||
Map<String, Long> byType = new HashMap<>();
|
||||
for (String type : new String[]{"guide", "article", "nutrients", "recipe"}) {
|
||||
LambdaQueryWrapper<V2Knowledge> lqw = new LambdaQueryWrapper<>();
|
||||
lqw.eq(V2Knowledge::getType, type);
|
||||
byType.put(type, v2KnowledgeDao.selectCount(lqw));
|
||||
}
|
||||
stats.put("byType", byType);
|
||||
|
||||
Map<String, Long> byStatus = new HashMap<>();
|
||||
for (String status : new String[]{"published", "draft", "deleted"}) {
|
||||
LambdaQueryWrapper<V2Knowledge> lqw = new LambdaQueryWrapper<>();
|
||||
lqw.eq(V2Knowledge::getStatus, status);
|
||||
byStatus.put(status, v2KnowledgeDao.selectCount(lqw));
|
||||
}
|
||||
stats.put("byStatus", byStatus);
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.zbkj.service.service.impl.tool;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.zbkj.common.request.kieai.KieAIGeminiChatRequest;
|
||||
import com.zbkj.service.service.tool.ToolKieAIService;
|
||||
import com.zbkj.service.service.tool.ToolNutritionFillService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 营养数据填充:用 Gemini 根据饮食描述估算热量、蛋白质、钾、磷
|
||||
* +----------------------------------------------------------------------
|
||||
* | Author:ScottPan
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ToolNutritionFillServiceImpl implements ToolNutritionFillService {
|
||||
|
||||
private static final String SYSTEM_PROMPT = "你是一个营养估算助手。用户会给出一段饮食描述(如一顿饭或几道菜),请仅输出一个 JSON 对象,不要其他文字或 markdown 标记。"
|
||||
+ " JSON 格式:{\"energyKcal\": 数字或null, \"proteinG\": 数字或null, \"potassiumMg\": 数字或null, \"phosphorusMg\": 数字或null}。"
|
||||
+ " 热量单位千卡,蛋白质克,钾和磷毫克。若无法估算某项则填 null。只输出这一行 JSON。";
|
||||
|
||||
@Resource
|
||||
private ToolKieAIService toolKieAIService;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> fillFromText(String text) {
|
||||
if (StrUtil.isBlank(text)) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
KieAIGeminiChatRequest request = new KieAIGeminiChatRequest();
|
||||
KieAIGeminiChatRequest.Message systemMsg = new KieAIGeminiChatRequest.Message();
|
||||
systemMsg.setRole("system");
|
||||
systemMsg.setContent(SYSTEM_PROMPT);
|
||||
KieAIGeminiChatRequest.Message userMsg = new KieAIGeminiChatRequest.Message();
|
||||
userMsg.setRole("user");
|
||||
userMsg.setContent("请根据以下饮食描述估算营养数据并只输出 JSON:\n\n" + text.trim());
|
||||
request.setMessages(java.util.Arrays.asList(systemMsg, userMsg));
|
||||
request.setStream(false);
|
||||
|
||||
try {
|
||||
Map<String, Object> geminiResult = toolKieAIService.geminiChat(request);
|
||||
String content = extractAssistantContent(geminiResult);
|
||||
if (StrUtil.isBlank(content)) {
|
||||
log.warn("AI nutrition fill: no content in response");
|
||||
return new HashMap<>();
|
||||
}
|
||||
content = content.trim();
|
||||
// 去掉可能的 markdown 代码块
|
||||
if (content.startsWith("```")) {
|
||||
int start = content.indexOf("\n");
|
||||
if (start > 0) content = content.substring(start + 1);
|
||||
int end = content.lastIndexOf("```");
|
||||
if (end > 0) content = content.substring(0, end).trim();
|
||||
}
|
||||
JSONObject obj = JSON.parseObject(content);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (obj.containsKey("energyKcal")) out.put("energyKcal", obj.get("energyKcal"));
|
||||
if (obj.containsKey("proteinG")) out.put("proteinG", obj.get("proteinG"));
|
||||
if (obj.containsKey("potassiumMg")) out.put("potassiumMg", obj.get("potassiumMg"));
|
||||
if (obj.containsKey("phosphorusMg")) out.put("phosphorusMg", obj.get("phosphorusMg"));
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
log.warn("AI nutrition fill failed for text length {}: {}", text.length(), e.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String extractAssistantContent(Map<String, Object> geminiResult) {
|
||||
Object choices = geminiResult.get("choices");
|
||||
if (!(choices instanceof List) || ((List<?>) choices).isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Object first = ((List<?>) choices).get(0);
|
||||
if (!(first instanceof Map)) return null;
|
||||
Object message = ((Map<String, Object>) first).get("message");
|
||||
if (!(message instanceof Map)) return null;
|
||||
Object content = ((Map<String, Object>) message).get("content");
|
||||
if (content instanceof String) return (String) content;
|
||||
if (content instanceof List) {
|
||||
for (Object part : (List<?>) content) {
|
||||
if (part instanceof Map) {
|
||||
Object type = ((Map<?, ?>) part).get("type");
|
||||
Object text = ((Map<?, ?>) part).get("text");
|
||||
if ("text".equals(type) && text != null) return text.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,24 @@ package com.zbkj.service.service.impl.tool;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.coze.openapi.client.chat.CreateChatResp;
|
||||
import com.coze.openapi.client.chat.RetrieveChatResp;
|
||||
import com.coze.openapi.client.chat.message.ListMessageResp;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.zbkj.common.config.CozeConfig;
|
||||
import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.tool.V2Recipe;
|
||||
import com.zbkj.common.request.PageParamRequest;
|
||||
import com.zbkj.common.request.coze.CozeChatRequest;
|
||||
import com.zbkj.common.request.coze.CozeListMessageRequest;
|
||||
import com.zbkj.common.request.coze.CozeRetrieveChatRequest;
|
||||
import com.zbkj.common.response.CozeBaseResponse;
|
||||
import com.zbkj.common.token.FrontTokenComponent;
|
||||
import com.zbkj.service.dao.tool.V2RecipeDao;
|
||||
import com.zbkj.service.service.tool.ToolCozeService;
|
||||
import com.zbkj.service.service.tool.ToolRecipeService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -16,7 +27,11 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -30,12 +45,22 @@ import java.util.Map;
|
||||
@Service
|
||||
public class ToolRecipeServiceImpl implements ToolRecipeService {
|
||||
|
||||
private static final String NUTRITION_PROMPT_PREFIX = "你是一个营养估算助手。根据以下食谱信息,仅输出一个 JSON 对象,不要其他文字或 markdown。"
|
||||
+ " JSON 格式:{\"energyKcal\": 数字或null, \"proteinG\": 数字或null, \"potassiumMg\": 数字或null, \"phosphorusMg\": 数字或null, \"sodiumMg\": 数字或null}。"
|
||||
+ " 热量单位千卡,蛋白质克,钾、磷、钠单位毫克。若无法估算某项则填 null。只输出这一行 JSON。\n\n食谱:\n";
|
||||
|
||||
@Resource
|
||||
private V2RecipeDao v2RecipeDao;
|
||||
|
||||
@Autowired
|
||||
private FrontTokenComponent frontTokenComponent;
|
||||
|
||||
@Autowired(required = false)
|
||||
private ToolCozeService toolCozeService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private CozeConfig cozeConfig;
|
||||
|
||||
/**
|
||||
* 获取食谱列表
|
||||
* @param pageParamRequest 分页参数
|
||||
@@ -99,4 +124,250 @@ public class ToolRecipeServiceImpl implements ToolRecipeService {
|
||||
v2RecipeDao.updateById(recipe);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> fillNutrition(Long recipeId) {
|
||||
if (toolCozeService == null || cozeConfig == null) {
|
||||
throw new CrmebException("Coze AI 未配置,无法填充营养数据");
|
||||
}
|
||||
V2Recipe recipe = v2RecipeDao.selectById(recipeId);
|
||||
if (recipe == null) {
|
||||
throw new CrmebException("食谱不存在");
|
||||
}
|
||||
StringBuilder text = new StringBuilder();
|
||||
if (StrUtil.isNotBlank(recipe.getName())) text.append("名称:").append(recipe.getName()).append("\n");
|
||||
if (StrUtil.isNotBlank(recipe.getDescription())) text.append("描述:").append(recipe.getDescription()).append("\n");
|
||||
if (StrUtil.isNotBlank(recipe.getIngredientsJson())) text.append("食材:").append(recipe.getIngredientsJson()).append("\n");
|
||||
if (text.length() == 0) {
|
||||
throw new CrmebException("食谱名称、描述和食材为空,无法估算营养");
|
||||
}
|
||||
String userMessage = NUTRITION_PROMPT_PREFIX + text.toString();
|
||||
CozeChatRequest chatReq = new CozeChatRequest();
|
||||
chatReq.setBotId(cozeConfig.getDefaultBotId());
|
||||
chatReq.setUserId(cozeConfig.getDefaultUserId());
|
||||
chatReq.setAdditionalMessages(Collections.singletonList(Collections.singletonMap("content", userMessage)));
|
||||
CozeBaseResponse<Object> chatResp = toolCozeService.chat(chatReq);
|
||||
if (chatResp == null || chatResp.getCode() == null || chatResp.getCode() != 200 || chatResp.getData() == null) {
|
||||
throw new CrmebException("Coze AI 调用失败:" + (chatResp != null ? chatResp.getMessage() : "无响应"));
|
||||
}
|
||||
Object data = chatResp.getData();
|
||||
if (!(data instanceof CreateChatResp)) {
|
||||
throw new CrmebException("Coze 返回格式异常");
|
||||
}
|
||||
CreateChatResp createResp = (CreateChatResp) data;
|
||||
String conversationId = getCreateChatRespConversationId(createResp);
|
||||
String chatId = getCreateChatRespId(createResp);
|
||||
if (StrUtil.isBlank(conversationId) || StrUtil.isBlank(chatId)) {
|
||||
throw new CrmebException("Coze 未返回会话信息");
|
||||
}
|
||||
for (int i = 0; i < 30; i++) {
|
||||
try {
|
||||
Thread.sleep(1500);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new CrmebException("等待 AI 响应被中断");
|
||||
}
|
||||
CozeRetrieveChatRequest retrieveReq = new CozeRetrieveChatRequest();
|
||||
retrieveReq.setConversationId(conversationId);
|
||||
retrieveReq.setChatId(chatId);
|
||||
CozeBaseResponse<Object> retrieveResp = toolCozeService.retrieveChat(retrieveReq);
|
||||
if (retrieveResp == null || retrieveResp.getCode() != 200 || retrieveResp.getData() == null) continue;
|
||||
Object retrieveData = retrieveResp.getData();
|
||||
if (!(retrieveData instanceof RetrieveChatResp)) continue;
|
||||
RetrieveChatResp retrieve = (RetrieveChatResp) retrieveData;
|
||||
String status = getRetrieveChatRespStatus(retrieve);
|
||||
if ("completed".equalsIgnoreCase(status)) {
|
||||
String content = getAssistantContentFromListMessages(conversationId, chatId);
|
||||
if (StrUtil.isNotBlank(content)) {
|
||||
Map<String, Object> nutrition = parseNutritionJson(content);
|
||||
if (!nutrition.isEmpty()) {
|
||||
applyNutritionToRecipe(recipe, nutrition);
|
||||
recipe.setUpdatedAt(new Date());
|
||||
v2RecipeDao.updateById(recipe);
|
||||
return nutrition;
|
||||
}
|
||||
}
|
||||
throw new CrmebException("AI 未能返回有效营养数据");
|
||||
}
|
||||
if ("failed".equalsIgnoreCase(status)) {
|
||||
throw new CrmebException("Coze AI 任务执行失败");
|
||||
}
|
||||
}
|
||||
throw new CrmebException("等待 Coze AI 响应超时");
|
||||
}
|
||||
|
||||
private String getAssistantContentFromListMessages(String conversationId, String chatId) {
|
||||
CozeListMessageRequest listReq = new CozeListMessageRequest();
|
||||
listReq.setConversationId(conversationId);
|
||||
listReq.setChatId(chatId);
|
||||
CozeBaseResponse<Object> listResp = toolCozeService.listMessages(listReq);
|
||||
if (listResp == null || listResp.getCode() != 200 || listResp.getData() == null) return null;
|
||||
Object listData = listResp.getData();
|
||||
if (!(listData instanceof ListMessageResp)) return null;
|
||||
ListMessageResp list = (ListMessageResp) listData;
|
||||
List<?> messages = getListMessageRespMessages(list);
|
||||
if (messages == null) return null;
|
||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||
Object msg = messages.get(i);
|
||||
if (msg == null) continue;
|
||||
String role = getMessageRole(msg);
|
||||
if (!"assistant".equalsIgnoreCase(role)) continue;
|
||||
String content = getMessageContent(msg);
|
||||
if (StrUtil.isNotBlank(content)) return content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<?> getListMessageRespMessages(ListMessageResp list) {
|
||||
try {
|
||||
try {
|
||||
return (List<?>) ListMessageResp.class.getMethod("getData").invoke(list);
|
||||
} catch (NoSuchMethodException e) {
|
||||
return (List<?>) ListMessageResp.class.getMethod("getMessages").invoke(list);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String getMessageRole(Object msg) {
|
||||
try {
|
||||
if (msg instanceof Map) return (String) ((Map<?, ?>) msg).get("role");
|
||||
return (String) msg.getClass().getMethod("getRole").invoke(msg);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String getMessageContent(Object msg) {
|
||||
try {
|
||||
if (msg instanceof Map) {
|
||||
Object content = ((Map<?, ?>) msg).get("content");
|
||||
if (content instanceof List) {
|
||||
for (Object part : (List<?>) content) {
|
||||
if (part instanceof Map) {
|
||||
Object type = ((Map<?, ?>) part).get("type");
|
||||
Object text = ((Map<?, ?>) part).get("text");
|
||||
if ("text".equals(type) && text != null) return text.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return content != null ? content.toString() : null;
|
||||
}
|
||||
Object content = msg.getClass().getMethod("getContent").invoke(msg);
|
||||
if (content instanceof List) {
|
||||
for (Object part : (List<?>) content) {
|
||||
if (part != null && part.getClass().getSimpleName().contains("Text")) {
|
||||
try {
|
||||
Object text = part.getClass().getMethod("getText").invoke(part);
|
||||
if (text != null) return text.toString();
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
return content != null ? content.toString() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseNutritionJson(String content) {
|
||||
content = content.trim();
|
||||
if (content.startsWith("```")) {
|
||||
int start = content.indexOf("\n");
|
||||
if (start > 0) content = content.substring(start + 1);
|
||||
int end = content.lastIndexOf("```");
|
||||
if (end > 0) content = content.substring(0, end).trim();
|
||||
}
|
||||
try {
|
||||
JSONObject obj = JSON.parseObject(content);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (obj.containsKey("energyKcal")) out.put("energyKcal", obj.get("energyKcal"));
|
||||
if (obj.containsKey("proteinG")) out.put("proteinG", obj.get("proteinG"));
|
||||
if (obj.containsKey("potassiumMg")) out.put("potassiumMg", obj.get("potassiumMg"));
|
||||
if (obj.containsKey("phosphorusMg")) out.put("phosphorusMg", obj.get("phosphorusMg"));
|
||||
if (obj.containsKey("sodiumMg")) out.put("sodiumMg", obj.get("sodiumMg"));
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
log.warn("Parse nutrition JSON failed: {}", e.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyNutritionToRecipe(V2Recipe recipe, Map<String, Object> nutrition) {
|
||||
if (nutrition.containsKey("energyKcal")) {
|
||||
Object v = nutrition.get("energyKcal");
|
||||
if (v != null) recipe.setTotalEnergy(toInteger(v));
|
||||
}
|
||||
if (nutrition.containsKey("proteinG")) {
|
||||
Object v = nutrition.get("proteinG");
|
||||
if (v != null) recipe.setTotalProtein(toBigDecimal(v));
|
||||
}
|
||||
if (nutrition.containsKey("potassiumMg")) {
|
||||
Object v = nutrition.get("potassiumMg");
|
||||
if (v != null) recipe.setTotalPotassium(toInteger(v));
|
||||
}
|
||||
if (nutrition.containsKey("phosphorusMg")) {
|
||||
Object v = nutrition.get("phosphorusMg");
|
||||
if (v != null) recipe.setTotalPhosphorus(toInteger(v));
|
||||
}
|
||||
if (nutrition.containsKey("sodiumMg")) {
|
||||
Object v = nutrition.get("sodiumMg");
|
||||
if (v != null) recipe.setTotalSodium(toInteger(v));
|
||||
}
|
||||
}
|
||||
|
||||
private static String getCreateChatRespId(CreateChatResp r) {
|
||||
try {
|
||||
try {
|
||||
return (String) CreateChatResp.class.getMethod("getID").invoke(r);
|
||||
} catch (NoSuchMethodException e) {
|
||||
return (String) CreateChatResp.class.getMethod("getId").invoke(r);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String getRetrieveChatRespStatus(RetrieveChatResp r) {
|
||||
try {
|
||||
return (String) RetrieveChatResp.class.getMethod("getStatus").invoke(r);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String getCreateChatRespConversationId(CreateChatResp r) {
|
||||
try {
|
||||
try {
|
||||
return (String) CreateChatResp.class.getMethod("getConversationID").invoke(r);
|
||||
} catch (NoSuchMethodException e) {
|
||||
return (String) CreateChatResp.class.getMethod("getConversationId").invoke(r);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer toInteger(Object v) {
|
||||
if (v instanceof Number) return ((Number) v).intValue();
|
||||
if (v instanceof String) {
|
||||
try { return Integer.parseInt((String) v); } catch (NumberFormatException e) { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static BigDecimal toBigDecimal(Object v) {
|
||||
if (v instanceof BigDecimal) return (BigDecimal) v;
|
||||
if (v instanceof Number) return BigDecimal.valueOf(((Number) v).doubleValue());
|
||||
if (v instanceof String) {
|
||||
try { return new BigDecimal((String) v); } catch (Exception e) { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -77,5 +77,12 @@ public interface ToolCommunityService {
|
||||
* @return 分享信息
|
||||
*/
|
||||
Map<String, Object> share(Long postId);
|
||||
|
||||
/**
|
||||
* 根据帖子内容用 AI 填充营养数据并更新帖子
|
||||
* @param postId 帖子ID
|
||||
* @return 填充后的营养数据(energyKcal, proteinG, potassiumMg, phosphorusMg)
|
||||
*/
|
||||
Map<String, Object> fillNutrition(Long postId);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,5 +43,12 @@ public interface ToolKnowledgeService {
|
||||
* @return 成功更新条数
|
||||
*/
|
||||
int fillMissingCoverImages(int limit);
|
||||
|
||||
/**
|
||||
* 统计 v2_knowledge 表:按 type、status 汇总条数,用于检查知识库数据
|
||||
*
|
||||
* @return 如 total, byType (guide/article/nutrients/recipe), byStatus (published/draft/deleted)
|
||||
*/
|
||||
Map<String, Object> getStats();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.zbkj.service.service.tool;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 营养数据填充服务:根据饮食描述文本,用大模型估算热量、蛋白质、钾、磷等
|
||||
* +----------------------------------------------------------------------
|
||||
* | Author:ScottPan
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
public interface ToolNutritionFillService {
|
||||
|
||||
/**
|
||||
* 根据一段饮食/菜品描述文本,调用 AI 估算营养数据并返回结构化结果
|
||||
*
|
||||
* @param text 饮食描述(如「今天中午吃了一碗米饭、一份青椒肉丝、一碗紫菜蛋花汤」)
|
||||
* @return 包含 energyKcal, proteinG, potassiumMg, phosphorusMg 的 Map,估算不出时为 null
|
||||
*/
|
||||
Map<String, Object> fillFromText(String text);
|
||||
}
|
||||
@@ -34,5 +34,12 @@ public interface ToolRecipeService {
|
||||
* @param isFavorite 是否收藏
|
||||
*/
|
||||
void toggleFavorite(Long recipeId, Boolean isFavorite);
|
||||
|
||||
/**
|
||||
* 使用 Coze AI 根据食谱食材分析并填充营养数据(能量、蛋白质、钾、磷、钠),更新食谱并返回
|
||||
* @param recipeId 食谱ID
|
||||
* @return 填充后的营养数据 Map(energyKcal, proteinG, potassiumMg, phosphorusMg, sodiumMg)
|
||||
*/
|
||||
java.util.Map<String, Object> fillNutrition(Long recipeId);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user