feat: 使用Coze工作流自动补齐帖子营养数据

- 后端: fillNutrition()改用Coze工作流7613879935811354687分析营养成分
- 后端: 新增parseWorkflowResponse/nestedToFlat解析工作流响应
- 前端: 页面加载时自动检测并补齐不完整的营养统计
- 修复: ToolKnowledgeServiceImpl编译错误(Integer转Long)

工作流输出格式: output.{calories,protein,potassium,phosphorus}.{value,unit}
This commit is contained in:
2026-03-07 12:36:56 +08:00
parent d62f934d7f
commit 8f94035703
3 changed files with 144 additions and 16 deletions

View File

@@ -23,6 +23,9 @@ import com.zbkj.service.service.UserService;
import com.zbkj.service.service.UserSignService; import com.zbkj.service.service.UserSignService;
import com.zbkj.service.service.tool.ToolCommunityService; import com.zbkj.service.service.tool.ToolCommunityService;
import com.zbkj.service.service.tool.ToolNutritionFillService; import com.zbkj.service.service.tool.ToolNutritionFillService;
import com.zbkj.service.service.tool.ToolCozeService;
import com.zbkj.common.request.coze.CozeWorkflowRequest;
import com.zbkj.common.response.CozeBaseResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -71,6 +74,9 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
@Resource @Resource
private ToolNutritionFillService toolNutritionFillService; private ToolNutritionFillService toolNutritionFillService;
@Resource
private ToolCozeService toolCozeService;
/** /**
* 获取社区内容列表 * 获取社区内容列表
* @param pageParamRequest 分页参数 * @param pageParamRequest 分页参数
@@ -593,6 +599,7 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
/** /**
* 根据帖子内容用 AI 填充营养数据并更新帖子 * 根据帖子内容用 AI 填充营养数据并更新帖子
* 使用 Coze 工作流 7613879935811354687 进行营养成分分析
* @param postId 帖子ID * @param postId 帖子ID
* @return 填充后的营养数据 * @return 填充后的营养数据
*/ */
@@ -611,14 +618,100 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
if (StrUtil.isNotBlank(post.getContent())) text.append(post.getContent()); if (StrUtil.isNotBlank(post.getContent())) text.append(post.getContent());
if (text.length() == 0) throw new CrmebException("帖子标题和内容为空,无法估算营养"); if (text.length() == 0) throw new CrmebException("帖子标题和内容为空,无法估算营养");
Map<String, Object> nutrition = toolNutritionFillService.fillFromText(text.toString()); // 使用 Coze 工作流进行营养成分分析
if (nutrition.isEmpty()) throw new CrmebException("AI 未能估算出营养数据"); CozeWorkflowRequest workflowRequest = new CozeWorkflowRequest();
workflowRequest.setWorkflowId("7613879935811354687");
// 存库使用嵌套格式value/unit与 v2_community_posts.nutrition_data_json 约定一致 Map<String, Object> params = new HashMap<>();
Map<String, Object> nested = toolNutritionFillService.flatToNestedForDb(nutrition); params.put("text", text.toString());
post.setNutritionDataJson(JSON.toJSONString(nested)); workflowRequest.setParameters(params);
CozeBaseResponse<Object> response = toolCozeService.workflow(workflowRequest);
if (response == null || response.getData() == null) {
throw new CrmebException("Coze 工作流调用失败");
}
// 解析工作流返回结果
Map<String, Object> workflowResult = parseWorkflowResponse(response.getData());
if (workflowResult == null || workflowResult.isEmpty()) {
throw new CrmebException("AI 未能估算出营养数据");
}
// 提取 output 中的营养数据(嵌套格式)
@SuppressWarnings("unchecked")
Map<String, Object> output = (Map<String, Object>) workflowResult.get("output");
if (output == null || output.isEmpty()) {
throw new CrmebException("AI 返回数据格式错误");
}
// 将嵌套格式转为扁平格式返回给前端
Map<String, Object> flatNutrition = nestedToFlat(output);
// 存库使用嵌套格式value/unit
post.setNutritionDataJson(JSON.toJSONString(output));
post.setUpdatedAt(new Date()); post.setUpdatedAt(new Date());
v2CommunityPostDao.updateById(post); v2CommunityPostDao.updateById(post);
return nutrition;
return flatNutrition;
}
/**
* 解析 Coze 工作流响应
*/
@SuppressWarnings("unchecked")
private Map<String, Object> parseWorkflowResponse(Object data) {
if (data == null) return null;
if (data instanceof Map) {
return (Map<String, Object>) data;
}
// 如果是 JSON 字符串,尝试解析
if (data instanceof String) {
try {
return JSON.parseObject((String) data, Map.class);
} catch (Exception e) {
log.warn("Failed to parse workflow response as JSON: {}", e.getMessage());
return null;
}
}
return null;
}
/**
* 将嵌套格式转为扁平格式
* 输入: {"calories":{"value":103,"unit":"kcal"},...}
* 输出: {"energyKcal":103,"proteinG":17.5,...}
*/
@SuppressWarnings("unchecked")
private Map<String, Object> nestedToFlat(Map<String, Object> nested) {
Map<String, Object> flat = new HashMap<>();
if (nested == null) return flat;
// 映射关系:嵌套 key -> 扁平 key
copyFlat(flat, nested, "calories", "energyKcal");
copyFlat(flat, nested, "protein", "proteinG");
copyFlat(flat, nested, "potassium", "potassiumMg");
copyFlat(flat, nested, "phosphorus", "phosphorusMg");
copyFlat(flat, nested, "fat", "fatG");
copyFlat(flat, nested, "carbohydrate", "carbohydratesG");
copyFlat(flat, nested, "sodium", "sodiumMg");
return flat;
}
@SuppressWarnings("unchecked")
private void copyFlat(Map<String, Object> flat, Map<String, Object> nested,
String nestedKey, String flatKey) {
Object entry = nested.get(nestedKey);
if (!(entry instanceof Map)) return;
Object value = ((Map<?, ?>) entry).get("value");
if (value instanceof Number) {
flat.put(flatKey, ((Number) value).doubleValue());
} else if (value != null) {
// 尝试转换为数字
try {
flat.put(flatKey, Double.parseDouble(value.toString()));
} catch (NumberFormatException e) {
flat.put(flatKey, value);
}
}
} }
} }

