From d8d202554327c2c230e92626951ddbfd282e0730 Mon Sep 17 00:00:00 2001 From: scottpan <43121650@qq.com> Date: Thu, 5 Mar 2026 09:35:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20T10=20=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20Bug=20=E4=BF=AE=E5=A4=8D=E4=B8=8E=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 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: 回归测试用例 --- docs/Testing/T10-full-regression-report.md | 73 +++++ docs/sql/check_knowledge_articles.sql | 21 ++ docs/sql/seed_v2_knowledge_guide_article.sql | 43 +++ .../interceptor/SwaggerInterceptor.java | 12 +- .../java/com/zbkj/common/page/CommonPage.java | 12 +- .../common/request/ArticleSearchRequest.java | 8 + .../zbkj/common/request/PageParamRequest.java | 8 + .../common/request/SystemMenuRequest.java | 35 +++ .../request/SystemMenuSearchRequest.java | 8 + .../common/response/CozeBaseResponse.java | 60 +++- .../zbkj/common/response/MenusResponse.java | 25 +- .../kieai/KieAICreateTaskResponse.java | 15 +- .../kieai/KieAINanoBananaResponse.java | 9 +- .../kieai/KieAIQueryTaskResponse.java | 50 +++- .../com/zbkj/common/result/ResultAdvice.java | 9 +- .../common/token/FrontTokenComponent.java | 10 +- .../com/zbkj/common/vo/DateLimitUtilVo.java | 17 ++ .../com/zbkj/common/vo/ImageMergeUtilVo.java | 25 ++ .../java/com/zbkj/common/vo/LoginUserVo.java | 15 +- .../java/com/zbkj/common/vo/MenuCheckVo.java | 62 +++- .../java/com/zbkj/common/vo/MenuTree.java | 5 +- .../zbkj/front/controller/ToolController.java | 49 +++- .../service/impl/SystemConfigServiceImpl.java | 4 +- .../impl/tool/ToolCheckinServiceImpl.java | 109 +++++-- .../impl/tool/ToolCommunityServiceImpl.java | 38 ++- .../impl/tool/ToolGrokServiceImpl.java | 82 +++++- .../impl/tool/ToolKnowledgeServiceImpl.java | 25 ++ .../tool/ToolNutritionFillServiceImpl.java | 100 +++++++ .../impl/tool/ToolRecipeServiceImpl.java | 271 ++++++++++++++++++ .../service/tool/ToolCommunityService.java | 7 + .../service/tool/ToolKnowledgeService.java | 7 + .../tool/ToolNutritionFillService.java | 20 ++ .../service/tool/ToolRecipeService.java | 7 + msh_single_uniapp/api/tool.js | 25 ++ .../pages/tool/ai-nutritionist.vue | 4 +- .../pages/tool/calculator-result.vue | 6 +- msh_single_uniapp/pages/tool/checkin.vue | 8 +- msh_single_uniapp/pages/tool/food-detail.vue | 11 +- .../pages/tool/food-encyclopedia.vue | 21 +- .../pages/tool/nutrition-knowledge.vue | 40 +-- msh_single_uniapp/pages/tool/post-detail.vue | 158 +++++++--- .../pages/tool/recipe-detail.vue | 39 ++- .../pages/tool_main/community.vue | 8 +- tests/e2e/bug-regression.spec.ts | 140 +++++++++ 44 files changed, 1536 insertions(+), 165 deletions(-) create mode 100644 docs/Testing/T10-full-regression-report.md create mode 100644 docs/sql/check_knowledge_articles.sql create mode 100644 docs/sql/seed_v2_knowledge_guide_article.sql create mode 100644 msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolNutritionFillServiceImpl.java create mode 100644 msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolNutritionFillService.java diff --git a/docs/Testing/T10-full-regression-report.md b/docs/Testing/T10-full-regression-report.md new file mode 100644 index 0000000..f2209c2 --- /dev/null +++ b/docs/Testing/T10-full-regression-report.md @@ -0,0 +1,73 @@ +# T10 Full Regression Test Report + +**Plan:** smooth-moseying-tiger · **Task:** T10 Full regression testing +**Date:** 2026-03-04 +**Scope:** Verify all 6 fixes: post detail tag, post/recipe AI fill, recipe favorite, recipe like visual, knowledge navigation. + +--- + +## Summary + +| # | Issue | Test ID | Pass/Fail | Notes | +|---|--------|---------|-----------|--------| +| 1 | Post detail tag shows Chinese | T10-1 | *(run tests)* | Asserts `.meal-tag`/`.type-tag` has Chinese on post detail | +| 2 | Post nutrition AI fill works | T10-2 | *(run tests)* | Clicks "AI 补充营养", expects stats and no error | +| 3 | Recipe nutrition AI fill works | T10-3 | *(run tests)* | Recipe detail shows nutrition (API or AI fill) | +| 4 | Recipe favorite no error | T10-4 | *(run tests)* | Favorite button shows 已收藏/已取消收藏, no 操作失败 | +| 5 | Recipe like emoji changes visual | T10-5 | *(run tests)* | Like button emoji 🤍 ↔ ❤️ on click | +| 6 | Knowledge articles can navigate to detail | T10-6 | *(run tests)* | 科普文章 → first item → detail has content | + +**To get pass/fail:** Run `./scripts/run-t10-regression.sh` or `npx playwright test tests/e2e/bug-regression.spec.ts --grep "T10"`, then set each row to PASS or FAIL from the test output. + +--- + +## How to Run + +**Prerequisites:** Frontend at `http://localhost:8080`, backend API at `http://127.0.0.1:20822`, logged-in test account (phone/password in spec). + +```bash +cd /Users/apple/scott2026/msh-system +npx playwright test tests/e2e/bug-regression.spec.ts --grep "T10" --reporter=list +``` + +To run with HTML report: + +```bash +npx playwright test tests/e2e/bug-regression.spec.ts --grep "T10" --reporter=html +npx playwright show-report tests/e2e/reports +``` + +--- + +## Test Descriptions + +| # | Issue | What the test does | +|---|--------|---------------------| +| **1** | **Post detail tag shows Chinese** | Opens community → first post → checks `.meal-tag` / `.type-tag` contains Chinese (e.g. 早餐/午餐/晚餐/分享). | +| **2** | **Post nutrition AI fill works** | Opens a post detail; if "🤖 AI 补充营养" is visible, clicks it, waits ~8s; asserts no error and nutrition stats appear. | +| **3** | **Recipe nutrition AI fill works** | Opens recipe detail (from tool main or `recipe-detail?id=1`), waits for nutrition card; asserts nutrition values are present (from API or AI fill). | +| **4** | **Recipe favorite no error** | Opens recipe detail, clicks favorite (⭐) button; expects toast "已收藏" or "已取消收藏" and no "操作失败". | +| **5** | **Recipe like emoji changes visual** | Opens recipe detail, reads like button emoji (🤍/❤️), clicks like; asserts emoji or state changes. | +| **6** | **Knowledge articles can navigate to detail** | Goes to nutrition-knowledge → 科普文章 tab → clicks first article; asserts detail page has visible content. | + +--- + +## After Running: Fill Pass/Fail + +Replace **Run tests to fill** in the Summary table with **PASS** or **FAIL** per test, and add any Notes (e.g. timeout, missing data, API error). + +Example: + +| # | Issue | Test ID | Pass/Fail | Notes | +|---|--------|---------|-----------|--------| +| 1 | Post detail tag shows Chinese | T10-1 | PASS | | +| 2 | Post nutrition AI fill works | T10-2 | PASS | | +| ... | ... | ... | ... | ... | + +--- + +## Related Existing Tests + +- **TC-B07** – Diet guide & science article detail content (same scope as item 6). +- **TC-B08** – Post detail nutrition stats visible. +- **TC-B09** – Community tabs and post type tags in Chinese (list page; T10-1 checks post *detail* page). diff --git a/docs/sql/check_knowledge_articles.sql b/docs/sql/check_knowledge_articles.sql new file mode 100644 index 0000000..c33d29a --- /dev/null +++ b/docs/sql/check_knowledge_articles.sql @@ -0,0 +1,21 @@ +-- T04: Check database for knowledge articles (v2_knowledge) +-- Run against your DB: mysql -u user -p your_db < docs/sql/check_knowledge_articles.sql +-- Or use API: GET /api/front/tool/knowledge/stats (returns total, byType, byStatus) +-- +-- If (guide + article, published) counts are 0: run docs/sql/seed_v2_knowledge_guide_article.sql +-- If data exists but list click doesn't open detail: check front-end console for +-- [nutrition-knowledge] goToDetail logs (item keys, resolved id, navigate url). + +SELECT 'v2_knowledge: total' AS report, COUNT(*) AS cnt FROM v2_knowledge; + +SELECT type, status, COUNT(*) AS cnt +FROM v2_knowledge +GROUP BY type, status +ORDER BY type, status; + +-- Published count by type (guide + article must be > 0 for list/click to work) +SELECT type, COUNT(*) AS cnt +FROM v2_knowledge +WHERE status = 'published' +GROUP BY type +ORDER BY type; diff --git a/docs/sql/seed_v2_knowledge_guide_article.sql b/docs/sql/seed_v2_knowledge_guide_article.sql new file mode 100644 index 0000000..b60ecdb --- /dev/null +++ b/docs/sql/seed_v2_knowledge_guide_article.sql @@ -0,0 +1,43 @@ +-- T04/T05: Seed v2_knowledge with guide/article rows when none exist (status='published') +-- 1) Check first: run docs/sql/check_knowledge_articles.sql or GET /api/front/tool/knowledge/stats +-- 2) If (guide + article, published) counts are 0, run this once. +-- Usage: mysql -u user -p your_db < docs/sql/seed_v2_knowledge_guide_article.sql +-- Example (local): mysql -h 127.0.0.1 -u test_java -p test_java < docs/sql/seed_v2_knowledge_guide_article.sql + +INSERT INTO `v2_knowledge` ( + `title`, `content`, `summary`, `cover_image`, `type`, `category`, + `view_count`, `like_count`, `collect_count`, `share_count`, `status`, + `is_recommend`, `sort_order`, `published_at` +) VALUES +-- 饮食指南 (type=guide) +( + '慢性肾病饮食指南概述', + '

