fix: 修复Tool模块相关问题 - 优化签到、社区、食物和AI服务功能

This commit is contained in:
msh-agent
2026-04-12 09:31:00 +08:00
parent b164d8ba11
commit 77632510cf
12 changed files with 1158 additions and 756 deletions

View File

@@ -1,11 +1,11 @@
# 环境 # 环境
1. Java Jdk1.8 1. Java Jdk1.8/JDK17
2. Redis 5+ 2. Redis 5+
3. Mysql 5.7+ 3. Mysql 5.7+
# Java项目框架 # Java项目框架
1. SpringBoot 2.2.6.RELEASE 1. SpringBoot 2.2.6.RELEASE
2. Maven 3.6.1 2. Maven 3.9.1
3. Swagger 2.9.2 3. Swagger 2.9.2
4. Mybatis Plus 3.3.1 4. Mybatis Plus 3.3.1

View File

@@ -474,7 +474,9 @@ public class ToolController {
@ApiOperation(value = "关注/取消关注用户") @ApiOperation(value = "关注/取消关注用户")
@PostMapping("/community/follow") @PostMapping("/community/follow")
public CommonResult<String> toggleFollow(@RequestBody Map<String, Object> data) { public CommonResult<String> toggleFollow(@RequestBody Map<String, Object> 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("操作成功"); return CommonResult.success("操作成功");
} }

View File

@@ -11,8 +11,10 @@ import com.zbkj.common.request.*;
import com.zbkj.common.response.*; import com.zbkj.common.response.*;
import com.zbkj.common.result.CommonResult; import com.zbkj.common.result.CommonResult;
import com.zbkj.front.service.UserCenterService; import com.zbkj.front.service.UserCenterService;
import com.zbkj.common.vo.SystemGroupDataSignConfigVo;
import com.zbkj.service.service.SystemGroupDataService; import com.zbkj.service.service.SystemGroupDataService;
import com.zbkj.service.service.UserService; import com.zbkj.service.service.UserService;
import com.zbkj.service.service.UserSignService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam; import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
@@ -53,6 +55,9 @@ public class UserController {
@Autowired @Autowired
private UserCenterService userCenterService; private UserCenterService userCenterService;
@Autowired
private UserSignService userSignService;
/** /**
* 修改密码 * 修改密码
*/ */
@@ -83,6 +88,24 @@ public class UserController {
return CommonResult.success(userService.getUserCenter()); return CommonResult.success(userService.getUserCenter());
} }
/**
* 用户信息(含积分,用于打卡等操作后刷新积分)
*/
@ApiOperation(value = "用户信息-含积分")
@RequestMapping(value = "/user/info", method = RequestMethod.GET)
public CommonResult<UserCenterResponse> getUserInfo() {
return CommonResult.success(userService.getUserCenter());
}
/**
* 每日打卡领积分(与 GET /user/sign/integral 相同,供前端 /user/checkin 调用)
*/
@ApiOperation(value = "每日打卡")
@RequestMapping(value = "/user/checkin", method = RequestMethod.GET)
public CommonResult<SystemGroupDataSignConfigVo> userDailyCheckin() {
return CommonResult.success(userSignService.sign());
}
/** /**
* 换绑手机号校验 * 换绑手机号校验
*/ */

View File

@@ -712,21 +712,29 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
String mealTypeLabel = getMealTypeLabel(sign.getMealType()); String mealTypeLabel = getMealTypeLabel(sign.getMealType());
String title = mealTypeLabel + "打卡"; String title = mealTypeLabel + "打卡";
// 5. 准备营养数据JSON如果有AI分析结果 // 5. 准备营养数据 JSON:有 AI 结果时写入识别串;无论是否有 AI都写入打卡汇总热量/蛋白质),供社区详情营养卡展示
String nutritionDataJson = null; String nutritionDataJson = null;
if (StrUtil.isNotBlank(sign.getAiRecognizedFoodsJson())) {
try { try {
Map<String, Object> nutritionData = new HashMap<>(); Map<String, Object> 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("aiRecognizedFoods", sign.getAiRecognizedFoodsJson());
nutritionData.put("aiStatus", sign.getAiRecognitionStatus()); nutritionData.put("aiStatus", sign.getAiRecognitionStatus());
nutritionData.put("actualProtein", sign.getActualProtein()); }
nutritionData.put("actualEnergy", sign.getActualEnergy()); if (!nutritionData.isEmpty()) {
nutritionData.put("nutritionScore", sign.getNutritionScore());
nutritionDataJson = com.alibaba.fastjson.JSON.toJSONString(nutritionData); nutritionDataJson = com.alibaba.fastjson.JSON.toJSONString(nutritionData);
}
} catch (Exception e) { } catch (Exception e) {
log.warn("生成营养数据JSON失败: {}", e.getMessage()); log.warn("生成营养数据JSON失败: {}", e.getMessage());
} }
}
// 6. 解析封面图 // 6. 解析封面图
String coverImage = parseFirstImage(sign.getPhotosJson()); String coverImage = parseFirstImage(sign.getPhotosJson());