View File

@@ -161,7 +161,7 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
for (String type : new String[]{"guide", "article", "nutrients", "recipe"}) { for (String type : new String[]{"guide", "article", "nutrients", "recipe"}) {
LambdaQueryWrapper<V2Knowledge> lqw = new LambdaQueryWrapper<>(); LambdaQueryWrapper<V2Knowledge> lqw = new LambdaQueryWrapper<>();
lqw.eq(V2Knowledge::getType, type); lqw.eq(V2Knowledge::getType, type);
byType.put(type, v2KnowledgeDao.selectCount(lqw)); byType.put(type, Long.valueOf(v2KnowledgeDao.selectCount(lqw)));
} }
stats.put("byType", byType); stats.put("byType", byType);
@@ -169,7 +169,7 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
for (String status : new String[]{"published", "draft", "deleted"}) { for (String status : new String[]{"published", "draft", "deleted"}) {
LambdaQueryWrapper<V2Knowledge> lqw = new LambdaQueryWrapper<>(); LambdaQueryWrapper<V2Knowledge> lqw = new LambdaQueryWrapper<>();
lqw.eq(V2Knowledge::getStatus, status); lqw.eq(V2Knowledge::getStatus, status);
byStatus.put(status, v2KnowledgeDao.selectCount(lqw)); byStatus.put(status, Long.valueOf(v2KnowledgeDao.selectCount(lqw)));
} }
stats.put("byStatus", byStatus); stats.put("byStatus", byStatus);
return stats; return stats;

View File