慢性肾病(CKD)患者需在医生或营养师指导下调整饮食,以延缓病情、减少并发症。

基本原则:适量优质蛋白、控制钾磷钠、限水(必要时)、充足热量。

', + 'CKD饮食基本原则:优质蛋白、控钾磷钠、限水与充足热量。', + NULL, 'guide', '饮食指南', 0, 0, 0, 0, 'published', 1, 10, NOW() +), +( + '优质蛋白怎么选', + '

优质蛋白主要来自蛋、奶、瘦肉、鱼及大豆类。

非优质蛋白(如米面、部分豆类)需在总蛋白控制下搭配,避免加重肾脏负担。

', + '蛋奶瘦肉鱼及大豆为优质蛋白来源,需在总蛋白限量内合理选择。', + NULL, 'guide', '饮食指南', 0, 0, 0, 0, 'published', 1, 9, NOW() +), +( + '钾与磷的饮食控制要点', + '

高钾食物:香蕉、橙子、土豆、番茄、深色蔬菜等,需根据血钾水平限量和焯水去钾。

高磷食物:奶制品、坚果、动物内脏、全谷等,需配合磷结合剂与饮食控制。

', + '高钾高磷食物识别与限量、焯水去钾等实用要点。', + NULL, 'guide', '饮食指南', 0, 0, 0, 0, 'published', 1, 8, NOW() +), +-- 科普文章 (type=article) +( + '认识慢性肾病分期与营养', + '

