From 77632510cfe923fe25149349d2fb59e09158e6b2 Mon Sep 17 00:00:00 2001 From: msh-agent Date: Sun, 12 Apr 2026 09:31:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DTool=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=97=AE=E9=A2=98=20-=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=AD=BE=E5=88=B0=E3=80=81=E7=A4=BE=E5=8C=BA=E3=80=81=E9=A3=9F?= =?UTF-8?q?=E7=89=A9=E5=92=8CAI=E6=9C=8D=E5=8A=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- msh_crmeb_22/README.md | 4 +- .../zbkj/front/controller/ToolController.java | 4 +- .../zbkj/front/controller/UserController.java | 23 + .../impl/tool/ToolCheckinServiceImpl.java | 28 +- .../impl/tool/ToolCommunityServiceImpl.java | 5 + .../impl/tool/ToolFoodServiceImpl.java | 4 + .../impl/tool/ToolKieAIServiceImpl.java | 258 +++- msh_single_uniapp/api/tool.js | 51 +- msh_single_uniapp/api/user.js | 23 +- msh_single_uniapp/config/app.js | 4 +- msh_single_uniapp/pages/tool/checkin.vue | 165 +- msh_single_uniapp/pages/tool/food-detail.vue | 1345 +++++++++-------- 12 files changed, 1158 insertions(+), 756 deletions(-) diff --git a/msh_crmeb_22/README.md b/msh_crmeb_22/README.md index d038e1e..024e5fd 100644 --- a/msh_crmeb_22/README.md +++ b/msh_crmeb_22/README.md @@ -1,11 +1,11 @@ # 环境 -1. Java Jdk1.8 +1. Java Jdk1.8/JDK17 2. Redis 5+ 3. Mysql 5.7+ # Java项目框架 1. SpringBoot 2.2.6.RELEASE -2. Maven 3.6.1 +2. Maven 3.9.1 3. Swagger 2.9.2 4. Mybatis Plus 3.3.1 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 8635fdd..7dd7e14 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 @@ -474,7 +474,9 @@ public class ToolController { @ApiOperation(value = "关注/取消关注用户") @PostMapping("/community/follow") public CommonResult toggleFollow(@RequestBody Map data) { - toolCommunityService.toggleFollow((Long) data.get("userId"), (Boolean) data.get("isFollow")); + Long userId = ((Number) data.get("userId")).longValue(); + Boolean isFollow = (Boolean) data.get("isFollow"); + toolCommunityService.toggleFollow(userId, isFollow); return CommonResult.success("操作成功"); } diff --git a/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/UserController.java b/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/UserController.java index f9b76e0..4fcfd9f 100644 --- a/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/UserController.java +++ b/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/UserController.java @@ -11,8 +11,10 @@ import com.zbkj.common.request.*; import com.zbkj.common.response.*; import com.zbkj.common.result.CommonResult; import com.zbkj.front.service.UserCenterService; +import com.zbkj.common.vo.SystemGroupDataSignConfigVo; import com.zbkj.service.service.SystemGroupDataService; import com.zbkj.service.service.UserService; +import com.zbkj.service.service.UserSignService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiImplicitParam; import io.swagger.annotations.ApiOperation; @@ -53,6 +55,9 @@ public class UserController { @Autowired private UserCenterService userCenterService; + @Autowired + private UserSignService userSignService; + /** * 修改密码 */ @@ -83,6 +88,24 @@ public class UserController { return CommonResult.success(userService.getUserCenter()); } + /** + * 用户信息(含积分,用于打卡等操作后刷新积分) + */ + @ApiOperation(value = "用户信息-含积分") + @RequestMapping(value = "/user/info", method = RequestMethod.GET) + public CommonResult getUserInfo() { + return CommonResult.success(userService.getUserCenter()); + } + + /** + * 每日打卡领积分(与 GET /user/sign/integral 相同,供前端 /user/checkin 调用) + */ + @ApiOperation(value = "每日打卡") + @RequestMapping(value = "/user/checkin", method = RequestMethod.GET) + public CommonResult userDailyCheckin() { + return CommonResult.success(userSignService.sign()); + } + /** * 换绑手机号校验 */ diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCheckinServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCheckinServiceImpl.java index 3311a3d..e0196dc 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCheckinServiceImpl.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCheckinServiceImpl.java @@ -712,20 +712,28 @@ public class ToolCheckinServiceImpl implements ToolCheckinService { String mealTypeLabel = getMealTypeLabel(sign.getMealType()); String title = mealTypeLabel + "打卡"; - // 5. 准备营养数据JSON(如果有AI分析结果) + // 5. 准备营养数据 JSON:有 AI 结果时写入识别串;无论是否有 AI,都写入打卡汇总(热量/蛋白质),供社区详情营养卡展示 String nutritionDataJson = null; - if (StrUtil.isNotBlank(sign.getAiRecognizedFoodsJson())) { - try { - Map nutritionData = new HashMap<>(); + try { + Map nutritionData = new HashMap<>(); + if (sign.getActualEnergy() != null) { + nutritionData.put("actualEnergy", sign.getActualEnergy()); + } + if (sign.getActualProtein() != null) { + nutritionData.put("actualProtein", sign.getActualProtein()); + } + if (sign.getNutritionScore() != null) { + nutritionData.put("nutritionScore", sign.getNutritionScore()); + } + if (StrUtil.isNotBlank(sign.getAiRecognizedFoodsJson())) { nutritionData.put("aiRecognizedFoods", sign.getAiRecognizedFoodsJson()); nutritionData.put("aiStatus", sign.getAiRecognitionStatus()); - nutritionData.put("actualProtein", sign.getActualProtein()); - nutritionData.put("actualEnergy", sign.getActualEnergy()); - nutritionData.put("nutritionScore", sign.getNutritionScore()); - nutritionDataJson = com.alibaba.fastjson.JSON.toJSONString(nutritionData); - } catch (Exception e) { - log.warn("生成营养数据JSON失败: {}", e.getMessage()); } + if (!nutritionData.isEmpty()) { + nutritionDataJson = com.alibaba.fastjson.JSON.toJSONString(nutritionData); + } + } catch (Exception e) { + log.warn("生成营养数据JSON失败: {}", e.getMessage()); } // 6. 解析封面图 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 6acfd21..2ddaaa6 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 @@ -300,6 +300,11 @@ public class ToolCommunityServiceImpl implements ToolCommunityService { UserSign userSign = userSignService.getOne(signLqw); if (userSign != null) { map.put("mealType", userSign.getMealType()); + // 帖子详情页营养卡:无 nutritionDataJson 时前端仍可从顶层 actualEnergy/actualProtein 构建统计 + map.put("actualEnergy", userSign.getActualEnergy()); + map.put("actualProtein", userSign.getActualProtein()); + // 与打卡详情接口字段对齐:仅有 AI 菜品识别、无 actual 汇总时,前端可聚合营养统计 + map.put("aiResult", userSign.getAiRecognizedFoodsJson()); map.put("enableAIVideo", userSign.getEnableAiVideo() != null && userSign.getEnableAiVideo() == 1); log.info("找到打卡记录,checkInRecordId: {}, taskId: {}, enableAIVideo: {}", diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolFoodServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolFoodServiceImpl.java index 60cd763..914292b 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolFoodServiceImpl.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolFoodServiceImpl.java @@ -69,7 +69,10 @@ public class ToolFoodServiceImpl implements ToolFoodService { map.put("protein", food.getProtein()); map.put("potassium", food.getPotassium()); map.put("phosphorus", food.getPhosphorus()); + map.put("sodium", food.getSodium()); + map.put("calcium", food.getCalcium()); map.put("suitabilityLevel", food.getSuitabilityLevel()); + map.put("nutrientsJson", food.getNutrientsJson()); result.add(map); } return result; @@ -124,6 +127,7 @@ public class ToolFoodServiceImpl implements ToolFoodService { map.put("vitaminC", food.getVitaminC()); map.put("purine", food.getPurine()); map.put("servingSize", food.getServingSize()); + map.put("nutrientsJson", food.getNutrientsJson()); return map; } diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKieAIServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKieAIServiceImpl.java index 036448d..605953f 100644 --- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKieAIServiceImpl.java +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKieAIServiceImpl.java @@ -304,7 +304,7 @@ public class ToolKieAIServiceImpl implements ToolKieAIService { String url = config.getBaseUrl() + GEMINI_CHAT_PATH; Map body = buildGeminiRequestBody(request, false); HttpHeaders headers = helper.createHeaders(); - HttpEntity entity = new HttpEntity<>(helper.toJsonString(body), headers); + HttpEntity entity = new HttpEntity<>(toGeminiUpstreamJson(body), headers); RestTemplate client = restTemplateWithLongTimeout(); ResponseEntity response = client.exchange(url, HttpMethod.POST, entity, String.class); if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { @@ -314,7 +314,181 @@ public class ToolKieAIServiceImpl implements ToolKieAIService { if (result == null) { throw new RuntimeException("Gemini Chat 响应解析失败"); } - return result; + // 上游可能将 OpenAI 形态包在 data / result 等字段内,解包后再规范化,避免前端拿不到 choices + result = unwrapUpstreamChatCompletionMap(result); + Map normalized = normalizeToOpenAiChoicesFormat(result); + flattenChoicesMessageContentForClient(normalized); + return normalized; + } + + /** + * BUG-005:将 choices[0].message.content 统一压成字符串,便于前端只读 data.choices[0].message.content 展示; + * 避免上游返回 List/Map 嵌套时前端解析为空却误判为「无有效回复」。 + */ + @SuppressWarnings("unchecked") + private void flattenChoicesMessageContentForClient(Map result) { + Object choicesObj = result.get("choices"); + if (!(choicesObj instanceof List) || ((List) choicesObj).isEmpty()) { + return; + } + Object c0 = ((List) choicesObj).get(0); + if (!(c0 instanceof Map)) { + return; + } + Object msg = ((Map) c0).get("message"); + if (!(msg instanceof Map)) { + return; + } + Map message = (Map) msg; + Object raw = message.get("content"); + Object flat = flattenGeminiMessageContent(raw); + message.put("content", flat != null ? flat : ""); + } + + /** + * 上游偶发返回 Google 风格 {@code candidates} 而非 OpenAI 风格 {@code choices}, + * 或 {@code choices} 为空但 {@code candidates} 有正文;规范为 choices[0].message.content, + * 便于前端 BUG-005 统一从 data.choices[0].message.content 取值。 + */ + @SuppressWarnings("unchecked") + private Map normalizeToOpenAiChoicesFormat(Map result) { + Object choicesObj = result.get("choices"); + if (hasNonEmptyFirstChoiceAssistantContent(choicesObj)) { + return result; + } + Object candidatesObj = result.get("candidates"); + if (!(candidatesObj instanceof List) || ((List) candidatesObj).isEmpty()) { + return result; + } + Object c0 = ((List) candidatesObj).get(0); + if (!(c0 instanceof Map)) { + return result; + } + Map candidate = (Map) c0; + Object flatContent = flattenGeminiMessageContent(candidate.get("content")); + if (isBlankAssistantContent(flatContent)) { + Object t = candidate.get("text"); + if (t != null) { + flatContent = t.toString(); + } + } + Map message = new HashMap<>(); + message.put("role", "assistant"); + message.put("content", flatContent != null ? flatContent : ""); + Map choice = new HashMap<>(); + choice.put("message", message); + List newChoices = new ArrayList<>(); + newChoices.add(choice); + Map out = new LinkedHashMap<>(result); + out.put("choices", newChoices); + return out; + } + + private boolean hasNonEmptyFirstChoiceAssistantContent(Object choicesObj) { + if (!(choicesObj instanceof List) || ((List) choicesObj).isEmpty()) { + return false; + } + Object c0 = ((List) choicesObj).get(0); + if (!(c0 instanceof Map)) { + return false; + } + Object msg = ((Map) c0).get("message"); + if (!(msg instanceof Map)) { + return false; + } + return !isBlankAssistantContent(((Map) msg).get("content")); + } + + /** + * KieAI 网关偶发返回 { "data": { "choices": [...] } } 等与 OpenAI 兼容体嵌套结构; + * 若根上无有效 choices/candidates,则尝试取内层 Map(含非空 choices 或非空 candidates)。 + */ + @SuppressWarnings("unchecked") + private Map unwrapUpstreamChatCompletionMap(Map root) { + if (root == null) { + return null; + } + if (hasUsableChoicesOrCandidates(root)) { + return root; + } + String[] keys = {"data", "result", "output"}; + for (String key : keys) { + Object nested = root.get(key); + if (!(nested instanceof Map)) { + continue; + } + Map m = (Map) nested; + if (hasUsableChoicesOrCandidates(m)) { + return m; + } + Object inner = m.get("data"); + if (inner instanceof Map) { + Map innerMap = (Map) inner; + if (hasUsableChoicesOrCandidates(innerMap)) { + return innerMap; + } + } + } + return root; + } + + private boolean hasUsableChoicesOrCandidates(Map m) { + Object choices = m.get("choices"); + if (choices instanceof List && !((List) choices).isEmpty()) { + return true; + } + Object candidates = m.get("candidates"); + return candidates instanceof List && !((List) candidates).isEmpty(); + } + + private boolean isBlankAssistantContent(Object content) { + if (content == null) { + return true; + } + if (content instanceof String) { + return ((String) content).trim().isEmpty(); + } + if (content instanceof List) { + return ((List) content).isEmpty(); + } + if (content instanceof Map) { + return ((Map) content).isEmpty(); + } + return false; + } + + @SuppressWarnings("unchecked") + private Object flattenGeminiMessageContent(Object contentObj) { + if (contentObj == null) { + return ""; + } + if (contentObj instanceof String) { + return contentObj; + } + if (contentObj instanceof List) { + StringBuilder sb = new StringBuilder(); + for (Object part : (List) contentObj) { + if (part instanceof Map) { + Object t = ((Map) part).get("text"); + if (t != null) { + sb.append(t.toString()); + } + } + } + return sb.toString(); + } + if (contentObj instanceof Map) { + Map m = (Map) contentObj; + Object parts = m.get("parts"); + if (parts instanceof List) { + return flattenGeminiMessageContent(parts); + } + Object text = m.get("text"); + if (text != null) { + return text; + } + } + return contentObj.toString(); } private RestTemplate restTemplateWithLongTimeout() { @@ -341,7 +515,7 @@ public class ToolKieAIServiceImpl implements ToolKieAIService { .build(); RequestBody requestBody = RequestBody.create( okhttp3.MediaType.parse("application/json; charset=utf-8"), - helper.toJsonString(body)); + toGeminiUpstreamJson(body)); Request okRequest = new Request.Builder() .url(url) .post(requestBody) @@ -390,68 +564,26 @@ public class ToolKieAIServiceImpl implements ToolKieAIService { return emitter; } - /** BUG-005: 仅使用 request.getMessages() 透传用户/助手消息,不注入硬编码 prompt */ + /** 发往 KieAI Gemini 的请求体 JSON(含 FastJSON 解析的 messages,需用 FastJSON 序列化) */ + private static String toGeminiUpstreamJson(Map body) { + return com.alibaba.fastjson.JSON.toJSONString(body); + } + + /** + * BUG-005:仅透传 request.getMessages(),不注入系统 prompt。 + * 经 FastJSON 往返序列化,保证多模态等嵌套结构与客户端一致(避免 Jackson 再写出时丢字段)。 + */ private Map buildGeminiRequestBody(KieAIGeminiChatRequest request, boolean stream) { - Map body = new HashMap<>(); - List> messagesOut = new ArrayList<>(); - for (KieAIGeminiChatRequest.Message msg : request.getMessages()) { - Map m = new HashMap<>(); - m.put("role", msg.getRole()); - Object content = msg.getContent(); - if (content instanceof String) { - List> parts = new ArrayList<>(); - Map textPart = new HashMap<>(); - textPart.put("type", "text"); - textPart.put("text", content); - parts.add(textPart); - m.put("content", parts); - } else if (content instanceof List) { - @SuppressWarnings("unchecked") - List list = (List) content; - List> parts = new ArrayList<>(); - for (Object item : list) { - Map part = new HashMap<>(); - if (item instanceof KieAIGeminiChatRequest.ContentItem) { - KieAIGeminiChatRequest.ContentItem ci = (KieAIGeminiChatRequest.ContentItem) item; - part.put("type", ci.getType()); - if ("text".equals(ci.getType())) { - part.put("text", ci.getText()); - } else if ("image_url".equals(ci.getType()) && ci.getImageUrl() != null) { - Map iu = new HashMap<>(); - iu.put("url", ci.getImageUrl().getUrl()); - part.put("image_url", iu); - } - parts.add(part); - } else if (item instanceof Map) { - @SuppressWarnings("unchecked") - Map map = (Map) item; - String t = (String) map.get("type"); - if ("text".equals(t)) { - part.put("type", "text"); - part.put("text", map.get("text")); - parts.add(part); - } else if ("image_url".equals(t)) { - Object iu = map.get("image_url"); - if (iu instanceof Map) { - String url = (String) ((Map) iu).get("url"); - if (url != null) { - part.put("type", "image_url"); - Map iuOut = new HashMap<>(); - iuOut.put("url", url); - part.put("image_url", iuOut); - parts.add(part); - } - } - } - } - } - m.put("content", parts); - } else { - m.put("content", content); - } - messagesOut.add(m); + if (request.getMessages() == null || request.getMessages().isEmpty()) { + throw new IllegalArgumentException("messages 不能为空"); } - body.put("messages", messagesOut); + Map body = new HashMap<>(); + // BUG-005:仅用 request.getMessages() 透传;用 FastJSON 序列化再 parse,与上游 toJSONString 一致,避免 Jackson 对 Object content 往返变形 + String messagesJson = com.alibaba.fastjson.JSON.toJSONString(request.getMessages()); + if (messagesJson == null || messagesJson.isEmpty() || "null".equals(messagesJson)) { + throw new IllegalArgumentException("messages 序列化失败"); + } + body.put("messages", com.alibaba.fastjson.JSON.parse(messagesJson)); body.put("stream", stream); if (request.getIncludeThoughts() != null) { body.put("include_thoughts", request.getIncludeThoughts()); diff --git a/msh_single_uniapp/api/tool.js b/msh_single_uniapp/api/tool.js index bf3c47d..05de7bf 100644 --- a/msh_single_uniapp/api/tool.js +++ b/msh_single_uniapp/api/tool.js @@ -119,8 +119,8 @@ export function getCheckinList(data) { * 获取打卡记录详情 * @param {Number} id - 打卡记录ID */ -export function getCheckinDetail(id) { - return request.get('tool/checkin/detail/' + id); +export function getCheckinDetail(id, opt) { + return request.get('tool/checkin/detail/' + id, {}, opt || {}); } /** @@ -196,7 +196,7 @@ export function shareCheckinToCommunity(checkinId) { * @param {Number} data.limit - 每页数量 */ export function searchFood(data) { - return request.get('tool/food/search', data); + return request.get('tool/food/search', data, { noAuth: true }); } /** @@ -205,9 +205,31 @@ export function searchFood(data) { * @param {String} data.category - 分类:all/grain/vegetable/fruit/meat/seafood * @param {Number} data.page - 页码 * @param {Number} data.limit - 每页数量 + * + * 响应 data 为分页:list[] 项含 image、nutrientsJson、energy、protein、钾/磷/钠/钙、suitabilityLevel(ToolFoodServiceImpl) */ export function getFoodList(data) { - return request.get('tool/food/list', data); + return request.get('tool/food/list', data, { noAuth: true }); +} + +/** 将列表/路由上的 id 规范为整数字符串(兼容 Long 序列化为 123、123.0、"123.0" 等,拒绝小数与非数字) */ +export function normalizeFoodDetailIdString(v) { + if (v === undefined || v === null || v === '') return ''; + let input = v; + if (typeof input === 'string') { + input = input.trim(); + if (input === '') return ''; + } + const n = Number(input); + if (Number.isFinite(n)) { + const t = Math.trunc(n); + if (Math.abs(n - t) > 1e-6) return ''; + return String(t); + } + const s = String(input).trim(); + if (s === '') return ''; + if (/^-?\d+$/.test(s)) return s; + return ''; } /** @@ -215,14 +237,18 @@ export function getFoodList(data) { * @param {Number|String} id - 食物ID(必须为数字,不能传名称) */ export function getFoodDetail(id) { - const numId = typeof id === 'number' && !isNaN(id) ? id : parseInt(String(id), 10); - // 打印请求参数便于确认(后端仅接受 Long 类型 id,传 name 会 400) - const apiPath = 'tool/food/detail/' + numId; - console.log('[api/tool] getFoodDetail 请求参数:', { id, numId, type: typeof id, apiPath }); - if (isNaN(numId)) { + const rawId = normalizeFoodDetailIdString(id); + // 严格按整型 ID 校验,避免 parseInt('123abc') 这类误判;路径用字符串拼接避免超过 JS 安全整数时 Number() 精度丢失 + const isIntegerId = rawId !== '' && /^-?\d+$/.test(rawId); + const apiPath = 'tool/food/detail/' + rawId; + const numId = isIntegerId ? Number(rawId) : NaN; + // 打印请求参数便于确认(后端仅接受 Long 类型 id,传 name 会导致 400 NumberFormatException) + console.log('[api/tool] getFoodDetail 请求参数:', { id, rawId, numId, idType: typeof id, apiPath, isNumeric: !isNaN(numId) }); + if (!isIntegerId) { return Promise.reject(new Error('食物详情接口需要数字ID,当前传入: ' + id)); } - return request.get(apiPath); + // 与食物百科列表一致:只读接口免登录,避免未带 token 时请求被拦截导致详情页空白/仅 toast + return request.get(apiPath, {}, { noAuth: true }); } /** @@ -244,7 +270,8 @@ export function getSimilarFoods(foodId) { * @param {Number} data.limit - 每页数量 */ export function getKnowledgeList(data) { - return request.get('tool/knowledge/list', data); + // 与后端 /api/front/tool/** 免登录一致,未登录用户也可浏览饮食指南/科普文章 + return request.get('tool/knowledge/list', data, { noAuth: true }); } /** @@ -252,7 +279,7 @@ export function getKnowledgeList(data) { * @param {Number} id - 知识ID */ export function getKnowledgeDetail(id) { - return request.get('tool/knowledge/detail/' + id); + return request.get('tool/knowledge/detail/' + id, {}, { noAuth: true }); } /** diff --git a/msh_single_uniapp/api/user.js b/msh_single_uniapp/api/user.js index b203284..6b80295 100644 --- a/msh_single_uniapp/api/user.js +++ b/msh_single_uniapp/api/user.js @@ -21,9 +21,10 @@ export function getUserInfo(){ /** * 获取用户信息(GET /api/front/user/info,用于打卡后刷新积分等) + * @param {Object} [query] - 可选查询参数(如 { _: Date.now() } 避免缓存陈旧积分) */ -export function getFrontUserInfo(){ - return request.get('user/info'); +export function getFrontUserInfo(query) { + return request.get('user/info', query || {}); } /** @@ -120,12 +121,26 @@ export function getSignGet() { } /** - * 用户签到 -*/ + * 用户签到(每日打卡领积分) + * 对应 GET /api/front/user/sign/integral,服务端累加积分;无 /user/checkin 时与此等价。 + */ export function setSignIntegral(){ return request.get('user/sign/integral') } +/** + * 用户打卡(优先接口) + * 对应 /api/front/user/checkin(如后端未实现可回退至 user/sign/integral) + */ +export function userCheckin() { + return request.get('user/checkin'); +} + +/** 与 setSignIntegral 相同,语义为「每日打卡」 */ +export function userCheckinDaily() { + return request.get('user/sign/integral'); +} + /** * 签到列表(年月) * @param object data diff --git a/msh_single_uniapp/config/app.js b/msh_single_uniapp/config/app.js index c5d83a5..b8caa5f 100644 --- a/msh_single_uniapp/config/app.js +++ b/msh_single_uniapp/config/app.js @@ -2,9 +2,9 @@ // | // +---------------------------------------------------------------------- // 移动端商城API -let domain = 'http://127.0.0.1:20822' +// let domain = 'http://127.0.0.1:20822' // let domain = 'https://chenyin.uj345.cc' -// let domain = 'https://sophia-shop.uj345.cc' +let domain = 'https://sophia-shop.uj345.cc' module.exports = { domain, diff --git a/msh_single_uniapp/pages/tool/checkin.vue b/msh_single_uniapp/pages/tool/checkin.vue index 6fdcbc4..7a87bf4 100644 --- a/msh_single_uniapp/pages/tool/checkin.vue +++ b/msh_single_uniapp/pages/tool/checkin.vue @@ -113,7 +113,13 @@ export default { data() { return { todaySigned: false, - currentPoints: 522, + /** 防止 loadCheckinData 与打卡后刷新并发时,旧 user/info 响应覆盖新积分 */ + _userInfoFetchGen: 0, + /** 打卡流程进行中:禁止 onShow 触发的 loadCheckinData 用「打卡前」发出的 user/info 写 currentPoints */ + _suppressStalePointsLoad: false, + /** 防止连点导致多次打卡请求与积分展示乱序 */ + _checkinSubmitting: false, + currentPoints: 0, streakDays: [ { day: 1, completed: false, special: false }, { day: 2, completed: false, special: false }, @@ -183,17 +189,25 @@ export default { this.loadCheckinData(); }, onShow() { - // 页面显示时刷新数据 + // 页面显示时刷新数据(打卡请求进行中时跳过,避免与 handleCheckin 内刷新竞态) + if (this._checkinSubmitting) { + return; + } this.loadCheckinData(); }, methods: { - async loadCheckinData() { + async loadCheckinData(options = {}) { + const includePointsRefresh = !!options.includePointsRefresh; + const skipPoints = includePointsRefresh + ? false + : (!!options.skipPoints || this._suppressStalePointsLoad); + const pointsGen = skipPoints ? null : ++this._userInfoFetchGen; try { - const { getUserPoints, getCheckinStreak, getCheckinTasks } = await import('@/api/tool.js'); - const { getSignGet } = await import('@/api/user.js'); + const { getCheckinStreak, getCheckinTasks } = await import('@/api/tool.js'); + const { getSignGet, getFrontUserInfo } = await import('@/api/user.js'); - const [pointsRes, streakRes, tasksRes, signRes] = await Promise.all([ - getUserPoints().catch(() => ({ data: { points: 0 } })), + const [userInfoRes, streakRes, tasksRes, signRes] = await Promise.all([ + skipPoints ? Promise.resolve(null) : getFrontUserInfo().catch(() => null), getCheckinStreak().catch(() => ({ data: { streakDays: 7, currentStreak: 0 } })), getCheckinTasks().catch(() => ({ data: { tasks: [] } })), getSignGet().catch(() => ({ data: { today: false } })) @@ -203,8 +217,14 @@ export default { this.todaySigned = true; } - if (pointsRes.data) { - this.currentPoints = pointsRes.data.totalPoints ?? pointsRes.data.points ?? 0; + // BUG-001-A:打卡请求进行中时,勿用并行的 user/info 回写积分(避免先显示旧分再跳变);显式 includePointsRefresh 时仍刷新 + const allowPointsFromThisLoad = + !this._checkinSubmitting || includePointsRefresh; + if (!skipPoints && userInfoRes && pointsGen === this._userInfoFetchGen && allowPointsFromThisLoad) { + const p = this._parsePointsFromUserInfo(userInfoRes); + if (p != null && !Number.isNaN(Number(p))) { + this.currentPoints = Number(p); + } } if (streakRes.data) { @@ -235,68 +255,107 @@ export default { url: '/pages/tool/points-rules' }) }, + /** + * BUG-001-A:打卡 API 成功前不得修改 currentPoints(禁止本地 +30 或与跳转前的乐观更新)。 + * BUG-001-B:仅 GET /api/front/user/info(getFrontUserInfo)解析账户 integral;打卡接口返回的 integral 为当日奖励分值,绝非总积分。 + */ async handleCheckin() { + if (this._checkinSubmitting) { + return; + } if (this.todaySigned) { uni.showToast({ title: '今日已打卡', icon: 'none' }); return; } + this._checkinSubmitting = true; + this._suppressStalePointsLoad = true; + const pointsBeforeCheckin = Number(this.currentPoints) || 0; + const { userCheckin, userCheckinDaily, getFrontUserInfo } = await import('@/api/user.js'); + uni.showLoading({ title: '打卡中...', mask: true }); try { - const { setSignIntegral, getFrontUserInfo, getUserInfo } = await import('@/api/user.js'); - const { getUserPoints } = await import('@/api/tool.js'); - - // 子问题 A:在 API 返回成功前不得修改 currentPoints,避免打卡前积分提前跳变(禁止前端本地 +30 等) - // 打卡接口:GET /api/front/user/sign/integral(setSignIntegral),即本项目的签到/打卡接口 - await setSignIntegral(); - - // 子问题 B:打卡成功后必须用服务端数据更新积分。发 GET /api/front/user/info 刷新用户积分,禁止硬编码 +30 - const serverPoints = await this._fetchServerPoints(getFrontUserInfo, getUserInfo, getUserPoints); - if (serverPoints != null) { - this.currentPoints = Number(serverPoints); + // 1) /api/front/user/checkin,失败时回退 /api/front/user/sign/integral;二者均不读取 body 更新积分 + await this._requestCheckin(userCheckin, userCheckinDaily); + // 2) 成功后必须 GET user/info,用服务端账户积分更新展示 + let refreshed = await this._refreshCurrentPointsFromUserInfo(getFrontUserInfo).catch((err) => { + console.warn('打卡后刷新积分失败:', err); + return false; + }); + if (refreshed && Number(this.currentPoints) === pointsBeforeCheckin) { + await new Promise((r) => setTimeout(r, 250)); + refreshed = + (await this._refreshCurrentPointsFromUserInfo(getFrontUserInfo).catch(() => false)) || refreshed; + } + if (!refreshed) { + await this.loadCheckinData({ includePointsRefresh: true }); } - this.todaySigned = true; + await this.loadCheckinData({ skipPoints: true }); + uni.navigateTo({ + url: '/pages/tool/checkin-publish' + }); } catch (e) { const msg = (typeof e === 'string' ? e : (e && (e.message || e.msg))) || '打卡失败'; if (msg.includes('今日已签到') || msg.includes('不可重复')) { this.todaySigned = true; + await this.loadCheckinData({ skipPoints: true }); + } else { + await this.loadCheckinData({ includePointsRefresh: true }).catch(() => {}); } uni.showToast({ title: msg, icon: 'none' }); return; + } finally { + this._suppressStalePointsLoad = false; + this._checkinSubmitting = false; + uni.hideLoading(); } - uni.navigateTo({ - url: '/pages/tool/checkin-publish' - }); }, - /** - * 从服务端拉取积分(用于打卡成功后刷新,积分值必须来自服务端,不可前端累加) - * 优先 GET /api/front/user/info,失败时回退到 user 或 tool/points/info - */ - async _fetchServerPoints(getFrontUserInfo, getUserInfo, getUserPoints) { + /** 优先 GET user/checkin,路由类失败时回退 user/sign/integral;返回值勿用于 currentPoints(data 为签到档位配置,integral 为奖励分非余额)。 */ + async _requestCheckin(userCheckin, userCheckinDaily) { try { - const userRes = await getFrontUserInfo(); - if (userRes && userRes.data) { - const d = userRes.data; - const p = d.integral ?? d.points ?? d.totalPoints ?? (d.user && (d.user.integral ?? d.user.points ?? d.user.totalPoints)); - if (p != null) return p; - } - } catch (_) {} - try { - const userRes = await getUserInfo(); - if (userRes && userRes.data) { - const d = userRes.data; - const p = d.integral ?? d.points ?? d.totalPoints ?? (d.user && (d.user.integral ?? d.user.points ?? d.user.totalPoints)); - if (p != null) return p; - } - } catch (_) {} - try { - const pointsRes = await getUserPoints(); - if (pointsRes && pointsRes.data) { - const d = pointsRes.data; - const p = d.totalPoints ?? d.points ?? d.availablePoints; - if (p != null) return p; - } - } catch (_) {} - return null; + await userCheckin(); + } catch (err) { + const msg = String((typeof err === 'string' ? err : (err && (err.message || err.msg))) || ''); + const m = msg.toLowerCase(); + // CRMEB 前端对 code=404 常 reject 文案「没有找到相关数据」,不含数字 404,须一并识别才能回退到 sign/integral + const needFallback = + m.includes('404') || + m.includes('405') || + m.includes('not found') || + m.includes('no static resource') || + msg.includes('没有找到相关数据') || + msg.includes('找不到'); + if (!needFallback) throw err; + await userCheckinDaily(); + } + }, + async _refreshCurrentPointsFromUserInfo(getFrontUserInfoFn) { + const gen = ++this._userInfoFetchGen; + const getInfo = + typeof getFrontUserInfoFn === 'function' + ? getFrontUserInfoFn + : (await import('@/api/user.js')).getFrontUserInfo; + const infoRes = await getInfo({ _: Date.now() }); + if (gen !== this._userInfoFetchGen) { + return false; + } + const serverPoints = this._parsePointsFromUserInfo(infoRes); + if (serverPoints != null && !Number.isNaN(Number(serverPoints))) { + this.currentPoints = Number(serverPoints); + return true; + } + return false; + }, + _parsePointsFromUserInfo(infoRes) { + if (!infoRes) return null; + const payload = infoRes.data; + const d = (payload && payload.data != null && typeof payload === 'object') ? payload.data : payload; + if (d == null || typeof d !== 'object') return null; + // 勿把打卡/签到接口的 SystemGroupDataSignConfigVo(含 day + integral 奖励)当成个人中心 integral + if (d.day != null && d.nickname == null && d.nowMoney == null) { + return null; + } + const v = d.integral ?? d.points ?? d.totalPoints ?? (d.user && (d.user.integral ?? d.user.points ?? d.user.totalPoints)); + return v != null && v !== '' ? v : null; }, getTodayIndex() { // 根据连续打卡数据计算当前是第几天 diff --git a/msh_single_uniapp/pages/tool/food-detail.vue b/msh_single_uniapp/pages/tool/food-detail.vue index 64ec785..d0fea57 100644 --- a/msh_single_uniapp/pages/tool/food-detail.vue +++ b/msh_single_uniapp/pages/tool/food-detail.vue @@ -1,699 +1,826 @@ -