@@ -385,7 +385,7 @@ export default {
buildNutritionStatsFromDetailData(data) { buildNutritionStatsFromDetailData(data) {
if (!data) return [] if (!data) return []
// 1) 后端直接返回的 stat 数组(兼容不同命名) // 1) 后端直接返回的 stat 数组或对象(兼容不同命名)
const rawStats = data.nutritionStats || data.nutrition_stats const rawStats = data.nutritionStats || data.nutrition_stats
if (Array.isArray(rawStats) && rawStats.length > 0) { if (Array.isArray(rawStats) && rawStats.length > 0) {
const mapped = rawStats.map(s => ({ const mapped = rawStats.map(s => ({
@@ -398,6 +398,11 @@ export default {
} }
return mapped return mapped
} }
// 1b) 后端返回 nutritionStats 为对象(如 { protein: 56, calories: 200 })时按营养对象解析
if (rawStats && typeof rawStats === 'object' && !Array.isArray(rawStats) && Object.keys(rawStats).length > 0) {
const statsFromObj = this.buildNutritionStatsFromNutritionObject(rawStats)
if (statsFromObj.length > 0 && !statsFromObj.every(s => s.value === '-')) return statsFromObj
}
// 2) nutritionDataJson / nutrition_data_json兼容后端驼峰与下划线含 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg、打卡字段 actualEnergy/actualProtein // 2) nutritionDataJson / nutrition_data_json兼容后端驼峰与下划线含 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg、打卡字段 actualEnergy/actualProtein
const jsonRaw = data.nutritionDataJson || data.nutrition_data_json const jsonRaw = data.nutritionDataJson || data.nutrition_data_json
@@ -454,13 +459,42 @@ export default {
return [] return []
}, },
/** 从单一营养对象构建 [{label, value}, ...],统一兼容 energyKcal/proteinG/potassiumMg/phosphorusMg 及 calories/protein、下划线命名 */ /**
* 从对象中取数值,兼容后端嵌套格式 { calories: { value, unit } } 与扁平格式 energyKcal/proteinG
* 嵌套 value 支持 number 或 string后端 BigDecimal 等可能序列化为字符串)
*/
readNutritionNumber(obj, ...flatKeys) {
if (!obj || typeof obj !== 'object') return null
for (const key of flatKeys) {
const v = obj[key]
if (v == null) continue
if (typeof v === 'number' && !Number.isNaN(v)) return v
// 嵌套格式 { value, unit }value 可能为 number 或 string
if (typeof v === 'object' && v !== null && 'value' in v) {
const val = v.value
if (typeof val === 'number' && !Number.isNaN(val)) return val
const str = typeof val === 'string' ? val.trim() : (val != null ? String(val) : '')
if (str !== '' && str !== 'undefined' && str !== 'null') {
const n = parseFloat(str)
if (!Number.isNaN(n)) return n
}
}
const s = typeof v === 'string' ? v.trim() : String(v)
if (s !== '' && s !== 'undefined' && s !== 'null') {
const n = parseFloat(s)
if (!Number.isNaN(n)) return n
}
}
return null
},
/** 从单一营养对象构建 [{label, value}, ...],统一兼容 energyKcal/proteinG、嵌套 calories: { value, unit }、下划线命名 */
buildNutritionStatsFromNutritionObject(obj) { buildNutritionStatsFromNutritionObject(obj) {
if (!obj || typeof obj !== 'object') return [] if (!obj || typeof obj !== 'object') return []
const cal = obj.energyKcal ?? obj.energy_kcal ?? obj.calories ?? obj.energy ?? obj.calorie ?? obj.actualEnergy const cal = this.readNutritionNumber(obj, 'energyKcal', 'energy_kcal', 'calories', 'energy', 'calorie', 'actualEnergy')
const pro = obj.proteinG ?? obj.protein_g ?? obj.protein ?? obj.proteins ?? obj.actualProtein const pro = this.readNutritionNumber(obj, 'proteinG', 'protein_g', 'protein', 'proteins', 'actualProtein')
const pot = obj.potassiumMg ?? obj.potassium_mg ?? obj.potassium ?? obj.k const pot = this.readNutritionNumber(obj, 'potassiumMg', 'potassium_mg', 'potassium', 'k')
const pho = obj.phosphorusMg ?? obj.phosphorus_mg ?? obj.phosphorus ?? obj.p const pho = this.readNutritionNumber(obj, 'phosphorusMg', 'phosphorus_mg', 'phosphorus', 'p')
return [ return [
{ label: '热量(kcal)', value: cal != null ? String(cal) : '-' }, { label: '热量(kcal)', value: cal != null ? String(cal) : '-' },
{ label: '蛋白质', value: this.formatNutritionValue(pro, 'g') }, { label: '蛋白质', value: this.formatNutritionValue(pro, 'g') },
@@ -548,8 +582,9 @@ export default {
const data = (res && res.data !== undefined && res.data !== null) ? res.data : res const data = (res && res.data !== undefined && res.data !== null) ? res.data : res
if (!data) return if (!data) return
const stats = this.buildNutritionStatsFromDetailData(data) const stats = this.buildNutritionStatsFromDetailData(data)
if (stats.length > 0) { if (stats.length > 0 && !stats.every(s => s.value === '-' || s.value === '')) {
this.$set(this.postData, 'nutritionStats', stats) this.$set(this.postData, 'nutritionStats', stats)
console.log('[post-detail] 营养数据已补齐:', stats)
} }
} catch (e) { } catch (e) {
console.warn('服务端填充帖子营养失败:', e) console.warn('服务端填充帖子营养失败:', e)