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:
2026-03-05 09:35:00 +08:00
parent 6f2dc27fbc
commit d8d2025543
44 changed files with 1536 additions and 165 deletions

View File

@@ -6,8 +6,6 @@ import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import sun.misc.BASE64Decoder;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@@ -34,6 +32,14 @@ public class SwaggerInterceptor extends HandlerInterceptorAdapter {
this.password = password;
this.check = check;
}
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public Boolean getCheck() { return check; }
public void setCheck(Boolean check) { this.check = check; }
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String authorization = request.getHeader("Authorization");
@@ -52,7 +58,7 @@ public class SwaggerInterceptor extends HandlerInterceptorAdapter {
public boolean httpBasicAuth(String authorization) throws IOException {
if(check){
if (authorization != null && authorization.split(" ").length == 2) {
String userAndPass = new String(new BASE64Decoder().decodeBuffer(authorization.split(" ")[1]));
String userAndPass = new String(java.util.Base64.getDecoder().decode(authorization.split(" ")[1]));
String username = userAndPass.split(":").length == 2 ? userAndPass.split(":")[0] : null;
String password = userAndPass.split(":").length == 2 ? userAndPass.split(":")[1] : null;
return this.username.equals(username) && this.password.equals(password);

View File

@@ -2,7 +2,6 @@ package com.zbkj.common.page;
import com.zbkj.common.constants.Constants;
import com.github.pagehelper.PageInfo;
import lombok.Data;
import org.springframework.beans.BeanUtils;
import org.springframework.data.domain.Page;
@@ -15,7 +14,6 @@ import java.util.List;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
@Data
public class CommonPage<T> {
private Integer page = Constants.DEFAULT_PAGE;
private Integer limit = Constants.DEFAULT_LIMIT;
@@ -23,6 +21,16 @@ public class CommonPage<T> {
private Long total = 0L ;
private List<T> list = new ArrayList<>();
public Integer getPage() { return page; }
public void setPage(Integer page) { this.page = page; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Integer getTotalPage() { return totalPage; }
public void setTotalPage(Integer totalPage) { this.totalPage = totalPage; }
public Long getTotal() { return total; }
public void setTotal(Long total) { this.total = total; }
public List<T> getList() { return list; }
public void setList(List<T> list) { this.list = list; }
/**
* 将PageHelper分页后的list转为分页信息

View File

@@ -35,4 +35,12 @@ public class ArticleSearchRequest implements Serializable {
@ApiModelProperty(value = "搜索关键字")
private String keywords;
public String getCid() {
return cid;
}
public String getKeywords() {
return keywords;
}
}

View File

@@ -19,4 +19,12 @@ public class PageParamRequest {
@ApiModelProperty(value = "每页数量", example = Constants.DEFAULT_LIMIT + "")
private int limit = Constants.DEFAULT_LIMIT;
public int getPage() {
return page;
}
public int getLimit() {
return limit;
}
}

View File

@@ -64,5 +64,40 @@ public class SystemMenuRequest implements Serializable {
@NotNull(message = "显示状态不能为空")
private Boolean isShow;
public Integer getId() {
return id;
}
public Integer getPid() {
return pid;
}
public String getName() {
return name;
}
public String getIcon() {
return icon;
}
public String getPerms() {
return perms;
}
public String getComponent() {
return component;
}
public String getMenuType() {
return menuType;
}
public Integer getSort() {
return sort;
}
public Boolean getIsShow() {
return isShow;
}
}

View File

@@ -22,4 +22,12 @@ public class SystemMenuSearchRequest {
@ApiModelProperty(value = "菜单类型:M-目录C-菜单A-按钮")
@StringContains(limitValues = {"M","C","A"}, message = "未知的菜单类型")
private String menuType;
public String getName() {
return name;
}
public String getMenuType() {
return menuType;
}
}

View File

@@ -2,16 +2,7 @@ package com.zbkj.common.response;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Coze API 统一响应
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(value = "CozeBaseResponse", description = "Coze API 统一响应")
public class CozeBaseResponse<T> {
@@ -24,15 +15,60 @@ public class CozeBaseResponse<T> {
@ApiModelProperty(value = "响应数据")
private T data;
public CozeBaseResponse() {
}
public CozeBaseResponse(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public static <T> CozeBaseResponse<T> success(T data) {
return new CozeBaseResponse<>(200, "success", data);
CozeBaseResponse<T> response = new CozeBaseResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
return response;
}
public static <T> CozeBaseResponse<T> error(Integer code, String message) {
return new CozeBaseResponse<>(code, message, null);
CozeBaseResponse<T> response = new CozeBaseResponse<>();
response.setCode(code);
response.setMessage(message);
response.setData(null);
return response;
}
public static <T> CozeBaseResponse<T> error(String message) {
return new CozeBaseResponse<>(500, message, null);
CozeBaseResponse<T> response = new CozeBaseResponse<>();
response.setCode(500);
response.setMessage(message);
response.setData(null);
return response;
}
}

View File

@@ -2,9 +2,6 @@ package com.zbkj.common.response;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
@@ -15,9 +12,6 @@ import java.util.List;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@ApiModel(value="MenusResponse对象", description="系统左侧菜单对象")
public class MenusResponse implements Serializable {
@@ -49,4 +43,23 @@ public class MenusResponse implements Serializable {
@ApiModelProperty(value = "子对象列表")
private List<MenusResponse> childList;
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public Integer getPid() { return pid; }
public void setPid(Integer pid) { this.pid = pid; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getIcon() { return icon; }
public void setIcon(String icon) { this.icon = icon; }
public String getPerms() { return perms; }
public void setPerms(String perms) { this.perms = perms; }
public String getComponent() { return component; }
public void setComponent(String component) { this.component = component; }
public String getMenuType() { return menuType; }
public void setMenuType(String menuType) { this.menuType = menuType; }
public Integer getSort() { return sort; }
public void setSort(Integer sort) { this.sort = sort; }
public List<MenusResponse> getChildList() { return childList; }
public void setChildList(List<MenusResponse> childList) { this.childList = childList; }
}

View File

@@ -2,7 +2,6 @@ package com.zbkj.common.response.kieai;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@@ -13,7 +12,6 @@ import java.io.Serializable;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
@Data
@ApiModel(value = "KieAI创建任务响应", description = "KieAI创建任务响应结果")
public class KieAICreateTaskResponse implements Serializable {
@@ -28,6 +26,13 @@ public class KieAICreateTaskResponse implements Serializable {
@ApiModelProperty(value = "响应数据")
private TaskData data;
public Integer getCode() { return code; }
public void setCode(Integer code) { this.code = code; }
public String getMsg() { return msg; }
public void setMsg(String msg) { this.msg = msg; }
public TaskData getData() { return data; }
public void setData(TaskData data) { this.data = data; }
/**
* 便捷方法获取任务ID
*/
@@ -42,7 +47,6 @@ public class KieAICreateTaskResponse implements Serializable {
return code != null && code == 200;
}
@Data
public static class TaskData implements Serializable {
private static final long serialVersionUID = 1L;
@@ -51,5 +55,10 @@ public class KieAICreateTaskResponse implements Serializable {
@ApiModelProperty(value = "记录ID", example = "5d5ba5bc66c11c1a97f719312c76a5df")
private String recordId;
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public String getRecordId() { return recordId; }
public void setRecordId(String recordId) { this.recordId = recordId; }
}
}

View File

@@ -2,7 +2,6 @@ package com.zbkj.common.response.kieai;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@@ -12,7 +11,6 @@ import java.io.Serializable;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
@Data
@ApiModel(value = "KieAI NanoBanana响应", description = "KieAI NanoBanana通用响应")
public class KieAINanoBananaResponse<T> implements Serializable {
@@ -27,6 +25,13 @@ public class KieAINanoBananaResponse<T> implements Serializable {
@ApiModelProperty(value = "响应数据")
private T data;
public int getCode() { return code; }
public void setCode(int code) { this.code = code; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public T getData() { return data; }
public void setData(T data) { this.data = data; }
public static <T> KieAINanoBananaResponse<T> success(T data) {
KieAINanoBananaResponse<T> response = new KieAINanoBananaResponse<>();
response.setCode(200);

View File

@@ -2,9 +2,9 @@ package com.zbkj.common.response.kieai;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* KieAI 查询任务响应 DTO新版 API v1
@@ -13,7 +13,6 @@ import java.io.Serializable;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
@Data
@ApiModel(value = "KieAI查询任务响应", description = "KieAI查询任务状态及结果")
public class KieAIQueryTaskResponse implements Serializable {
@@ -28,6 +27,13 @@ public class KieAIQueryTaskResponse implements Serializable {
@ApiModelProperty(value = "响应数据")
private TaskData data;
public Integer getCode() { return code; }
public void setCode(Integer code) { this.code = code; }
public String getMsg() { return msg; }
public void setMsg(String msg) { this.msg = msg; }
public TaskData getData() { return data; }
public void setData(TaskData data) { this.data = data; }
/**
* 便捷方法获取任务ID
*/
@@ -59,7 +65,7 @@ public class KieAIQueryTaskResponse implements Serializable {
/**
* 便捷方法获取结果URL数组
*/
public java.util.List<String> getResultUrls() {
public List<String> getResultUrls() {
return data != null ? data.getResultUrls() : null;
}
@@ -70,7 +76,6 @@ public class KieAIQueryTaskResponse implements Serializable {
return code != null && code == 200;
}
@Data
public static class TaskData implements Serializable {
private static final long serialVersionUID = 1L;
@@ -89,17 +94,9 @@ public class KieAIQueryTaskResponse implements Serializable {
@ApiModelProperty(value = "结果JSON包含resultUrls", example = "{\"resultUrls\":[\"https://...\"]}")
private String resultJson;
@ApiModelProperty(value = "结果URL数组视频/图片地址)")
private java.util.List<String> resultUrls;
/**
* 显式添加 setter 方法以确保 Jackson 反序列化正常工作
* 解决 "Problem deserializing 'setterless' property" 错误
*/
public void setResultUrls(java.util.List<String> resultUrls) {
this.resultUrls = resultUrls;
}
private List<String> resultUrls;
@ApiModelProperty(value = "失败错误码")
private String failCode;
@@ -118,5 +115,30 @@ public class KieAIQueryTaskResponse implements Serializable {
@ApiModelProperty(value = "更新时间戳")
private Long updateTime;
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public String getState() { return state; }
public void setState(String state) { this.state = state; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
public String getResultJson() { return resultJson; }
public void setResultJson(String resultJson) { this.resultJson = resultJson; }
public List<String> getResultUrls() { return resultUrls; }
public void setResultUrls(List<String> resultUrls) { this.resultUrls = resultUrls; }
public String getFailCode() { return failCode; }
public void setFailCode(String failCode) { this.failCode = failCode; }
public String getFailMsg() { return failMsg; }
public void setFailMsg(String failMsg) { this.failMsg = failMsg; }
public Long getCostTime() { return costTime; }
public void setCostTime(Long costTime) { this.costTime = costTime; }
public Long getCompleteTime() { return completeTime; }
public void setCompleteTime(Long completeTime) { this.completeTime = completeTime; }
public Long getCreateTime() { return createTime; }
public void setCreateTime(Long createTime) { this.createTime = createTime; }
public Long getUpdateTime() { return updateTime; }
public void setUpdateTime(Long updateTime) { this.updateTime = updateTime; }
}
}

View File

@@ -1,8 +1,8 @@
package com.zbkj.common.result;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zbkj.common.annotation.CustomResponseAnnotation;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
@@ -49,11 +49,14 @@ public class ResultAdvice implements ResponseBodyAdvice<Object> {
/**
* 对返回数据进行处理
*/
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof String) {// 如果Controller直接返回String的话SpringBoot是直接返回故我们需要手动转换成json。
return objectMapper.writeValueAsString(CommonResult.success(body));
try {
return objectMapper.writeValueAsString(CommonResult.success(body));
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize response to JSON", e);
}
}
if (body instanceof CommonResult) {// 如果返回的结果是CommonResult对象直接返回即可。
return body;

View File

@@ -149,7 +149,15 @@ public class FrontTokenComponent {
return null;
// throw new CrmebException("登录信息已过期,请重新登录!");
}
return redisUtil.get(getTokenKey(token));
Object value = redisUtil.get(getTokenKey(token));
if (value instanceof Integer) {
return (Integer) value;
}
if (value instanceof LoginUserVo) {
LoginUserVo loginUser = (LoginUserVo) value;
return loginUser.getUser() != null ? loginUser.getUser().getId() : null;
}
return null;
}
//路由在此处则返回true无论用户是否登录都可以访问

View File

@@ -19,4 +19,21 @@ public class DateLimitUtilVo {
private String startTime; //开始时间
private String endTime; //结束时间
public String getStartTime() {
return startTime;
}
public void setStartTime(String startTime) {
this.startTime = startTime;
}
public String getEndTime() {
return endTime;
}
public void setEndTime(String endTime) {
this.endTime = endTime;
}
}

View File

@@ -32,4 +32,29 @@ public class ImageMergeUtilVo {
@ApiModelProperty(value = "y轴", required = true)
@Min(value = 0, message = "y轴至少为0")
private int y; //y轴
// 手动添加 getter/setter避免 Lombok 未生效时编译报错
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
}

View File

@@ -74,9 +74,15 @@ public class LoginUserVo implements UserDetails {
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
if (permissions == null) {
return new ArrayList<>();
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>(permissions.size());
for (SystemPermissions permission : permissions) {
authorities.add(new SimpleGrantedAuthority(permission.getPath()));
String path = permission.getPath();
if (path != null) {
authorities.add(new SimpleGrantedAuthority(path));
}
}
return authorities;
}
@@ -201,4 +207,11 @@ public class LoginUserVo implements UserDetails {
this.user = user;
}
/**
* 获取用户昵称/显示名称(后台管理员为 realName
*/
public String getNickname() {
return user != null ? user.getRealName() : null;
}
}

View File

@@ -9,12 +9,6 @@ import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
/**
* 菜单待选中Vo对象
* +----------------------------------------------------------------------
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@@ -43,4 +37,60 @@ public class MenuCheckVo implements Serializable {
@ApiModelProperty(value = "子对象列表")
private List<MenuCheckVo> childList;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Integer getPid() {
return pid;
}
public void setPid(Integer pid) {
this.pid = pid;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public Boolean getChecked() {
return checked;
}
public void setChecked(Boolean checked) {
this.checked = checked;
}
public Integer getSort() {
return sort;
}
public void setSort(Integer sort) {
this.sort = sort;
}
public List<MenuCheckVo> getChildList() {
return childList;
}
public void setChildList(List<MenuCheckVo> childList) {
this.childList = childList;
}
}

View File

@@ -22,6 +22,9 @@ public class MenuTree {
this.menuList = menuList;
}
public List<MenusResponse> getMenuList() { return menuList; }
public void setMenuList(List<MenusResponse> menuList) { this.menuList = menuList; }
//建立树形结构
public List<MenusResponse> buildTree(){
List<MenusResponse> treeMenus = new ArrayList<MenusResponse>();
@@ -35,7 +38,7 @@ public class MenuTree {
// 排序
private List<MenusResponse> sortList(List<MenusResponse> treeMenus) {
treeMenus = treeMenus.stream().sorted(Comparator.comparing(MenusResponse::getSort).reversed()).collect(Collectors.toList());
treeMenus = treeMenus.stream().sorted(Comparator.comparing((MenusResponse m) -> m.getSort() != null ? m.getSort() : 0).reversed()).collect(Collectors.toList());
treeMenus.forEach(e -> {
if (CollUtil.isNotEmpty(e.getChildList())) {
e.setChildList(sortList(e.getChildList()));

View File

@@ -53,6 +53,9 @@ public class ToolController {
@Autowired
private ToolPointsService toolPointsService;
@Autowired
private ToolNutritionFillService toolNutritionFillService;
@Autowired
private ToolHomeService toolHomeService;
@@ -312,6 +315,15 @@ public class ToolController {
return CommonResult.success(toolKnowledgeService.getNutrientDetail(name));
}
/**
* 检查知识库数据:按 type、status 汇总 v2_knowledge 条数T04
*/
@ApiOperation(value = "知识库统计")
@GetMapping("/knowledge/stats")
public CommonResult<Map<String, Object>> getKnowledgeStats() {
return CommonResult.success(toolKnowledgeService.getStats());
}
/**
* 为 cover_image 为空的饮食指南/科普文章生成封面图KieAI 1:1100KB 内,上传 OSS 并更新 v2_knowledge
*/
@@ -322,6 +334,19 @@ public class ToolController {
return CommonResult.success(updated);
}
// ==================== AI 营养填充T06/T07 ====================
/**
* 根据饮食描述文本,用 AI 估算营养数据(热量、蛋白质、钾、磷)
*/
@ApiOperation(value = "AI营养估算")
@PostMapping("/nutrition/fill-ai")
public CommonResult<Map<String, Object>> fillNutritionByAi(@RequestBody Map<String, Object> data) {
String text = data != null && data.containsKey("text") ? String.valueOf(data.get("text")) : null;
Map<String, Object> result = toolNutritionFillService.fillFromText(text);
return CommonResult.success(result);
}
// ==================== 打卡社区相关 ====================
/**
@@ -352,6 +377,16 @@ public class ToolController {
return CommonResult.success(toolCommunityService.publish(data));
}
/**
* 帖子营养 AI 填充:根据帖子标题与内容用 AI 估算营养数据并更新帖子
*/
@ApiOperation(value = "帖子营养AI填充")
@PostMapping("/community/post/{postId}/fill-nutrition")
public CommonResult<Map<String, Object>> fillPostNutrition(
@ApiParam(value = "帖子ID", required = true) @PathVariable Long postId) {
return CommonResult.success(toolCommunityService.fillNutrition(postId));
}
/**
* 点赞/取消点赞
*/
@@ -548,10 +583,22 @@ public class ToolController {
@ApiOperation(value = "收藏/取消收藏食谱")
@PostMapping("/recipe/favorite")
public CommonResult<String> toggleRecipeFavorite(@RequestBody Map<String, Object> data) {
toolRecipeService.toggleFavorite((Long) data.get("recipeId"), (Boolean) data.get("isFavorite"));
Long recipeId = ((Number) data.get("recipeId")).longValue();
Boolean isFavorite = (Boolean) data.get("isFavorite");
toolRecipeService.toggleFavorite(recipeId, isFavorite);
return CommonResult.success("操作成功");
}
/**
* 食谱营养 AI 填充:根据食谱食材用 Coze AI 分析并填充营养数据(能量、蛋白质、钾、磷、钠),更新食谱并返回
*/
@ApiOperation(value = "食谱营养AI填充")
@PostMapping("/recipe/{recipeId}/fill-nutrition")
public CommonResult<Map<String, Object>> fillRecipeNutrition(
@ApiParam(value = "食谱ID", required = true) @PathVariable Long recipeId) {
return CommonResult.success(toolRecipeService.fillNutrition(recipeId));
}
// ==================== 文件上传相关 ====================
/**

View File

@@ -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);

View File

@@ -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 内触发视频任务返回空 taskIdcheckInRecordId: {}", 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 "饮食";

View File

@@ -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;
}
}

View File

@@ -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_ratio2: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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -34,5 +34,12 @@ public interface ToolRecipeService {
* @param isFavorite 是否收藏
*/
void toggleFavorite(Long recipeId, Boolean isFavorite);
/**
* 使用 Coze AI 根据食谱食材分析并填充营养数据(能量、蛋白质、钾、磷、钠),更新食谱并返回
* @param recipeId 食谱ID
* @return 填充后的营养数据 MapenergyKcal, proteinG, potassiumMg, phosphorusMg, sodiumMg
*/
java.util.Map<String, Object> fillNutrition(Long recipeId);
}