View File

@@ -300,6 +300,11 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
UserSign userSign = userSignService.getOne(signLqw); UserSign userSign = userSignService.getOne(signLqw);
if (userSign != null) { if (userSign != null) {
map.put("mealType", userSign.getMealType()); 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); map.put("enableAIVideo", userSign.getEnableAiVideo() != null && userSign.getEnableAiVideo() == 1);
log.info("找到打卡记录checkInRecordId: {}, taskId: {}, enableAIVideo: {}", log.info("找到打卡记录checkInRecordId: {}, taskId: {}, enableAIVideo: {}",

View File

@@ -69,7 +69,10 @@ public class ToolFoodServiceImpl implements ToolFoodService {
map.put("protein", food.getProtein()); map.put("protein", food.getProtein());
map.put("potassium", food.getPotassium()); map.put("potassium", food.getPotassium());
map.put("phosphorus", food.getPhosphorus()); map.put("phosphorus", food.getPhosphorus());
map.put("sodium", food.getSodium());
map.put("calcium", food.getCalcium());
map.put("suitabilityLevel", food.getSuitabilityLevel()); map.put("suitabilityLevel", food.getSuitabilityLevel());
map.put("nutrientsJson", food.getNutrientsJson());
result.add(map); result.add(map);
} }
return result; return result;
@@ -124,6 +127,7 @@ public class ToolFoodServiceImpl implements ToolFoodService {
map.put("vitaminC", food.getVitaminC()); map.put("vitaminC", food.getVitaminC());
map.put("purine", food.getPurine()); map.put("purine", food.getPurine());
map.put("servingSize", food.getServingSize()); map.put("servingSize", food.getServingSize());
map.put("nutrientsJson", food.getNutrientsJson());
return map; return map;
} }

View File

@@ -304,7 +304,7 @@ public class ToolKieAIServiceImpl implements ToolKieAIService {
String url = config.getBaseUrl() + GEMINI_CHAT_PATH; String url = config.getBaseUrl() + GEMINI_CHAT_PATH;
Map<String, Object> body = buildGeminiRequestBody(request, false); Map<String, Object> body = buildGeminiRequestBody(request, false);
HttpHeaders headers = helper.createHeaders(); HttpHeaders headers = helper.createHeaders();
HttpEntity<String> entity = new HttpEntity<>(helper.toJsonString(body), headers); HttpEntity<String> entity = new HttpEntity<>(toGeminiUpstreamJson(body), headers);
RestTemplate client = restTemplateWithLongTimeout(); RestTemplate client = restTemplateWithLongTimeout();
ResponseEntity<String> response = client.exchange(url, HttpMethod.POST, entity, String.class); ResponseEntity<String> response = client.exchange(url, HttpMethod.POST, entity, String.class);
if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) {
@@ -314,8 +314,182 @@ public class ToolKieAIServiceImpl implements ToolKieAIService {
if (result == null) { if (result == null) {
throw new RuntimeException("Gemini Chat 响应解析失败"); throw new RuntimeException("Gemini Chat 响应解析失败");
} }
// 上游可能将 OpenAI 形态包在 data / result 等字段内,解包后再规范化,避免前端拿不到 choices
result = unwrapUpstreamChatCompletionMap(result);
Map<String, Object> 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<String, Object> 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<String, Object> message = (Map<String, Object>) 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<String, Object> normalizeToOpenAiChoicesFormat(Map<String, Object> result) {
Object choicesObj = result.get("choices");
if (hasNonEmptyFirstChoiceAssistantContent(choicesObj)) {
return result; 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<String, Object> candidate = (Map<String, Object>) c0;
Object flatContent = flattenGeminiMessageContent(candidate.get("content"));
if (isBlankAssistantContent(flatContent)) {
Object t = candidate.get("text");
if (t != null) {
flatContent = t.toString();
}
}
Map<String, Object> message = new HashMap<>();
message.put("role", "assistant");
message.put("content", flatContent != null ? flatContent : "");
Map<String, Object> choice = new HashMap<>();
choice.put("message", message);
List<Object> newChoices = new ArrayList<>();
newChoices.add(choice);
Map<String, Object> 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<String, Object> unwrapUpstreamChatCompletionMap(Map<String, Object> 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<String, Object> m = (Map<String, Object>) nested;
if (hasUsableChoicesOrCandidates(m)) {
return m;
}
Object inner = m.get("data");
if (inner instanceof Map) {
Map<String, Object> innerMap = (Map<String, Object>) inner;
if (hasUsableChoicesOrCandidates(innerMap)) {
return innerMap;
}
}
}
return root;
}
private boolean hasUsableChoicesOrCandidates(Map<String, Object> 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() { private RestTemplate restTemplateWithLongTimeout() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
@@ -341,7 +515,7 @@ public class ToolKieAIServiceImpl implements ToolKieAIService {
.build(); .build();
RequestBody requestBody = RequestBody.create( RequestBody requestBody = RequestBody.create(
okhttp3.MediaType.parse("application/json; charset=utf-8"), okhttp3.MediaType.parse("application/json; charset=utf-8"),
helper.toJsonString(body)); toGeminiUpstreamJson(body));
Request okRequest = new Request.Builder() Request okRequest = new Request.Builder()
.url(url) .url(url)
.post(requestBody) .post(requestBody)
@@ -390,68 +564,26 @@ public class ToolKieAIServiceImpl implements ToolKieAIService {
return emitter; return emitter;
} }
/** BUG-005: 仅使用 request.getMessages() 透传用户/助手消息,不注入硬编码 prompt */ /** 发往 KieAI Gemini 的请求体 JSON含 FastJSON 解析的 messages需用 FastJSON 序列化) */
private static String toGeminiUpstreamJson(Map<String, Object> body) {
return com.alibaba.fastjson.JSON.toJSONString(body);
}
/**
* BUG-005仅透传 request.getMessages(),不注入系统 prompt。
* 经 FastJSON 往返序列化,保证多模态等嵌套结构与客户端一致(避免 Jackson 再写出时丢字段)。
*/
private Map<String, Object> buildGeminiRequestBody(KieAIGeminiChatRequest request, boolean stream) { private Map<String, Object> buildGeminiRequestBody(KieAIGeminiChatRequest request, boolean stream) {
if (request.getMessages() == null || request.getMessages().isEmpty()) {
throw new IllegalArgumentException("messages 不能为空");
}
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
List<Map<String, Object>> messagesOut = new ArrayList<>(); // BUG-005仅用 request.getMessages() 透传;用 FastJSON 序列化再 parse与上游 toJSONString 一致,避免 Jackson 对 Object content 往返变形
for (KieAIGeminiChatRequest.Message msg : request.getMessages()) { String messagesJson = com.alibaba.fastjson.JSON.toJSONString(request.getMessages());
Map<String, Object> m = new HashMap<>(); if (messagesJson == null || messagesJson.isEmpty() || "null".equals(messagesJson)) {
m.put("role", msg.getRole()); throw new IllegalArgumentException("messages 序列化失败");
Object content = msg.getContent();
if (content instanceof String) {
List<Map<String, Object>> parts = new ArrayList<>();
Map<String, Object> 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<Map<String, Object>> parts = new ArrayList<>();
for (Object item : list) {
Map<String, Object> 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<String, Object> iu = new HashMap<>();
iu.put("url", ci.getImageUrl().getUrl());
part.put("image_url", iu);
} }
parts.add(part); body.put("messages", com.alibaba.fastjson.JSON.parse(messagesJson));
} else if (item instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) 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<String, Object> 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);
}
body.put("messages", messagesOut);
body.put("stream", stream); body.put("stream", stream);
if (request.getIncludeThoughts() != null) { if (request.getIncludeThoughts() != null) {
body.put("include_thoughts", request.getIncludeThoughts()); body.put("include_thoughts", request.getIncludeThoughts());

View File

@@ -119,8 +119,8 @@ export function getCheckinList(data) {
* 获取打卡记录详情 * 获取打卡记录详情
* @param {Number} id - 打卡记录ID * @param {Number} id - 打卡记录ID
*/ */
export function getCheckinDetail(id) { export function getCheckinDetail(id, opt) {
return request.get('tool/checkin/detail/' + id); return request.get('tool/checkin/detail/' + id, {}, opt || {});
} }
/** /**
@@ -196,7 +196,7 @@ export function shareCheckinToCommunity(checkinId) {
* @param {Number} data.limit - 每页数量 * @param {Number} data.limit - 每页数量
*/ */
export function searchFood(data) { 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 {String} data.category - 分类all/grain/vegetable/fruit/meat/seafood
* @param {Number} data.page - 页码 * @param {Number} data.page - 页码
* @param {Number} data.limit - 每页数量 * @param {Number} data.limit - 每页数量
*
* 响应 data 为分页list[] 项含 image、nutrientsJson、energy、protein、钾/磷/钠/钙、suitabilityLevelToolFoodServiceImpl
*/ */
export function getFoodList(data) { 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必须为数字不能传名称 * @param {Number|String} id - 食物ID必须为数字不能传名称
*/ */
export function getFoodDetail(id) { export function getFoodDetail(id) {
const numId = typeof id === 'number' && !isNaN(id) ? id : parseInt(String(id), 10); const rawId = normalizeFoodDetailIdString(id);
// 打印请求参数便于确认(后端仅接受 Long 类型 id传 name 会 400 // 严格按整型 ID 校验,避免 parseInt('123abc') 这类误判;路径用字符串拼接避免超过 JS 安全整数时 Number() 精度丢失
const apiPath = 'tool/food/detail/' + numId; const isIntegerId = rawId !== '' && /^-?\d+$/.test(rawId);
console.log('[api/tool] getFoodDetail 请求参数:', { id, numId, type: typeof id, apiPath }); const apiPath = 'tool/food/detail/' + rawId;
if (isNaN(numId)) { 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 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 - 每页数量 * @param {Number} data.limit - 每页数量
*/ */
export function getKnowledgeList(data) { 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 * @param {Number} id - 知识ID
*/ */
export function getKnowledgeDetail(id) { export function getKnowledgeDetail(id) {
return request.get('tool/knowledge/detail/' + id); return request.get('tool/knowledge/detail/' + id, {}, { noAuth: true });
} }
/** /**

View File

@@ -21,9 +21,10 @@ export function getUserInfo(){
/** /**
* 获取用户信息GET /api/front/user/info用于打卡后刷新积分等 * 获取用户信息GET /api/front/user/info用于打卡后刷新积分等
* @param {Object} [query] - 可选查询参数(如 { _: Date.now() } 避免缓存陈旧积分)
*/ */
export function getFrontUserInfo(){ export function getFrontUserInfo(query) {
return request.get('user/info'); return request.get('user/info', query || {});
} }
/** /**
@@ -120,12 +121,26 @@ export function getSignGet() {
} }
/** /**
* 用户签到 * 用户签到(每日打卡领积分)
*/ * 对应 GET /api/front/user/sign/integral服务端累加积分无 /user/checkin 时与此等价。
*/
export function setSignIntegral(){ export function setSignIntegral(){
return request.get('user/sign/integral') 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 * @param object data

View File

@@ -2,9 +2,9 @@
// | // |
// +---------------------------------------------------------------------- // +----------------------------------------------------------------------
// 移动端商城API // 移动端商城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://chenyin.uj345.cc'
// let domain = 'https://sophia-shop.uj345.cc' let domain = 'https://sophia-shop.uj345.cc'
module.exports = { module.exports = {
domain, domain,

View File

@@ -113,7 +113,13 @@ export default {
data() { data() {
return { return {
todaySigned: false, todaySigned: false,
currentPoints: 522, /** 防止 loadCheckinData 与打卡后刷新并发时,旧 user/info 响应覆盖新积分 */
_userInfoFetchGen: 0,
/** 打卡流程进行中:禁止 onShow 触发的 loadCheckinData 用「打卡前」发出的 user/info 写 currentPoints */
_suppressStalePointsLoad: false,
/** 防止连点导致多次打卡请求与积分展示乱序 */
_checkinSubmitting: false,
currentPoints: 0,
streakDays: [ streakDays: [
{ day: 1, completed: false, special: false }, { day: 1, completed: false, special: false },
{ day: 2, completed: false, special: false }, { day: 2, completed: false, special: false },
@@ -183,17 +189,25 @@ export default {
this.loadCheckinData(); this.loadCheckinData();
}, },
onShow() { onShow() {
// 页面显示时刷新数据 // 页面显示时刷新数据(打卡请求进行中时跳过,避免与 handleCheckin 内刷新竞态)
if (this._checkinSubmitting) {
return;
}
this.loadCheckinData(); this.loadCheckinData();
}, },
methods: { 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 { try {
const { getUserPoints, getCheckinStreak, getCheckinTasks } = await import('@/api/tool.js'); const { getCheckinStreak, getCheckinTasks } = await import('@/api/tool.js');
const { getSignGet } = await import('@/api/user.js'); const { getSignGet, getFrontUserInfo } = await import('@/api/user.js');
const [pointsRes, streakRes, tasksRes, signRes] = await Promise.all([ const [userInfoRes, streakRes, tasksRes, signRes] = await Promise.all([
getUserPoints().catch(() => ({ data: { points: 0 } })), skipPoints ? Promise.resolve(null) : getFrontUserInfo().catch(() => null),
getCheckinStreak().catch(() => ({ data: { streakDays: 7, currentStreak: 0 } })), getCheckinStreak().catch(() => ({ data: { streakDays: 7, currentStreak: 0 } })),
getCheckinTasks().catch(() => ({ data: { tasks: [] } })), getCheckinTasks().catch(() => ({ data: { tasks: [] } })),
getSignGet().catch(() => ({ data: { today: false } })) getSignGet().catch(() => ({ data: { today: false } }))
@@ -203,8 +217,14 @@ export default {
this.todaySigned = true; this.todaySigned = true;
} }
if (pointsRes.data) { // BUG-001-A打卡请求进行中时勿用并行的 user/info 回写积分(避免先显示旧分再跳变);显式 includePointsRefresh 时仍刷新
this.currentPoints = pointsRes.data.totalPoints ?? pointsRes.data.points ?? 0; 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) { if (streakRes.data) {
@@ -235,68 +255,107 @@ export default {
url: '/pages/tool/points-rules' url: '/pages/tool/points-rules'
}) })
}, },
/**
* BUG-001-A打卡 API 成功前不得修改 currentPoints禁止本地 +30 或与跳转前的乐观更新)。
* BUG-001-B仅 GET /api/front/user/infogetFrontUserInfo解析账户 integral打卡接口返回的 integral 为当日奖励分值,绝非总积分。
*/
async handleCheckin() { async handleCheckin() {
if (this._checkinSubmitting) {
return;
}
if (this.todaySigned) { if (this.todaySigned) {
uni.showToast({ title: '今日已打卡', icon: 'none' }); uni.showToast({ title: '今日已打卡', icon: 'none' });
return; 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 { try {
const { setSignIntegral, getFrontUserInfo, getUserInfo } = await import('@/api/user.js'); // 1) /api/front/user/checkin失败时回退 /api/front/user/sign/integral二者均不读取 body 更新积分
const { getUserPoints } = await import('@/api/tool.js'); await this._requestCheckin(userCheckin, userCheckinDaily);
// 2) 成功后必须 GET user/info用服务端账户积分更新展示
// 子问题 A在 API 返回成功前不得修改 currentPoints避免打卡前积分提前跳变禁止前端本地 +30 等) let refreshed = await this._refreshCurrentPointsFromUserInfo(getFrontUserInfo).catch((err) => {
// 打卡接口GET /api/front/user/sign/integralsetSignIntegral即本项目的签到/打卡接口 console.warn('打卡后刷新积分失败:', err);
await setSignIntegral(); return false;
});
// 子问题 B打卡成功后必须用服务端数据更新积分。发 GET /api/front/user/info 刷新用户积分,禁止硬编码 +30 if (refreshed && Number(this.currentPoints) === pointsBeforeCheckin) {
const serverPoints = await this._fetchServerPoints(getFrontUserInfo, getUserInfo, getUserPoints); await new Promise((r) => setTimeout(r, 250));
if (serverPoints != null) { refreshed =
this.currentPoints = Number(serverPoints); (await this._refreshCurrentPointsFromUserInfo(getFrontUserInfo).catch(() => false)) || refreshed;
}
if (!refreshed) {
await this.loadCheckinData({ includePointsRefresh: true });
} }
this.todaySigned = true; this.todaySigned = true;
await this.loadCheckinData({ skipPoints: true });
uni.navigateTo({
url: '/pages/tool/checkin-publish'
});
} catch (e) { } catch (e) {
const msg = (typeof e === 'string' ? e : (e && (e.message || e.msg))) || '打卡失败'; const msg = (typeof e === 'string' ? e : (e && (e.message || e.msg))) || '打卡失败';
if (msg.includes('今日已签到') || msg.includes('不可重复')) { if (msg.includes('今日已签到') || msg.includes('不可重复')) {
this.todaySigned = true; this.todaySigned = true;
await this.loadCheckinData({ skipPoints: true });
} else {
await this.loadCheckinData({ includePointsRefresh: true }).catch(() => {});
} }
uni.showToast({ title: msg, icon: 'none' }); uni.showToast({ title: msg, icon: 'none' });
return; return;
} finally {
this._suppressStalePointsLoad = false;
this._checkinSubmitting = false;
uni.hideLoading();
} }
uni.navigateTo({
url: '/pages/tool/checkin-publish'
});
}, },
/** /** 优先 GET user/checkin路由类失败时回退 user/sign/integral返回值勿用于 currentPointsdata 为签到档位配置integral 为奖励分非余额)。 */
* 从服务端拉取积分(用于打卡成功后刷新,积分值必须来自服务端,不可前端累加) async _requestCheckin(userCheckin, userCheckinDaily) {
* 优先 GET /api/front/user/info失败时回退到 user 或 tool/points/info
*/
async _fetchServerPoints(getFrontUserInfo, getUserInfo, getUserPoints) {
try { try {
const userRes = await getFrontUserInfo(); await userCheckin();
if (userRes && userRes.data) { } catch (err) {
const d = userRes.data; const msg = String((typeof err === 'string' ? err : (err && (err.message || err.msg))) || '');
const p = d.integral ?? d.points ?? d.totalPoints ?? (d.user && (d.user.integral ?? d.user.points ?? d.user.totalPoints)); const m = msg.toLowerCase();
if (p != null) return p; // 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();
} }
} catch (_) {} },
try { async _refreshCurrentPointsFromUserInfo(getFrontUserInfoFn) {
const userRes = await getUserInfo(); const gen = ++this._userInfoFetchGen;
if (userRes && userRes.data) { const getInfo =
const d = userRes.data; typeof getFrontUserInfoFn === 'function'
const p = d.integral ?? d.points ?? d.totalPoints ?? (d.user && (d.user.integral ?? d.user.points ?? d.user.totalPoints)); ? getFrontUserInfoFn
if (p != null) return p; : (await import('@/api/user.js')).getFrontUserInfo;
const infoRes = await getInfo({ _: Date.now() });
if (gen !== this._userInfoFetchGen) {
return false;
} }
} catch (_) {} const serverPoints = this._parsePointsFromUserInfo(infoRes);
try { if (serverPoints != null && !Number.isNaN(Number(serverPoints))) {
const pointsRes = await getUserPoints(); this.currentPoints = Number(serverPoints);
if (pointsRes && pointsRes.data) { return true;
const d = pointsRes.data;
const p = d.totalPoints ?? d.points ?? d.availablePoints;
if (p != null) return p;
} }
} catch (_) {} 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; 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() { getTodayIndex() {
// 根据连续打卡数据计算当前是第几天 // 根据连续打卡数据计算当前是第几天

File diff suppressed because it is too large Load Diff