feat: 使用Coze工作流自动补齐帖子营养数据
- 后端: fillNutrition()改用Coze工作流7613879935811354687分析营养成分
- 后端: 新增parseWorkflowResponse/nestedToFlat解析工作流响应
- 前端: 页面加载时自动检测并补齐不完整的营养统计
- 修复: ToolKnowledgeServiceImpl编译错误(Integer转Long)
工作流输出格式: output.{calories,protein,potassium,phosphorus}.{value,unit}
This commit is contained in:
@@ -23,6 +23,9 @@ 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 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 org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -71,6 +74,9 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
|
||||
@Resource
|
||||
private ToolNutritionFillService toolNutritionFillService;
|
||||
|
||||
@Resource
|
||||
private ToolCozeService toolCozeService;
|
||||
|
||||
/**
|
||||
* 获取社区内容列表
|
||||
* @param pageParamRequest 分页参数
|
||||
@@ -593,6 +599,7 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
|
||||
|
||||
/**
|
||||
* 根据帖子内容用 AI 填充营养数据并更新帖子
|
||||
* 使用 Coze 工作流 7613879935811354687 进行营养成分分析
|
||||
* @param postId 帖子ID
|
||||
* @return 填充后的营养数据
|
||||
*/
|
||||
@@ -611,14 +618,100 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
|
||||
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 未能估算出营养数据");
|
||||
// 使用 Coze 工作流进行营养成分分析
|
||||
CozeWorkflowRequest workflowRequest = new CozeWorkflowRequest();
|
||||
workflowRequest.setWorkflowId("7613879935811354687");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("text", text.toString());
|
||||
workflowRequest.setParameters(params);
|
||||
|
||||
// 存库使用嵌套格式(value/unit),与 v2_community_posts.nutrition_data_json 约定一致
|
||||
Map<String, Object> nested = toolNutritionFillService.flatToNestedForDb(nutrition);
|
||||
post.setNutritionDataJson(JSON.toJSONString(nested));
|
||||
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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
|
||||
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));
|
||||
byType.put(type, Long.valueOf(v2KnowledgeDao.selectCount(lqw)));
|
||||
}
|
||||
stats.put("byType", byType);
|
||||
|
||||
@@ -169,7 +169,7 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
|
||||
for (String status : new String[]{"published", "draft", "deleted"}) {
|
||||
LambdaQueryWrapper<V2Knowledge> lqw = new LambdaQueryWrapper<>();
|
||||
lqw.eq(V2Knowledge::getStatus, status);
|
||||
byStatus.put(status, v2KnowledgeDao.selectCount(lqw));
|
||||
byStatus.put(status, Long.valueOf(v2KnowledgeDao.selectCount(lqw)));
|
||||
}
|
||||
stats.put("byStatus", byStatus);
|
||||
return stats;
|
||||
|
||||
@@ -385,7 +385,7 @@ export default {
|
||||
buildNutritionStatsFromDetailData(data) {
|
||||
if (!data) return []
|
||||
|
||||
// 1) 后端直接返回的 stat 数组(兼容不同命名)
|
||||
// 1) 后端直接返回的 stat 数组或对象(兼容不同命名)
|
||||
const rawStats = data.nutritionStats || data.nutrition_stats
|
||||
if (Array.isArray(rawStats) && rawStats.length > 0) {
|
||||
const mapped = rawStats.map(s => ({
|
||||
@@ -398,6 +398,11 @@ export default {
|
||||
}
|
||||
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)
|
||||
const jsonRaw = data.nutritionDataJson || data.nutrition_data_json
|
||||
@@ -454,13 +459,42 @@ export default {
|
||||
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) {
|
||||
if (!obj || typeof obj !== 'object') return []
|
||||
const cal = obj.energyKcal ?? obj.energy_kcal ?? obj.calories ?? obj.energy ?? obj.calorie ?? obj.actualEnergy
|
||||
const pro = obj.proteinG ?? obj.protein_g ?? obj.protein ?? obj.proteins ?? obj.actualProtein
|
||||
const pot = obj.potassiumMg ?? obj.potassium_mg ?? obj.potassium ?? obj.k
|
||||
const pho = obj.phosphorusMg ?? obj.phosphorus_mg ?? obj.phosphorus ?? obj.p
|
||||
const cal = this.readNutritionNumber(obj, 'energyKcal', 'energy_kcal', 'calories', 'energy', 'calorie', 'actualEnergy')
|
||||
const pro = this.readNutritionNumber(obj, 'proteinG', 'protein_g', 'protein', 'proteins', 'actualProtein')
|
||||
const pot = this.readNutritionNumber(obj, 'potassiumMg', 'potassium_mg', 'potassium', 'k')
|
||||
const pho = this.readNutritionNumber(obj, 'phosphorusMg', 'phosphorus_mg', 'phosphorus', 'p')
|
||||
return [
|
||||
{ label: '热量(kcal)', value: cal != null ? String(cal) : '-' },
|
||||
{ label: '蛋白质', value: this.formatNutritionValue(pro, 'g') },
|
||||
@@ -548,8 +582,9 @@ export default {
|
||||
const data = (res && res.data !== undefined && res.data !== null) ? res.data : res
|
||||
if (!data) return
|
||||
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)
|
||||
console.log('[post-detail] 营养数据已补齐:', stats)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('服务端填充帖子营养失败:', e)
|
||||
|
||||
Reference in New Issue
Block a user