CKD 1–5期营养重点不同:早期注重预防与均衡,中晚期需严格控蛋白、钾、磷、钠及液体。

定期复查并与营养师沟通,制定个体化饮食方案。

', + '各分期营养侧重点与个体化饮食方案简介。', + NULL, 'article', '科普', 0, 0, 0, 0, 'published', 1, 10, NOW() +), +( + '透析患者一日饮食安排建议', + '

透析日与非透析日可在营养师指导下微调蛋白与液体。

建议定时定量、少盐少油、适量优质蛋白,并注意钾磷控制。

', + '透析患者日常饮食安排与注意事项。', + NULL, 'article', '科普', 0, 0, 0, 0, 'published', 1, 9, NOW() +); diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/interceptor/SwaggerInterceptor.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/interceptor/SwaggerInterceptor.java index fcb0776..bb43420 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/interceptor/SwaggerInterceptor.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/interceptor/SwaggerInterceptor.java @@ -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); diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/page/CommonPage.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/page/CommonPage.java index 7f4d9d2..49e9c23 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/page/CommonPage.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/page/CommonPage.java @@ -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 { private Integer page = Constants.DEFAULT_PAGE; private Integer limit = Constants.DEFAULT_LIMIT; @@ -23,6 +21,16 @@ public class CommonPage { private Long total = 0L ; private List 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 getList() { return list; } + public void setList(List list) { this.list = list; } /** * 将PageHelper分页后的list转为分页信息 diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/ArticleSearchRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/ArticleSearchRequest.java index 33e2821..a49bbfb 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/ArticleSearchRequest.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/ArticleSearchRequest.java @@ -35,4 +35,12 @@ public class ArticleSearchRequest implements Serializable { @ApiModelProperty(value = "搜索关键字") private String keywords; + public String getCid() { + return cid; + } + + public String getKeywords() { + return keywords; + } + } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/PageParamRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/PageParamRequest.java index e215db4..ad4cb4b 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/PageParamRequest.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/PageParamRequest.java @@ -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; + } + } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuRequest.java index 680b8c3..5920800 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuRequest.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuRequest.java @@ -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; + } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuSearchRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuSearchRequest.java index 2555148..83e568f 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuSearchRequest.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuSearchRequest.java @@ -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; + } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/CozeBaseResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/CozeBaseResponse.java index 1a427ba..4a7dbf3 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/CozeBaseResponse.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/CozeBaseResponse.java @@ -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 { @@ -24,15 +15,60 @@ public class CozeBaseResponse { @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 CozeBaseResponse success(T data) { - return new CozeBaseResponse<>(200, "success", data); + CozeBaseResponse response = new CozeBaseResponse<>(); + response.setCode(200); + response.setMessage("success"); + response.setData(data); + return response; } public static CozeBaseResponse error(Integer code, String message) { - return new CozeBaseResponse<>(code, message, null); + CozeBaseResponse response = new CozeBaseResponse<>(); + response.setCode(code); + response.setMessage(message); + response.setData(null); + return response; } public static CozeBaseResponse error(String message) { - return new CozeBaseResponse<>(500, message, null); + CozeBaseResponse response = new CozeBaseResponse<>(); + response.setCode(500); + response.setMessage(message); + response.setData(null); + return response; } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/MenusResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/MenusResponse.java index a1f153f..dbb7288 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/MenusResponse.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/MenusResponse.java @@ -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 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 getChildList() { return childList; } + public void setChildList(List childList) { this.childList = childList; } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAICreateTaskResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAICreateTaskResponse.java index 04950e2..d921ad4 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAICreateTaskResponse.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAICreateTaskResponse.java @@ -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; } } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAINanoBananaResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAINanoBananaResponse.java index 82cde48..5a0a018 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAINanoBananaResponse.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAINanoBananaResponse.java @@ -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 implements Serializable { @@ -27,6 +25,13 @@ public class KieAINanoBananaResponse 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 KieAINanoBananaResponse success(T data) { KieAINanoBananaResponse response = new KieAINanoBananaResponse<>(); response.setCode(200); diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAIQueryTaskResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAIQueryTaskResponse.java index 95c1826..ae2b5ec 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAIQueryTaskResponse.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAIQueryTaskResponse.java @@ -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 getResultUrls() { + public List 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 resultUrls; - - /** - * 显式添加 setter 方法以确保 Jackson 反序列化正常工作 - * 解决 "Problem deserializing 'setterless' property" 错误 - */ - public void setResultUrls(java.util.List resultUrls) { - this.resultUrls = resultUrls; - } + private List 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 getResultUrls() { return resultUrls; } + public void setResultUrls(List 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; } } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/result/ResultAdvice.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/result/ResultAdvice.java index c03f5a7..1f7fd09 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/result/ResultAdvice.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/result/ResultAdvice.java @@ -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 { /** * 对返回数据进行处理 */ - @SneakyThrows @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> 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; diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java index 00142a9..74e4090 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java @@ -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,无论用户是否登录都可以访问 diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/DateLimitUtilVo.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/DateLimitUtilVo.java index 59bfa3d..97e30e3 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/DateLimitUtilVo.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/DateLimitUtilVo.java @@ -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; + } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/ImageMergeUtilVo.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/ImageMergeUtilVo.java index faf5548..1d25b75 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/ImageMergeUtilVo.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/ImageMergeUtilVo.java @@ -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; + } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/LoginUserVo.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/LoginUserVo.java index 9a5f993..6c2c744 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/LoginUserVo.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/LoginUserVo.java @@ -74,9 +74,15 @@ public class LoginUserVo implements UserDetails { @Override @JsonIgnore public Collection getAuthorities() { + if (permissions == null) { + return new ArrayList<>(); + } List 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; + } + } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuCheckVo.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuCheckVo.java index 897c5d0..dc5c38f 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuCheckVo.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuCheckVo.java @@ -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 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 getChildList() { + return childList; + } + + public void setChildList(List childList) { + this.childList = childList; + } } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuTree.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuTree.java index e2fb33d..2aff512 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuTree.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuTree.java @@ -22,6 +22,9 @@ public class MenuTree { this.menuList = menuList; } + public List getMenuList() { return menuList; } + public void setMenuList(List menuList) { this.menuList = menuList; } + //建立树形结构 public List buildTree(){ List treeMenus = new ArrayList(); @@ -35,7 +38,7 @@ public class MenuTree { // 排序 private List sortList(List 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())); diff --git a/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/ToolController.java b/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/ToolController.java index d069868..6bbedb9 100644 --- a/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/ToolController.java +++ b/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/ToolController.java @@ -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> getKnowledgeStats() { + return CommonResult.success(toolKnowledgeService.getStats()); + } + /** * 为 cover_image 为空的饮食指南/科普文章生成封面图(KieAI 1:1,100KB 内,上传 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> fillNutritionByAi(@RequestBody Map data) { + String text = data != null && data.containsKey("text") ? String.valueOf(data.get("text")) : null; + Map 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> 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 toggleRecipeFavorite(@RequestBody Map 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> fillRecipeNutrition( + @ApiParam(value = "食谱ID", required = true) @PathVariable Long recipeId) { + return CommonResult.success(toolRecipeService.fillNutrition(recipeId)); + } + // ==================== 文件上传相关 ==================== /** diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/SystemConfigServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/SystemConfigServiceImpl.java index 1f2c94d..4a57dfd 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/SystemConfigServiceImpl.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/SystemConfigServiceImpl.java @@ -163,7 +163,9 @@ public class SystemConfigServiceImpl extends ServiceImpl(){{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(){{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(){{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(){{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
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 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 "饮食"; diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCommunityServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCommunityServiceImpl.java index 6887f42..6183074 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCommunityServiceImpl.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCommunityServiceImpl.java @@ -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 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 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; + } } diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolGrokServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolGrokServiceImpl.java index ccf6f8c..de5fabb 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolGrokServiceImpl.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolGrokServiceImpl.java @@ -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 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 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 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; diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKnowledgeServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKnowledgeServiceImpl.java index ed184ac..3a4e0bd 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKnowledgeServiceImpl.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKnowledgeServiceImpl.java @@ -149,4 +149,29 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService { } return "健康营养主题配图,标题:" + title + "。简洁现代风格,1:1 方图。"; } + + @Override + public Map getStats() { + Map stats = new HashMap<>(); + // 总数 + long total = v2KnowledgeDao.selectCount(null); + stats.put("total", total); + + Map byType = new HashMap<>(); + for (String type : new String[]{"guide", "article", "nutrients", "recipe"}) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(V2Knowledge::getType, type); + byType.put(type, v2KnowledgeDao.selectCount(lqw)); + } + stats.put("byType", byType); + + Map byStatus = new HashMap<>(); + for (String status : new String[]{"published", "draft", "deleted"}) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(V2Knowledge::getStatus, status); + byStatus.put(status, v2KnowledgeDao.selectCount(lqw)); + } + stats.put("byStatus", byStatus); + return stats; + } } diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolNutritionFillServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolNutritionFillServiceImpl.java new file mode 100644 index 0000000..d4dd82d --- /dev/null +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolNutritionFillServiceImpl.java @@ -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 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 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 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 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) first).get("message"); + if (!(message instanceof Map)) return null; + Object content = ((Map) 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; + } +} diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolRecipeServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolRecipeServiceImpl.java index 5e4eb4e..4058b58 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolRecipeServiceImpl.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolRecipeServiceImpl.java @@ -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 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 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 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 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 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 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 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 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; + } + } diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolCommunityService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolCommunityService.java index a43fea3..8cff738 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolCommunityService.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolCommunityService.java @@ -77,5 +77,12 @@ public interface ToolCommunityService { * @return 分享信息 */ Map share(Long postId); + + /** + * 根据帖子内容用 AI 填充营养数据并更新帖子 + * @param postId 帖子ID + * @return 填充后的营养数据(energyKcal, proteinG, potassiumMg, phosphorusMg) + */ + Map fillNutrition(Long postId); } diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolKnowledgeService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolKnowledgeService.java index 08bedff..5b934e5 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolKnowledgeService.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolKnowledgeService.java @@ -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 getStats(); } diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolNutritionFillService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolNutritionFillService.java new file mode 100644 index 0000000..7eabed5 --- /dev/null +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolNutritionFillService.java @@ -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 fillFromText(String text); +} diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolRecipeService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolRecipeService.java index 4af1859..312ff94 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolRecipeService.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolRecipeService.java @@ -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 fillNutrition(Long recipeId); } diff --git a/msh_single_uniapp/api/tool.js b/msh_single_uniapp/api/tool.js index eb796f7..a46be5f 100644 --- a/msh_single_uniapp/api/tool.js +++ b/msh_single_uniapp/api/tool.js @@ -208,6 +208,8 @@ export function getFoodList(data) { */ export function getFoodDetail(id) { const numId = typeof id === 'number' && !isNaN(id) ? id : parseInt(String(id), 10); + // 打印请求参数便于确认(后端仅接受 Long 类型 id,传 name 会 400) + console.log('[api/tool] getFoodDetail 请求参数:', { id, numId, type: typeof id }); if (isNaN(numId)) { return Promise.reject(new Error('食物详情接口需要数字ID,当前传入: ' + id)); } @@ -252,6 +254,15 @@ export function getNutrientDetail(name) { return request.get('tool/knowledge/nutrient/' + name); } +/** + * AI 营养估算:根据饮食描述文本返回估算的热量、蛋白质、钾、磷(T06-T09) + * @param {String} text - 饮食描述 + * @returns {Promise<{data: {energyKcal?, proteinG?, potassiumMg?, phosphorusMg?}}>} + */ +export function getAiNutritionFill(text) { + return request.post('tool/nutrition/fill-ai', { text: text || '' }); +} + // ==================== 打卡社区相关 ==================== /** @@ -346,6 +357,16 @@ export function sharePost(postId) { return request.post('tool/community/share', { postId }); } +/** + * 填充帖子营养数据(服务端根据帖子内容/打卡补充营养并回写) + * @param {Number|String} postId - 帖子ID + * @returns {Promise<{data?: object}>} 返回更新后的帖子或营养数据 + */ +export function fillPostNutrition(postId) { + const id = typeof postId === 'number' && !isNaN(postId) ? postId : parseInt(postId, 10); + return request.post('tool/community/post/' + id + '/fill-nutrition'); +} + // ==================== 积分系统相关 ==================== /** @@ -458,6 +479,10 @@ export function getRecipeDetail(id) { export function toggleRecipeFavorite(recipeId, isFavorite) { return request.post('tool/recipe/favorite', { recipeId, isFavorite }); } +// T09: 食谱营养 AI 回填 +export function fillRecipeNutrition(recipeId) { + return request.post("tool/recipe/" + recipeId + "/fill-nutrition") +} // ==================== 文件上传相关 ==================== diff --git a/msh_single_uniapp/pages/tool/ai-nutritionist.vue b/msh_single_uniapp/pages/tool/ai-nutritionist.vue index 02a37c7..6a13767 100644 --- a/msh_single_uniapp/pages/tool/ai-nutritionist.vue +++ b/msh_single_uniapp/pages/tool/ai-nutritionist.vue @@ -623,7 +623,8 @@ export default { async sendToAI(content, type) { this.isLoading = true; - // 文本对话必须走 KieAI Gemini:POST /api/front/kieai/gemini/chat,请求体 { messages: [{ role: 'user', content }], stream: false } + // BUG-005:文本/多模态必须走 KieAI Gemini,回复仅从 data.choices[0].message.content 取,不得使用固定话术 + // 请求体: { messages: [{ role: 'user', content }], stream: false } if (type === 'text' || type === 'multimodal') { try { const messages = [{ role: 'user', content: content }]; @@ -635,6 +636,7 @@ export default { const msgObj = choice && choice.message; const rawContent = msgObj && msgObj.content; const reply = rawContent != null ? this.extractReplyContent(rawContent) : ''; + // 成功时仅展示模型返回内容 this.messageList.push({ role: 'ai', content: reply || '未能获取到有效回复。' }); } else { const msg = (response && response.message) || '发起对话失败'; diff --git a/msh_single_uniapp/pages/tool/calculator-result.vue b/msh_single_uniapp/pages/tool/calculator-result.vue index 50378ba..e02082c 100644 --- a/msh_single_uniapp/pages/tool/calculator-result.vue +++ b/msh_single_uniapp/pages/tool/calculator-result.vue @@ -492,25 +492,23 @@ export default { box-sizing: border-box; color: #9ca3af; font-weight: 400; - + /* 未激活:明显变灰,无下划线 */ .tab-icon { font-size: 28rpx; color: #9ca3af; font-weight: 400; } - .tab-text { font-size: 28rpx; color: #9ca3af; font-weight: 400; } - + /* 激活:加粗、主色、橙色底部下划线 */ &.active { background: transparent; border-bottom: 3px solid #f97316; color: #f97316; font-weight: 700; - .tab-text { color: #f97316; font-weight: 700; diff --git a/msh_single_uniapp/pages/tool/checkin.vue b/msh_single_uniapp/pages/tool/checkin.vue index eb1c75d..0919f1f 100644 --- a/msh_single_uniapp/pages/tool/checkin.vue +++ b/msh_single_uniapp/pages/tool/checkin.vue @@ -243,12 +243,14 @@ export default { try { const { setSignIntegral, getUserInfo } = await import('@/api/user.js'); const { getUserPoints } = await import('@/api/tool.js'); + // 子问题 A:不得在 API 返回成功前修改 currentPoints,避免打卡前积分提前跳变 - // 打卡接口:GET /api/front/user/sign/integral(setSignIntegral) + // 子问题 B-1:打卡接口为 GET /api/front/user/sign/integral(setSignIntegral),与 /api/front/user/checkin 同属签到类接口 await setSignIntegral(); + this.todaySigned = true; - // 子问题 B:仅用服务端返回的积分更新 currentPoints,禁止前端本地 +30 - // 先 GET /api/front/user(getUserInfo)刷新用户信息,取 integral 赋给 currentPoints + + // 子问题 B-2/B-3:仅用服务端返回值更新积分,禁止前端本地 +30。调用 GET /api/front/user(getUserInfo)刷新用户信息,将返回的 integral 赋给 currentPoints const userRes = await getUserInfo(); if (userRes && userRes.data && (userRes.data.integral != null || userRes.data.points != null)) { this.currentPoints = userRes.data.integral ?? userRes.data.points ?? 0; diff --git a/msh_single_uniapp/pages/tool/food-detail.vue b/msh_single_uniapp/pages/tool/food-detail.vue index 71dfe5b..f2de6ce 100644 --- a/msh_single_uniapp/pages/tool/food-detail.vue +++ b/msh_single_uniapp/pages/tool/food-detail.vue @@ -14,13 +14,13 @@ - + - {{ foodData.name }} + {{ foodData.name || '—' }} - {{ foodData.category }} - {{ foodData.safetyTag }} + {{ foodData.category || '—' }} + {{ foodData.safetyTag || '—' }} @@ -193,6 +193,7 @@ export default { try { const res = await getFoodDetail(id) const data = res.data || res + console.log('[food-detail] getFoodDetail 响应:', data ? { hasName: !!data.name, hasImage: !!data.image, keys: Object.keys(data) } : null) if (data && data.name) { // 解析API返回的食物数据 this.foodData = { @@ -220,6 +221,7 @@ export default { } catch (error) { const errMsg = (error && (error.message || error.msg || error.errMsg || error)) ? String(error.message || error.msg || error.errMsg || error) : '未知错误' console.error('[food-detail] 加载食物数据失败:', error) + console.error('[food-detail] loadError(用于调试):', errMsg) // a. 将 loadError 置为具体错误信息(用于调试) this.loadError = errMsg // b. 使用 defaultFoodData 填充页面,保证用户能看到基础界面;不清空 loadError 以便展示「当前数据来自缓存」提示 @@ -230,6 +232,7 @@ export default { this.foodData.name = decodeURIComponent(String(this.pageParams.name)) } catch (e) {} } + // c. 页面已通过 v-if="loadError" 显示「当前数据来自缓存,可能不是最新」;再弹出轻提示 uni.showToast({ title: '数据加载失败', icon: 'none' diff --git a/msh_single_uniapp/pages/tool/food-encyclopedia.vue b/msh_single_uniapp/pages/tool/food-encyclopedia.vue index ea552d7..2d1a510 100644 --- a/msh_single_uniapp/pages/tool/food-encyclopedia.vue +++ b/msh_single_uniapp/pages/tool/food-encyclopedia.vue @@ -105,7 +105,7 @@ @@ -126,11 +126,11 @@ - {{ nut.label }} - {{ nut.value }} + {{ nut.label || '—' }} + {{ nut.value != null ? nut.value : '—' }} @@ -320,8 +320,12 @@ export default { colorClass: n.colorClass || 'green' })); } else { + // 后端列表仅返回扁平字段,无 nutrition/nutrients 数组,此处组装并始终展示主要项(空值显示 —) nutrition = []; - const push = (label, val, unit) => { if (val != null && val !== '') nutrition.push({ label, value: String(val) + (unit || ''), colorClass: 'green' }); }; + const push = (label, val, unit) => { + const value = (val != null && val !== '') ? String(val) + (unit || '') : '—'; + nutrition.push({ label, value, colorClass: 'green' }); + }; push('能量', item.energy, 'kcal'); push('蛋白质', item.protein, 'g'); push('钾', item.potassium, 'mg'); @@ -330,9 +334,14 @@ export default { push('钙', item.calcium, 'mg'); } + // 后端详情接口仅接受 Long 类型 id;若列表返回的 id 为非数字(如名称),不传 id,避免详情页请求 400 + const rawId = item.id != null ? item.id : item.foodId; + const numericId = (rawId !== undefined && rawId !== null && rawId !== '' && !isNaN(Number(rawId))) + ? (typeof rawId === 'number' ? rawId : Number(rawId)) + : undefined; return { ...item, - id: item.id != null ? item.id : item.foodId, + id: numericId, image, imageUrl: image || undefined, category: item.category || '', diff --git a/msh_single_uniapp/pages/tool/nutrition-knowledge.vue b/msh_single_uniapp/pages/tool/nutrition-knowledge.vue index 524996e..e7fff13 100644 --- a/msh_single_uniapp/pages/tool/nutrition-knowledge.vue +++ b/msh_single_uniapp/pages/tool/nutrition-knowledge.vue @@ -187,7 +187,7 @@ export default { // 有 id 时切换到科普文章 tab,switchTab 内会调用 loadKnowledgeList 加载列表 this.switchTab('articles'); } else { - // 无 id 时默认当前 tab 为「营养素」;用户切换到「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList + // 无 id 时默认当前 tab 为「营养素」,不请求接口;用户点击「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList this.currentTab = 'nutrients'; } }, @@ -215,23 +215,30 @@ export default { page: 1, limit: 50 }); - // 兼容 result.data.list 或 result.data 为数组 + // 兼容 CommonPage:result.data.list,或 result.data/result.data.records 为数组 let rawList = []; if (result && result.data) { if (Array.isArray(result.data.list)) { rawList = result.data.list; + } else if (Array.isArray(result.data.records)) { + rawList = result.data.records; } else if (Array.isArray(result.data)) { rawList = result.data; } } - const list = (rawList || []).map(item => ({ - ...item, - desc: item.desc || item.summary || '', - time: item.time || (item.publishedAt || item.createdAt ? this.formatKnowledgeTime(item.publishedAt || item.createdAt) : ''), - views: item.views != null ? item.views : (item.viewCount != null ? item.viewCount : 0), - icon: item.icon || '📄', - coverImage: item.coverImage || item.cover_image || '' - })); + // Normalize id: backend may return knowledgeId, id, or knowledge_id (BeanUtil/JSON) + const list = (rawList || []).map(item => { + const id = item.knowledgeId ?? item.id ?? item.knowledge_id; + return { + ...item, + id, + desc: item.desc || item.summary || '', + time: item.time || (item.publishedAt || item.createdAt ? this.formatKnowledgeTime(item.publishedAt || item.createdAt) : ''), + views: item.views != null ? item.views : (item.viewCount != null ? item.viewCount : 0), + icon: item.icon || '📄', + coverImage: item.coverImage || item.cover_image || '' + }; + }); if (this.currentTab === 'guide') { this.guideList = list; } else if (this.currentTab === 'articles') { @@ -239,16 +246,16 @@ export default { } } catch (error) { console.error('加载知识列表失败:', error); - const msg = (error && (error.message || error.msg)) || '加载列表失败'; + const msg = (error && (typeof error === 'string' ? error : (error.message || error.msg))) || '加载列表失败'; uni.showToast({ title: String(msg), icon: 'none' }); - // 确保列表始终为数组,不设为 undefined + // 失败时清空当前 tab 列表并确保始终为数组,不设为 undefined if (this.currentTab === 'guide') { - this.guideList = Array.isArray(this.guideList) ? this.guideList : []; + this.guideList = []; } else if (this.currentTab === 'articles') { - this.articleList = Array.isArray(this.articleList) ? this.articleList : []; + this.articleList = []; } } }, @@ -266,13 +273,12 @@ export default { uni.showToast({ title: '暂无详情', icon: 'none' }); return; } - // 兼容后端 knowledgeId / id / knowledge_id + // 确保 knowledgeId 或 id 存在才跳转,否则提示暂无详情 const id = item.knowledgeId ?? item.id ?? item.knowledge_id; - if (id === undefined || id === null || id === '') { + if (id === undefined || id === null || id === '' || (typeof id === 'number' && isNaN(id))) { uni.showToast({ title: '暂无详情', icon: 'none' }); return; } - // 饮食指南、科普文章使用知识详情页(调用 tool/knowledge/detail 接口) uni.navigateTo({ url: `/pages/tool/knowledge-detail?id=${id}` }); diff --git a/msh_single_uniapp/pages/tool/post-detail.vue b/msh_single_uniapp/pages/tool/post-detail.vue index 111cd33..83a988f 100644 --- a/msh_single_uniapp/pages/tool/post-detail.vue +++ b/msh_single_uniapp/pages/tool/post-detail.vue @@ -22,7 +22,7 @@ {{ getMealIcon(postData.mealType) }} - {{ postData.mealType }} + {{ getMealTypeLabel(postData.mealType) }} @@ -44,7 +44,7 @@ {{ getMealIcon(postData.mealType) }} - {{ postData.mealType }} + {{ getMealTypeLabel(postData.mealType) }} @@ -56,7 +56,7 @@ - {{ postData.mealType || '分享' }} + {{ getMealTypeLabel(postData.mealType) }} @@ -129,6 +129,18 @@ + + + + + @@ -234,6 +246,8 @@ import { getCommunityDetail, getCheckinDetail, + getAiNutritionFill, + fillPostNutrition, toggleLike as apiToggleLike, toggleCollect, toggleFollow as apiToggleFollow, @@ -282,7 +296,8 @@ export default { hasMoreComments: true, isLoadingComments: false, showCommentInput: false, - commentText: '' + commentText: '', + aiNutritionFilling: false } }, computed: { @@ -300,6 +315,13 @@ export default { } }, methods: { + // 获取餐次类型中文标签 + getMealTypeLabel(mealType) { + if (!mealType) return '分享' + const map = { breakfast:'早餐', lunch:'午餐', dinner:'晚餐', snack:'加餐', share:'分享', checkin:'打卡' } + return map[String(mealType).toLowerCase()] ?? '分享' + }, + // 加载帖子详情 async loadPostData(id) { this.isLoading = true @@ -312,8 +334,13 @@ export default { this.formatPostData(data) // 若详情接口未返回营养数据且有关联打卡记录,则根据打卡详情补充营养统计(等待完成后再结束加载) - if (this.postData.nutritionStats.length === 0 && (data.checkInRecordId != null)) { - await this.fillNutritionStatsFromCheckin(data.checkInRecordId) + const checkInId = data.checkInRecordId != null ? (Number(data.checkInRecordId) || data.checkInRecordId) : null + if (this.postData.nutritionStats.length === 0 && checkInId != null) { + await this.fillNutritionStatsFromCheckin(checkInId) + } + // T07: 营养仍为空时调用服务端填充接口 + if (this.postData.nutritionStats.length === 0) { + await this.fillNutritionFromServer() } // 同步状态 @@ -344,14 +371,6 @@ export default { */ buildNutritionStatsFromDetailData(data) { if (!data) return [] - console.log('[post-detail] buildNutritionStatsFromDetailData API response data:', JSON.stringify({ - nutritionStats: data.nutritionStats, - nutrition_stats: data.nutrition_stats, - nutritionDataJson: data.nutritionDataJson, - nutrition_data_json: data.nutrition_data_json, - dietaryData: data.dietaryData, - mealData: data.mealData - })) // 1) 后端直接返回的 stat 数组(兼容不同命名) const rawStats = data.nutritionStats || data.nutrition_stats @@ -362,23 +381,21 @@ export default { })).filter(s => s.label) } - // 2) nutritionDataJson / nutrition_data_json(兼容后端驼峰与下划线;含打卡字段 actualEnergy/actualProtein) + // 2) nutritionDataJson / nutrition_data_json(兼容后端驼峰与下划线;含 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg、打卡字段 actualEnergy/actualProtein) const jsonRaw = data.nutritionDataJson || data.nutrition_data_json if (jsonRaw) { try { const nutritionData = typeof jsonRaw === 'string' ? JSON.parse(jsonRaw) : jsonRaw - if (nutritionData && typeof nutritionData === 'object') { - const cal = nutritionData.calories ?? nutritionData.energy ?? nutritionData.calorie ?? nutritionData.actualEnergy - const pro = nutritionData.protein ?? nutritionData.proteins ?? nutritionData.actualProtein - const pot = nutritionData.potassium ?? nutritionData.k - const pho = nutritionData.phosphorus ?? nutritionData.p - return [ - { label: '热量(kcal)', value: cal != null ? String(cal) : '-' }, - { label: '蛋白质', value: this.formatNutritionValue(pro, 'g') }, - { label: '钾', value: pot != null ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' }, - { label: '磷', value: pho != null ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' } - ] + if (!nutritionData || typeof nutritionData !== 'object') return [] + // 2a) 若为数组格式 [{label, value}, ...],直接使用 + if (Array.isArray(nutritionData) && nutritionData.length > 0) { + return nutritionData.map(s => ({ + label: s.label || s.name || '', + value: s.value != null ? String(s.value) : '-' + })).filter(s => s.label) } + // 2b) 对象格式:兼容后端 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg 及常见命名 + return this.buildNutritionStatsFromNutritionObject(nutritionData) } catch (e) { // ignore } @@ -388,23 +405,36 @@ export default { const dietary = data.dietaryData || data.dietary_data || data.mealData || data.meal_data if (dietary) { const obj = typeof dietary === 'string' ? (() => { try { return JSON.parse(dietary) } catch (_) { return null } })() : dietary - if (obj && typeof obj === 'object') { - const cal = obj.calories ?? obj.energy ?? obj.calorie ?? obj.actualEnergy - const pro = obj.protein ?? obj.proteins ?? obj.actualProtein - const pot = obj.potassium ?? obj.k - const pho = obj.phosphorus ?? obj.p - return [ - { label: '热量(kcal)', value: cal != null ? String(cal) : '-' }, - { label: '蛋白质', value: this.formatNutritionValue(pro, 'g') }, - { label: '钾', value: pot != null ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' }, - { label: '磷', value: pho != null ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' } - ] + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + const statsFromObj = this.buildNutritionStatsFromNutritionObject(obj) + if (statsFromObj.length > 0) return statsFromObj } } + // 4) data 本身为营养对象(如 fill-nutrition 接口直接返回的 { energyKcal, proteinG, potassiumMg, phosphorusMg }) + if (data && typeof data === 'object' && !Array.isArray(data) && (data.energyKcal != null || data.proteinG != null || data.actualEnergy != null || data.actualProtein != null || data.calories != null || data.protein != null)) { + const statsFromData = this.buildNutritionStatsFromNutritionObject(data) + if (statsFromData.length > 0) return statsFromData + } + return [] }, + /** 从单一营养对象构建 [{label, value}, ...],统一兼容 energyKcal/proteinG/potassiumMg/phosphorusMg 及 calories/protein 等命名 */ + buildNutritionStatsFromNutritionObject(obj) { + if (!obj || typeof obj !== 'object') return [] + const cal = obj.energyKcal ?? obj.calories ?? obj.energy ?? obj.calorie ?? obj.actualEnergy + const pro = obj.proteinG ?? obj.protein ?? obj.proteins ?? obj.actualProtein + const pot = obj.potassiumMg ?? obj.potassium ?? obj.k + const pho = obj.phosphorusMg ?? obj.phosphorus ?? obj.p + return [ + { label: '热量(kcal)', value: cal != null ? String(cal) : '-' }, + { label: '蛋白质', value: this.formatNutritionValue(pro, 'g') }, + { label: '钾', value: pot != null ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' }, + { label: '磷', value: pho != null ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' } + ] + }, + /** 格式化营养素显示值(兼容 number/string/BigDecimal 等) */ formatNutritionValue(val, unit) { if (val == null || val === '') return '-' @@ -443,6 +473,60 @@ export default { } }, + /** + * T07: 调用服务端 fill-nutrition 接口,根据帖子内容补充营养并更新本地展示 + */ + async fillNutritionFromServer() { + if (!this.postId) return + try { + const res = await fillPostNutrition(this.postId) + const data = res.data || res + if (!data) return + const stats = this.buildNutritionStatsFromDetailData(data) + if (stats.length > 0) { + this.postData.nutritionStats = stats + } + } catch (e) { + console.warn('服务端填充帖子营养失败:', e) + } + }, + + /** + * T08/T09: 用 AI 根据帖子描述估算营养并填充 nutritionStats + */ + async fillNutritionByAi() { + const text = (this.postData.description || this.postData.title || '').trim() + if (!text) { + uni.showToast({ title: '暂无描述可估算', icon: 'none' }) + return + } + this.aiNutritionFilling = true + try { + const res = await getAiNutritionFill(text) + const data = (res && res.data) ? res.data : res + if (!data || typeof data !== 'object') { + uni.showToast({ title: 'AI 估算暂无结果', icon: 'none' }) + return + } + const energy = data.energyKcal != null ? String(data.energyKcal) : '-' + const protein = this.formatNutritionValue(data.proteinG, 'g') + const potassium = data.potassiumMg != null ? String(data.potassiumMg) + 'mg' : '-' + const phosphorus = data.phosphorusMg != null ? String(data.phosphorusMg) + 'mg' : '-' + this.postData.nutritionStats = [ + { label: '热量(kcal)', value: energy }, + { label: '蛋白质', value: protein }, + { label: '钾', value: potassium }, + { label: '磷', value: phosphorus } + ] + uni.showToast({ title: '已补充营养估算', icon: 'success' }) + } catch (e) { + console.warn('AI 营养估算失败:', e) + uni.showToast({ title: '估算失败,请重试', icon: 'none' }) + } finally { + this.aiNutritionFilling = false + } + }, + // 格式化帖子数据 formatPostData(data) { // 解析图片 diff --git a/msh_single_uniapp/pages/tool/recipe-detail.vue b/msh_single_uniapp/pages/tool/recipe-detail.vue index 1842385..c6263fa 100644 --- a/msh_single_uniapp/pages/tool/recipe-detail.vue +++ b/msh_single_uniapp/pages/tool/recipe-detail.vue @@ -214,7 +214,7 @@ - ❤️ + {{ isLiked ? '❤️' : '🤍' }} @@ -227,7 +227,7 @@