diff --git a/pom.xml b/pom.xml index 35e4b0e..b091fc7 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.imyeyu.timiserverapi TimiServerAPI - 1.0.48 + 1.0.49 jar TimiServerAPI imyeyu.com API diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java b/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java index c7c4882..d184eca 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java @@ -14,10 +14,6 @@ import org.springframework.stereotype.Component; @Component public class GaoAiProperties { - /// MCP 服务间密钥 - @Value("${gao.ai.mcp-service-key:}") - private String mcpServiceKey; - /// AI 会话有效期,单位秒 @Value("${gao.ai.session-ttl-seconds:300}") private long sessionTtlSeconds; @@ -33,16 +29,4 @@ public class GaoAiProperties { /// Dify 流式读取空闲超时,单位秒 @Value("${gao.ai.read-timeout-seconds:600}") private long readTimeoutSeconds; - - /// true 为允许缺少 AI 会话时使用固定测试会话,仅用于本地调试 - @Value("${gao.ai.allow-missing-session:false}") - private boolean allowMissingSession; - - /// 缺少 AI 会话时使用的测试用户 ID - @Value("${gao.ai.debug-user-id:dify-agent-debug}") - private String debugUserId; - - /// 缺少 AI 会话时使用的测试门店 ID - @Value("${gao.ai.debug-store-id:00000000-0000-0000-0000-000000000001}") - private String debugStoreId; } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java b/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java index 439e858..5687bd1 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java @@ -1,8 +1,11 @@ package com.imyeyu.api.modules.gao.ai.controller; +import com.fasterxml.jackson.databind.JsonNode; import com.imyeyu.api.modules.gao.ai.service.GaoAiAccessException; import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest; import com.imyeyu.spring.annotation.IgnoreGlobalReturn; import com.imyeyu.spring.annotation.RequestRateLimit; import lombok.RequiredArgsConstructor; @@ -10,6 +13,7 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -38,7 +42,57 @@ public class GaoAiChatController { try { return chatService.chat(request); } catch (GaoAiAccessException e) { - throw new ResponseStatusException(HttpStatus.valueOf(e.getStatus()), e.getMessage(), e); + throw accessException(e); } } + + /// 列出当前登录用户的历史会话 + /// + /// @param request 历史会话分页请求 + /// @return 历史会话分页结果 + @RequestRateLimit + @PostMapping("/conversation/list") + public JsonNode listConversation(@RequestBody(required = false) GaoAiConversationListRequest request) { + try { + return chatService.listConversation(request); + } catch (GaoAiAccessException e) { + throw accessException(e); + } + } + + /// 读取指定历史会话的消息 + /// + /// @param request 历史会话读取请求 + /// @return 会话消息分页结果 + @RequestRateLimit + @PostMapping("/conversation/read") + public JsonNode readConversation(@RequestBody GaoAiConversationReadRequest request) { + try { + return chatService.readConversation(request); + } catch (GaoAiAccessException e) { + throw accessException(e); + } + } + + /// 删除当前登录用户的历史会话 + /// + /// @param conversationId Dify 会话 ID + /// @return 无 + @RequestRateLimit + @PostMapping("/conversation/delete") + public void deleteConversation(@RequestParam String conversationId) { + try { + chatService.deleteConversation(conversationId); + } catch (GaoAiAccessException e) { + throw accessException(e); + } + } + + /// 转换智能体访问异常 + /// + /// @param exception 智能体访问异常 + /// @return HTTP 响应状态异常 + private ResponseStatusException accessException(GaoAiAccessException exception) { + return new ResponseStatusException(HttpStatus.valueOf(exception.getStatus()), exception.getMessage(), exception); + } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java index 1c2e769..4982419 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java @@ -1,9 +1,10 @@ package com.imyeyu.api.modules.gao.ai.mcp; -import com.imyeyu.api.modules.gao.ai.config.GaoAiProperties; +import com.imyeyu.api.modules.common.service.SettingService; import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException; +import com.imyeyu.api.modules.gao.bean.GaoSystemSetting; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -20,23 +21,21 @@ import java.security.MessageDigest; @RequiredArgsConstructor public class GaoAiMcpAuthentication { - private final GaoAiProperties properties; + private final SettingService settingService; private final GaoAiSessionService sessionService; /// 校验 Dify 服务密钥和 AI 会话 /// /// @param authorization Authorization 请求头 - /// @param sessionId X-Timi-AI-Session 请求头 + /// @param headerSessionId X-Timi-AI-Session 请求头 + /// @param argumentSessionId MCP 工具参数中的 AI 会话 ID /// @param toolName 工具名称,可为空 /// @return 已认证会话 - public GaoAiSession authenticate(String authorization, String sessionId, String toolName) { + public GaoAiSession authenticate(String authorization, String headerSessionId, String argumentSessionId, String toolName) { authenticateService(authorization); - if (isBlank(sessionId) && properties.isAllowMissingSession()) { - GaoAiSession session = debugSession(); - if (toolName != null && !session.getAllowedTools().contains(toolName)) { - throw new GaoAiSessionException("AI 会话无权调用该工具", 403); - } - return session; + String sessionId = headerSessionId; + if (isBlank(sessionId) && !isBlank(argumentSessionId)) { + sessionId = argumentSessionId; } GaoAiSession session = sessionService.require(sessionId); if (toolName != null && (session.getAllowedTools() == null || !session.getAllowedTools().contains(toolName))) { @@ -51,9 +50,9 @@ public class GaoAiMcpAuthentication { /// /// @param authorization Authorization 请求头 public void authenticateService(String authorization) { - String expectedKey = trim(properties.getMcpServiceKey()); + String key = settingService.getSystem(GaoSystemSetting.DIFY_MCP_SERVICE_KEY).asString(); String actualKey = bearerValue(authorization); - if (expectedKey.isEmpty() || actualKey.isEmpty() || !constantTimeEquals(expectedKey, actualKey)) { + if (key.isEmpty() || actualKey.isEmpty() || !constantTimeEquals(key, actualKey)) { throw new GaoAiSessionException("MCP 服务认证失败", 401); } } @@ -77,24 +76,6 @@ public class GaoAiMcpAuthentication { return value == null ? "" : value.trim(); } - /// 创建固定的开发测试会话 - /// - /// @return 开发测试会话 - private GaoAiSession debugSession() { - String userId = trim(properties.getDebugUserId()); - String storeId = trim(properties.getDebugStoreId()); - if (userId.isEmpty() || storeId.isEmpty()) { - throw new GaoAiSessionException("开发测试会话缺少用户或门店配置", 401); - } - GaoAiSession session = new GaoAiSession(); - session.setSessionId("dify-agent-debug-bypass"); - session.setUserId(userId); - session.setStoreId(storeId); - session.setAllowedTools(GaoAiToolNames.orderedReadOnlyTools()); - session.setExpiresAt(Long.MAX_VALUE); - return session; - } - /// 判断字符串是否为空 /// /// @param value 待判断字符串 diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java index 0d1b58e..e68a98f 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java @@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.ai.mcp; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.ai.mcp.tool.GaoAiTool; @@ -90,12 +91,13 @@ public class GaoAiMcpController { yield response(200, success(id, toolsResult())); } case "tools/call" -> { - GaoAiSession session = authentication.authenticate(authorization, aiSessionId, text(request.path("params"), "name")); - if (!hasText(aiSessionId)) { - log.warn("GAO MCP 使用开发测试会话:toolName={}, userId={}, storeId={}", - toolName, session.getUserId(), session.getStoreId()); + JsonNode params = request.path("params"); + String argumentSessionId = text(params.path("arguments"), "aiSessionId"); + GaoAiSession session = authentication.authenticate(authorization, aiSessionId, argumentSessionId, text(params, "name")); + if (hasText(argumentSessionId) && !hasText(aiSessionId)) { + log.info("GAO MCP 使用工具参数中的 AI 会话:toolName={}, aiSessionLength={}", toolName, length(argumentSessionId)); } - yield response(200, success(id, executeTool(session, request.path("params"), aiSessionId))); + yield response(200, success(id, executeTool(session, params, hasText(aiSessionId) ? aiSessionId : argumentSessionId))); } default -> response(400, error(id, -32601, "MCP 方法不支持")); }; @@ -126,12 +128,14 @@ public class GaoAiMcpController { if (arguments == null || !arguments.isObject()) { throw new GaoAiToolException("工具参数格式无效", 400); } - if (containsField(arguments, "storeId")) { + ObjectNode businessArguments = ((ObjectNode) arguments).deepCopy(); + businessArguments.remove("aiSessionId"); + if (containsField(businessArguments, "storeId")) { throw new GaoAiToolException("工具请求不允许携带 storeId", 403); } GaoAiToolRequest request; try { - request = jackson.treeToValue(arguments, GaoAiToolRequest.class); + request = jackson.treeToValue(businessArguments, GaoAiToolRequest.class); } catch (Exception e) { throw new GaoAiToolException("工具参数格式无效", 400); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java index ad23182..7da77dd 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java @@ -4,6 +4,7 @@ import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import java.util.LinkedHashMap; import java.util.Map; /// GAO 智能体只读工具 @@ -36,15 +37,19 @@ public interface GaoAiTool { ), "additionalProperties", false ); + Map properties = new LinkedHashMap<>(); + properties.put("query", Map.of("type", "string", "description", "自然语言关键词")); + properties.put("timeRange", timeRange); + properties.put("filters", Map.of("type", "object")); + properties.put("pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20)); + properties.put("cursor", Map.of("type", "string")); + properties.put("aiSessionId", Map.of( + "type", "string", + "description", "由工作流变量注入的 AI 会话 ID,不允许由模型生成" + )); return Map.of( "type", "object", - "properties", Map.of( - "query", Map.of("type", "string", "description", "自然语言关键词"), - "timeRange", timeRange, - "filters", Map.of("type", "object"), - "pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20), - "cursor", Map.of("type", "string") - ), + "properties", properties, "additionalProperties", false ); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java index 779cf05..94a0a20 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java @@ -1,6 +1,9 @@ package com.imyeyu.api.modules.gao.ai.service; +import com.fasterxml.jackson.databind.JsonNode; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; /// GAO 智能体聊天服务 @@ -14,4 +17,21 @@ public interface GaoAiChatService { /// @param request 聊天请求 /// @return SSE 发射器 SseEmitter chat(GaoAiChatRequest request); + + /// 列出当前登录用户的历史会话 + /// + /// @param request 历史会话分页请求 + /// @return 历史会话分页结果 + JsonNode listConversation(GaoAiConversationListRequest request); + + /// 读取当前登录用户的历史会话消息 + /// + /// @param request 历史会话读取请求 + /// @return 会话消息分页结果 + JsonNode readConversation(GaoAiConversationReadRequest request); + + /// 删除当前登录用户的历史会话 + /// + /// @param conversationId Dify 会话 ID + void deleteConversation(String conversationId); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java index 464c854..38f22a4 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java @@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.ai.service.implement; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.imyeyu.api.bean.ModuleCode; import com.imyeyu.api.modules.common.service.SettingService; @@ -13,6 +14,8 @@ import com.imyeyu.api.modules.gao.ai.service.GaoAiAuditService; import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService; import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest; import com.imyeyu.api.modules.gao.bean.GaoRoleCode; import com.imyeyu.api.modules.gao.bean.GaoSystemSetting; import com.imyeyu.api.modules.gao.entity.GaoStore; @@ -33,6 +36,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -101,13 +105,7 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { if (request.getConversationId() != null && 128 < request.getConversationId().length()) { throw new GaoAiAccessException("会话 ID 无效", 400); } - User user = userLoginService.getLoginUser(); - if (user == null) { - throw new GaoAiAccessException("登录已过期,请重新登录", 401); - } - if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name())) { - throw new GaoAiAccessException("当前账号无权使用门店智能助手", 403); - } + User user = requireAiUser(); String storeId; try { storeId = storeScopeService.resolveStoreId(request.getStoreId()); @@ -122,6 +120,50 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { return stream(session, message); } + @Override + public JsonNode listConversation(GaoAiConversationListRequest request) { + User user = requireAiUser(); + String lastId = optionalValue(request == null ? null : request.getLastId(), "上一页游标", 128); + int limit = normalizeLimit(request == null ? null : request.getLimit()); + String sortBy = normalizeSortBy(request == null ? null : request.getSortBy()); + StringBuilder url = new StringBuilder("/conversations"); + appendQueryParameter(url, "user", difyUser(user)); + appendQueryParameter(url, "last_id", lastId); + appendQueryParameter(url, "limit", String.valueOf(limit)); + appendQueryParameter(url, "sort_by", sortBy); + return sanitizeConversationList(parseJson(sendDify(newDifyRequest(url.toString()).GET().build(), "查询历史会话"))); + } + + @Override + public JsonNode readConversation(GaoAiConversationReadRequest request) { + User user = requireAiUser(); + if (request == null) { + throw new GaoAiAccessException("会话读取参数不能为空", 400); + } + String conversationId = requiredValue(request.getConversationId(), "会话 ID", 128); + String firstId = optionalValue(request.getFirstId(), "上一页游标", 128); + int limit = normalizeLimit(request.getLimit()); + StringBuilder url = new StringBuilder("/messages"); + appendQueryParameter(url, "user", difyUser(user)); + appendQueryParameter(url, "conversation_id", conversationId); + appendQueryParameter(url, "first_id", firstId); + appendQueryParameter(url, "limit", String.valueOf(limit)); + return sanitizeConversationMessages(parseJson(sendDify(newDifyRequest(url.toString()).GET().build(), "读取历史会话"))); + } + + @Override + public void deleteConversation(String conversationId) { + User user = requireAiUser(); + String value = requiredValue(conversationId, "会话 ID", 128); + ObjectNode body = jackson.createObjectNode(); + body.put("user", difyUser(user)); + HttpRequest request = newDifyRequest("/conversations/" + encodePathSegment(value)) + .method("DELETE", HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8)) + .header("Content-Type", "application/json") + .build(); + sendDify(request, "删除历史会话"); + } + private SseEmitter stream(GaoAiSession session, String message) { long readTimeoutSeconds = positive(properties.getReadTimeoutSeconds(), 600L); long emitterTimeout = Math.min(Long.MAX_VALUE - 1L, readTimeoutSeconds * 1000L + 30_000L); @@ -190,19 +232,250 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { GaoStore store = storeService.get(session.getStoreId()); inputs.put("storeName", store == null ? "当前门店" : store.getName()); inputs.put("today", LocalDate.now(ZONE).toString()); - String url = settingService.getSystem(DifySetting.BASE_URL).asString(); - String appApiKey = settingService.getSystem(GaoSystemSetting.DIFY_APP_API_KEY).asString(); - return HttpRequest.newBuilder() - .uri(URI.create(url + "/chat-messages")) + return newDifyRequest("/chat-messages") .timeout(Duration.ofSeconds(readTimeoutSeconds)) - .header("Authorization", "Bearer " + appApiKey) .header("X-Timi-AI-Session", session.getSessionId()) .header("Content-Type", "application/json") - .header("Accept", "text/event-stream") + .setHeader("Accept", "text/event-stream") .POST(HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8)) .build(); } + /// 获取当前登录且有权使用智能体的用户 + /// + /// @return 当前登录用户 + private User requireAiUser() { + User user = userLoginService.getLoginUser(); + if (user == null) { + throw new GaoAiAccessException("登录已过期,请重新登录", 401); + } + if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name())) { + throw new GaoAiAccessException("当前账号无权使用门店智能助手", 403); + } + return user; + } + + /// 创建 Dify API 请求 + /// + /// @param path Dify API 路径,可包含已编码的查询参数 + /// @return Dify API 请求构造器 + private HttpRequest.Builder newDifyRequest(String path) { + String appApiKey = settingService.getSystem(GaoSystemSetting.DIFY_APP_API_KEY).asString(); + if (isBlank(appApiKey)) { + throw new GaoAiAccessException("智能体服务未配置", 503); + } + return HttpRequest.newBuilder(difyUri(path)) + .timeout(Duration.ofSeconds(Math.min(60L, positive(properties.getReadTimeoutSeconds(), 600L)))) + .header("Authorization", "Bearer " + appApiKey) + .header("Accept", "application/json"); + } + + /// 调用 Dify API + /// + /// @param request Dify API 请求 + /// @param operation 操作名称 + /// @return Dify 响应正文 + private String sendDify(HttpRequest request, String operation) { + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GaoAiAccessException("智能体服务请求被中断", 503); + } catch (IOException | RuntimeException e) { + log.warn("{}失败", operation, e); + throw new GaoAiAccessException("智能体服务暂时不可用,请稍后重试", 503); + } + if (response.statusCode() < 200 || 299 < response.statusCode()) { + log.warn("{}失败:statusCode={}", operation, response.statusCode()); + int status = response.statusCode() == 404 ? 404 : 503; + String message = status == 404 ? "历史会话不存在" : "智能体服务暂时不可用,请稍后重试"; + throw new GaoAiAccessException(message, status); + } + return response.body(); + } + + /// 解析 Dify JSON 响应 + /// + /// @param responseBody 响应正文 + /// @return JSON 节点 + private JsonNode parseJson(String responseBody) { + try { + return jackson.readTree(responseBody); + } catch (IOException | RuntimeException e) { + log.warn("解析智能体历史会话响应失败", e); + throw new GaoAiAccessException("智能体服务返回数据无效", 503); + } + } + + /// 过滤会话列表中的 Dify 内部字段 + /// + /// @param response Dify 响应 + /// @return 脱敏后的响应 + private JsonNode sanitizeConversationList(JsonNode response) { + if (response == null || !response.isObject()) { + return response; + } + ObjectNode result = jackson.createObjectNode(); + copyFields(response, result, "has_more", "limit"); + ArrayNode data = result.putArray("data"); + for (JsonNode item : response.path("data")) { + if (!item.isObject()) { + continue; + } + ObjectNode conversation = data.addObject(); + copyFields(item, conversation, "id", "name", "status", "introduction", "created_at", "updated_at"); + } + return result; + } + + /// 过滤会话消息中的 Dify 内部字段 + /// + /// @param response Dify 响应 + /// @return 脱敏后的响应 + private JsonNode sanitizeConversationMessages(JsonNode response) { + if (response == null || !response.isObject()) { + return response; + } + ObjectNode result = jackson.createObjectNode(); + copyFields(response, result, "has_more", "limit"); + ArrayNode data = result.putArray("data"); + for (JsonNode item : response.path("data")) { + if (!item.isObject()) { + continue; + } + ObjectNode message = data.addObject(); + copyFields(item, message, "id", "conversation_id", "query", "answer", "created_at", "status", "feedback"); + } + return result; + } + + /// 复制指定的 JSON 字段 + /// + /// @param source 源 JSON 对象 + /// @param target 目标 JSON 对象 + /// @param fieldNames 字段名称 + /// @return 无 + private void copyFields(JsonNode source, ObjectNode target, String... fieldNames) { + for (String fieldName : fieldNames) { + if (source.has(fieldName)) { + target.set(fieldName, source.get(fieldName)); + } + } + } + + /// 生成 Dify 用户标识 + /// + /// @param user 登录用户 + /// @return Dify 用户标识 + private String difyUser(User user) { + return "gao-store-manager:" + user.getId(); + } + + /// 添加 URL 查询参数 + /// + /// @param url URL 构造器 + /// @param name 参数名称 + /// @param value 参数值 + /// @return 无 + private void appendQueryParameter(StringBuilder url, String name, String value) { + if (isBlank(value)) { + return; + } + url.append(url.indexOf("?") < 0 ? '?' : '&') + .append(name) + .append('=') + .append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + } + + /// 生成 Dify API URI + /// + /// @param path Dify API 路径 + /// @return Dify API URI + private URI difyUri(String path) { + String baseUrl = settingService.getSystem(DifySetting.BASE_URL).asString(); + if (isBlank(baseUrl)) { + throw new GaoAiAccessException("智能体服务未配置", 503); + } + String normalizedBaseUrl = baseUrl.trim(); + while (normalizedBaseUrl.endsWith("/")) { + normalizedBaseUrl = normalizedBaseUrl.substring(0, normalizedBaseUrl.length() - 1); + } + try { + URI uri = URI.create(normalizedBaseUrl + (path.startsWith("/") ? path : "/" + path)); + if (!uri.isAbsolute() || isBlank(uri.getScheme()) || isBlank(uri.getHost())) { + throw new IllegalArgumentException("Dify 地址必须是绝对 HTTP 地址"); + } + return uri; + } catch (IllegalArgumentException e) { + throw new GaoAiAccessException("智能体服务配置无效", 503); + } + } + + /// 编码 URL 路径片段 + /// + /// @param value 路径片段 + /// @return 编码后的路径片段 + private String encodePathSegment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + /// 标准化分页条数 + /// + /// @param limit 请求的分页条数 + /// @return 标准化后的分页条数 + private int normalizeLimit(Integer limit) { + int value = limit == null ? 20 : limit; + if (!(0 < value) || 100 < value) { + throw new GaoAiAccessException("分页条数必须在 1 至 100 之间", 400); + } + return value; + } + + /// 标准化会话排序方式 + /// + /// @param sortBy 请求的排序方式 + /// @return 标准化后的排序方式 + private String normalizeSortBy(String sortBy) { + String value = isBlank(sortBy) ? "-updated_at" : sortBy.trim(); + if (!"created_at".equals(value) && !"-created_at".equals(value) + && !"updated_at".equals(value) && !"-updated_at".equals(value)) { + throw new GaoAiAccessException("会话排序方式无效", 400); + } + return value; + } + + /// 校验必填文本参数 + /// + /// @param value 参数值 + /// @param name 参数名称 + /// @param maxLength 最大长度 + /// @return 去除首尾空白后的参数值 + private String requiredValue(String value, String name, int maxLength) { + String result = optionalValue(value, name, maxLength); + if (result == null) { + throw new GaoAiAccessException(name + "不能为空", 400); + } + return result; + } + + /// 校验可选文本参数 + /// + /// @param value 参数值 + /// @param name 参数名称 + /// @param maxLength 最大长度 + /// @return 去除首尾空白后的参数值,无值时返回 null + private String optionalValue(String value, String name, int maxLength) { + if (isBlank(value)) { + return null; + } + String result = value.trim(); + if (maxLength < result.length()) { + throw new GaoAiAccessException(name + "无效", 400); + } + return result; + } + private void readResponse(HttpResponse response, SseEmitter emitter, AtomicBoolean finished, AtomicReference>> upstreamRef, AtomicReference> timeoutRef, AtomicReference status, AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt, @@ -228,7 +501,11 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { if (line.startsWith("event:")) { sseEvent.set(line.substring(6).trim()); } else if (line.startsWith("data:")) { - data.append(line.substring(5).trim()); + if (!data.isEmpty()) { + data.append('\n'); + } + String dataLine = line.substring(5); + data.append(dataLine.startsWith(" ") ? dataLine.substring(1) : dataLine); } else if (line.isEmpty() && !data.isEmpty()) { processData(data.toString(), sseEvent.get(), emitter, status, finished, timeoutRef, auditRecorded, session, message, startedAt); @@ -292,9 +569,13 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { } switch (event) { case "message", "agent_message", "text_chunk" -> { - String answer = firstText(text(payload, "answer", null), text(payload, "text", null)); - if (answer != null) { - safeSend(emitter, SseEmitter.event().name("message").data(answer), finished); + String answer = firstRawText(payload, "answer"); + if (answer == null) { + answer = firstRawText(payload, "text"); + } + if (answer != null && !answer.isEmpty()) { + Map messageData = Map.of("answer", answer); + safeSend(emitter, SseEmitter.event().name("message").data(messageData), finished); } } case "message_end" -> emitDone(emitter, session, startedAt, finished, timeoutRef, auditRecorded, message, status); @@ -446,6 +727,13 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { return fallback; } + private String firstRawText(JsonNode node, String field) { + if (node != null && node.hasNonNull(field)) { + return node.get(field).asText(); + } + return null; + } + private String firstText(String... values) { for (String value : values) { if (!isBlank(value)) { diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationListRequest.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationListRequest.java new file mode 100644 index 0000000..40cdd1d --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationListRequest.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; + +/// GAO 智能体历史会话列表请求 +/// +/// 字段与 Dify 会话列表分页参数对应,但用户标识由后端注入 +/// +/// @author Codex +/// @since 2026-09-16 +@Data +public class GaoAiConversationListRequest { + + /// 上一页最后一条会话 ID,可为空 + private String lastId; + + /// 返回条数,可为空 + private Integer limit; + + /// 排序方式,可选 `created_at`、`-created_at`、`updated_at`、`-updated_at` + private String sortBy; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationReadRequest.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationReadRequest.java new file mode 100644 index 0000000..c1d6d43 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationReadRequest.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; + +/// GAO 智能体历史会话消息读取请求 +/// +/// 用户标识由后端注入,前端只能指定会话和分页游标 +/// +/// @author Codex +/// @since 2026-09-16 +@Data +public class GaoAiConversationReadRequest { + + /// Dify 会话 ID + private String conversationId; + + /// 上一页第一条消息 ID,可为空 + private String firstId; + + /// 返回条数,可为空 + private Integer limit; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java index cc58962..3a95d7c 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java +++ b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java @@ -17,11 +17,11 @@ public enum GaoSystemSetting implements SettingEnum { DIFY_APP_API_KEY(null, Setting.ValueType.STRING), - SERVICE_LICENSE(null, Setting.ValueType.STRING), + DIFY_MCP_SERVICE_KEY(null, Setting.ValueType.STRING), - PRIVACY_LICENSE(null, Setting.ValueType.STRING), + AI_AGENT_EXAMPLE(null, Setting.ValueType.JSON_LIST), - OPEN_SOURCE_LICENSE(null, Setting.ValueType.STRING); + AGREEMENT(null, Setting.ValueType.JSON_OBJECT); final String defaultValue; diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoEventRecordController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoEventRecordController.java index 4a4073f..4e066da 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoEventRecordController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoEventRecordController.java @@ -69,6 +69,7 @@ public class GaoEventRecordController { result.setTotal(pageResult.getTotal()); result.setList(pageResult.getList()); result.setNumberValueTotal(resolveNumberValueTotal(page)); + result.setCustomerCount(service.countCustomerByRange(page)); { List customerIdList = result.getList().stream().map(GaoEventRecord::getCustomerId).toList(); Map eventMap = eventService.mapByIdList(result.getList().stream().map(GaoEventRecord::getEventId).toList()); diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoFundRecordController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoFundRecordController.java index f4681f6..aa90fdb 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoFundRecordController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoFundRecordController.java @@ -4,7 +4,9 @@ import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.common.service.AttachmentService; import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; +import com.imyeyu.api.modules.gao.entity.GaoCustomer; import com.imyeyu.api.modules.gao.entity.GaoFundRecord; +import com.imyeyu.api.modules.gao.service.GaoCustomerService; import com.imyeyu.api.modules.gao.service.GaoFundRecordService; import com.imyeyu.api.modules.gao.service.GaoOrderPaymentService; import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; @@ -39,6 +41,7 @@ import java.util.stream.Stream; public class GaoFundRecordController { private final GaoFundRecordService service; + private final GaoCustomerService customerService; private final GaoOrderPaymentService orderPaymentService; private final GaoStoreScopeService storeScopeService; private final AttachmentService attachmentService; @@ -124,10 +127,23 @@ public class GaoFundRecordController { .distinct() .toList(); Map userMap = userIdList.isEmpty() ? Map.of() : userService.mapByIdList(userIdList); + List customerIdList = recordList.stream() + .map(GaoFundRecord::getCustomerId) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + Map customerMap = customerIdList.isEmpty() ? Map.of() : customerService.mapByIdList(customerIdList); + Map> customerPhotoMap = customerIdList.isEmpty() + ? Map.of() : attachmentService.mapByBizIdList(Attachment.BizType.GAO_CUSTOMER, customerIdList, GaoCustomer.AttachType.PHOTO.name()); for (GaoFundRecord record : recordList) { record.setAttachmentList(attachmentMap.getOrDefault(record.getId(), List.of())); record.setOperator(userMap.get(record.getOperatorUserId())); record.setVerifier(userMap.get(record.getVerifierUserId())); + GaoCustomer customer = customerMap.get(record.getCustomerId()); + if (customer != null) { + customer.setAttachmentList(customerPhotoMap.getOrDefault(customer.getId(), List.of())); + } + record.setCustomer(customer); } } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoOrderFulfillmentController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoOrderFulfillmentController.java index 940c519..db7efa6 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoOrderFulfillmentController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoOrderFulfillmentController.java @@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.controller; import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.bean.PermissionMatchMode; import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; import com.imyeyu.api.modules.gao.entity.GaoCustomer; @@ -78,6 +79,16 @@ public class GaoOrderFulfillmentController { return result; } + @AOPLog + @RequestRateLimit + @RequireGaoPermission(value = { GaoPermissionCode.ORDER_FULFILLMENT_CREATE, GaoPermissionCode.ORDER_FULFILLMENT_VERIFY }, mode = PermissionMatchMode.ALL) + @PostMapping("/create-and-verify") + public GaoOrderFulfillment createAndVerify(@RequestBody GaoOrderFulfillmentCreateRequest request) { + GaoOrderFulfillment result = service.createAndVerify(request); + fillTransient(List.of(result)); + return result; + } + @AOPLog @RequestRateLimit @RequireGaoPermission(GaoPermissionCode.ORDER_FULFILLMENT_VERIFY) diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPackageConfigController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPackageConfigController.java index 840152d..0474edc 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPackageConfigController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPackageConfigController.java @@ -101,8 +101,8 @@ public class GaoPackageConfigController { if (skuIdList.isEmpty()) { return; } - Map skuMap = skuIdList.stream() - .collect(java.util.stream.Collectors.toMap(item -> item, skuService::detail)); + Map skuMap = skuService.listByIdList(skuIdList).stream() + .collect(java.util.stream.Collectors.toMap(GaoProductSku::getId, item -> item)); for (GaoPackageConfig config : configList) { for (GaoPackageConfigItem item : config.getItemList()) { item.setSku(skuMap.get(item.getSkuId())); diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoProductStockController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoProductStockController.java index b8e4777..0bf0849 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoProductStockController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoProductStockController.java @@ -7,6 +7,7 @@ import com.imyeyu.api.modules.gao.entity.GaoProductStockRecord; import com.imyeyu.api.modules.gao.service.GaoProductStockService; import com.imyeyu.api.modules.gao.service.GaoProductStockRecordService; import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; +import com.imyeyu.api.modules.gao.vo.GaoProductStockBatchRequest; import com.imyeyu.api.modules.gao.vo.GaoProductStockChangeRequest; import com.imyeyu.api.modules.gao.vo.GaoProductStockManualRevokeRequest; import com.imyeyu.api.modules.gao.vo.GaoProductStockPage; @@ -23,6 +24,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /// GAO 产品库存接口 /// /// @author Codex @@ -64,6 +67,16 @@ public class GaoProductStockController { return stockService.detail(storeScopeService.resolveStoreId(storeId), skuId); } + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.PRODUCT_STOCK_READ) + @PostMapping("/batch-detail") + public List batchDetail(@RequestBody GaoProductStockBatchRequest request) { + TimiException.required(request, "not found stock batch request"); + request.setStoreId(storeScopeService.resolveStoreId(request.getStoreId())); + return stockService.batchDetail(request.getStoreId(), request.getSkuIdList()); + } + @AOPLog @RequestRateLimit @RequireGaoPermission(GaoPermissionCode.PRODUCT_STOCK_CREATE) diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoSalesOrderController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoSalesOrderController.java index 07222c5..36bc23e 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoSalesOrderController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoSalesOrderController.java @@ -102,6 +102,14 @@ public class GaoSalesOrderController { return paymentService.receive(request); } + @AOPLog + @RequestRateLimit + @RequireGaoPermission(value = { GaoPermissionCode.FUND_RECEIVE_CREATE, GaoPermissionCode.FUND_RECEIVE_VERIFY }, mode = PermissionMatchMode.ALL) + @PostMapping("/payment/receive-and-verify") + public GaoFundRecord receiveAndVerify(@RequestBody GaoOrderPaymentReceiveRequest request) { + return paymentService.receiveAndVerify(request); + } + @AOPLog @RequestRateLimit @RequireGaoPermission(GaoPermissionCode.FUND_RECEIVE_VERIFY) diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoFundRecord.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoFundRecord.java index 20390d0..f4eb139 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoFundRecord.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoFundRecord.java @@ -120,6 +120,10 @@ public class GaoFundRecord extends UUIDEntity { @Transient private User verifier; + /// 所属客户 + @Transient + private GaoCustomer customer; + /// 附件 @Transient private List attachmentList; diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoProductStockRecord.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoProductStockRecord.java index 02fb527..fa696d9 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoProductStockRecord.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoProductStockRecord.java @@ -168,6 +168,10 @@ public class GaoProductStockRecord implements IDEntity, Creatable { @Transient private User operator; + /// 所属客户 + @Transient + private GaoCustomer customer; + /// 附件 @Transient private List attachmentList; diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoEventRecordMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoEventRecordMapper.java index a71e9af..c71dc64 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoEventRecordMapper.java +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoEventRecordMapper.java @@ -87,6 +87,12 @@ public interface GaoEventRecordMapper extends BaseMapper /// @return 登记事件记录列表 List selectByRange(GaoEventRecordPage page); + /// 按登记记录列表条件统计去重客户数量 + /// + /// @param page 登记记录范围查询请求 + /// @return 去重后的客户数量 + long countCustomerByRange(GaoEventRecordPage page); + /// 按登记记录列表条件汇总数值 /// /// @param page 登记记录范围查询请求 diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoOrderFulfillmentMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoOrderFulfillmentMapper.java index 55fc95c..93209b2 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoOrderFulfillmentMapper.java +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoOrderFulfillmentMapper.java @@ -5,6 +5,7 @@ import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentPage; import com.imyeyu.spring.mapper.BaseMapper; import org.apache.ibatis.annotations.Param; +import java.util.Collection; import java.util.List; /// GAO 统一履约 Mapper @@ -19,6 +20,9 @@ public interface GaoOrderFulfillmentMapper extends BaseMapper selectByIdList(@Param("idList") Collection idList); + /// 锁定履约单 GaoOrderFulfillment selectForUpdate(@Param("id") String id); diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoProductStockMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoProductStockMapper.java index f9f8f7f..ee280c6 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoProductStockMapper.java +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoProductStockMapper.java @@ -32,6 +32,9 @@ public interface GaoProductStockMapper extends BaseMapper selectByStoreIdAndSkuIdList(@Param("storeId") String storeId, @Param("skuIdList") List skuIdList); + /// 条件更新库存余额 int updateQuantity(@Param("id") String id, @Param("onHandDelta") java.math.BigDecimal onHandDelta, @Param("reservedDelta") java.math.BigDecimal reservedDelta, @Param("updatedBy") String updatedBy); diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java index a798883..5c4944d 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java @@ -117,6 +117,12 @@ public interface GaoEventRecordService extends BaseService pageByRange(GaoEventRecordPage req); + /// 按登记记录列表条件统计去重客户数量 + /// + /// @param req 登记记录范围查询请求 + /// @return 去重后的客户数量 + long countCustomerByRange(GaoEventRecordPage req); + /// 按时间范围统计门店登记数 /// /// @param req 门店报表查询请求 diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoOrderFulfillmentService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoOrderFulfillmentService.java index d80f6a7..ebe84a2 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoOrderFulfillmentService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoOrderFulfillmentService.java @@ -32,6 +32,9 @@ public interface GaoOrderFulfillmentService extends BaseService batchDetail(String storeId, List skuIdList); + /// 期初入库 GaoProductStock initialIn(GaoProductStockChangeRequest request); diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerServiceImplement.java index 3634c13..c270049 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerServiceImplement.java @@ -159,6 +159,17 @@ public class GaoCustomerServiceImplement extends AbstractEntityService stateDailyByRange(GaoStoreChartReq req) { return mapper.stateDailyByRange(req); diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoOrderFulfillmentServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoOrderFulfillmentServiceImplement.java index 2bf2ef4..60ac386 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoOrderFulfillmentServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoOrderFulfillmentServiceImplement.java @@ -153,6 +153,19 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService implements GaoProductService { private final GaoProductMapper mapper; + private final GaoProductStockMapper stockMapper; private final AttachmentService attachmentService; private final ObjectProvider categoryServiceProvider; private final ObjectProvider skuServiceProvider; @@ -120,6 +123,9 @@ public class GaoProductServiceImplement extends AbstractEntityService skuIdList = skuServiceProvider.getObject().listByProductId(product.getId()).stream() + .map(GaoProductSku::getId) + .filter(id -> id != null && !id.isBlank()) + .toList(); + if (skuIdList.isEmpty()) { + return; + } + boolean hasReservedStock = stockMapper.selectByStoreIdAndSkuIdList(product.getStoreId(), skuIdList).stream() + .anyMatch(stock -> stock.getReservedQuantity() != null && 0 < stock.getReservedQuantity().signum()); + TimiException.requiredTrue(!hasReservedStock, "当前产品存在预占库存,不能切换为服务产品,请先完成或取消相关订单"); + } + @OperationAudit(AuditOperation.GAO_PRODUCT_DISABLE) @Transactional(TimiServerDBConfig.ROLLBACKER) @Override diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockRecordServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockRecordServiceImplement.java index 1cc297d..f4b9590 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockRecordServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockRecordServiceImplement.java @@ -2,8 +2,12 @@ package com.imyeyu.api.modules.gao.service.implement; import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.modules.gao.entity.GaoCustomer; +import com.imyeyu.api.modules.gao.entity.GaoOrderFulfillment; import com.imyeyu.api.modules.gao.entity.GaoProductStockRecord; +import com.imyeyu.api.modules.gao.mapper.GaoOrderFulfillmentMapper; import com.imyeyu.api.modules.gao.mapper.GaoProductStockRecordMapper; +import com.imyeyu.api.modules.gao.service.GaoCustomerService; import com.imyeyu.api.modules.gao.service.GaoProductStockRecordService; import com.imyeyu.api.modules.gao.vo.GaoProductStockRecordPage; import com.imyeyu.api.modules.user.entity.User; @@ -18,6 +22,7 @@ import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /// GAO 库存流水服务实现 /// @@ -28,7 +33,9 @@ import java.util.Map; public class GaoProductStockRecordServiceImplement extends AbstractEntityService implements GaoProductStockRecordService { private final GaoProductStockRecordMapper mapper; + private final GaoOrderFulfillmentMapper orderFulfillmentMapper; private final AttachmentService attachmentService; + private final GaoCustomerService customerService; private final UserService userService; @Override @@ -133,10 +140,46 @@ public class GaoProductStockRecordServiceImplement extends AbstractEntityService List userIdList = recordList.stream().map(GaoProductStockRecord::getOperatorUserId) .filter(value -> value != null && !value.isBlank()).distinct().toList(); Map userMap = userIdList.isEmpty() ? Map.of() : userService.mapByIdList(userIdList); + Map> userAvatarMap = userIdList.isEmpty() + ? Map.of() : attachmentService.mapByBizIdList(Attachment.BizType.USER, userIdList, User.AttachType.AVATAR.name()); + List fulfillmentIdList = recordList.stream() + .filter(record -> record.getBizType() == GaoProductStockRecord.BizType.ORDER_FULFILLMENT) + .map(GaoProductStockRecord::getBizId) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + Map fulfillmentMap = fulfillmentIdList.isEmpty() + ? Map.of() : orderFulfillmentMapper.selectByIdList(fulfillmentIdList).stream() + .collect(Collectors.toMap(GaoOrderFulfillment::getId, item -> item)); + List customerIdList = recordList.stream() + .map(record -> getCustomerId(record, fulfillmentMap)) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + Map customerMap = customerIdList.isEmpty() ? Map.of() : customerService.mapByIdList(customerIdList); + Map> customerPhotoMap = customerIdList.isEmpty() + ? Map.of() : attachmentService.mapByBizIdList(Attachment.BizType.GAO_CUSTOMER, customerIdList, GaoCustomer.AttachType.PHOTO.name()); for (GaoProductStockRecord record : recordList) { record.setAttachmentList(attachmentMap.getOrDefault(record.getId(), List.of())); - record.setOperator(userMap.get(record.getOperatorUserId())); + User operator = userMap.get(record.getOperatorUserId()); + if (operator != null) { + operator.setAttachmentList(userAvatarMap.getOrDefault(operator.getId(), List.of())); + } + record.setOperator(operator); + GaoCustomer customer = customerMap.get(getCustomerId(record, fulfillmentMap)); + if (customer != null) { + customer.setAttachmentList(customerPhotoMap.getOrDefault(customer.getId(), List.of())); + } + record.setCustomer(customer); } } + private String getCustomerId(GaoProductStockRecord record, Map fulfillmentMap) { + if (record.getBizType() != GaoProductStockRecord.BizType.ORDER_FULFILLMENT) { + return null; + } + GaoOrderFulfillment fulfillment = fulfillmentMap.get(record.getBizId()); + return fulfillment == null ? null : fulfillment.getCustomerId(); + } + } diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockServiceImplement.java index 740cb50..5cca2e0 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockServiceImplement.java @@ -68,7 +68,8 @@ public class GaoProductStockServiceImplement implements GaoProductStockService { List list = stockMapper.selectByQuery(page); Map photoMap = attachmentService.mapFirstImageByIdList( Attachment.BizType.GAO_PRODUCT_SKU, - list.stream().map(GaoProductStock::getSkuId).filter(id -> id != null && !id.isBlank()).toList() + list.stream().map(GaoProductStock::getSkuId).filter(id -> id != null && !id.isBlank()).toList(), + "PHOTO" ); for (GaoProductStock stock : list) { fillBalance(stock); @@ -86,7 +87,17 @@ public class GaoProductStockServiceImplement implements GaoProductStockService { TimiException.required(page, "not found stock product page"); PageResult result = new PageResult<>(); result.setTotal(stockMapper.countProductByQuery(page)); - result.setList(stockMapper.selectProductByQuery(page)); + List list = stockMapper.selectProductByQuery(page); + Map photoMap = attachmentService.mapFirstImageByIdList( + Attachment.BizType.GAO_PRODUCT, + list.stream().map(GaoProductStockProductView::getProductId).filter(id -> id != null && !id.isBlank()).toList(), + "PHOTO" + ); + for (GaoProductStockProductView product : list) { + Attachment photo = photoMap.get(product.getProductId()); + product.setAttachmentList(photo == null ? List.of() : List.of(photo)); + } + result.setList(list); return result; } @@ -111,6 +122,27 @@ public class GaoProductStockServiceImplement implements GaoProductStockService { return stock; } + @Override + public List batchDetail(String storeId, List skuIdList) { + TimiException.required(storeId, "not found storeId"); + storeScopeService.checkStoreId(storeId); + if (skuIdList == null || skuIdList.isEmpty()) { + return List.of(); + } + List normalizedSkuIdList = skuIdList.stream() + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + if (normalizedSkuIdList.isEmpty()) { + return List.of(); + } + List result = stockMapper.selectByStoreIdAndSkuIdList(storeId, normalizedSkuIdList); + for (GaoProductStock stock : result) { + fillBalance(stock); + } + return result; + } + @OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_INITIAL_IN) @Transactional(TimiServerDBConfig.ROLLBACKER) @Override diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoEventRecordPageResult.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoEventRecordPageResult.java index eb96821..00e96e3 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoEventRecordPageResult.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoEventRecordPageResult.java @@ -19,4 +19,8 @@ public class GaoEventRecordPageResult extends PageResult { /// 指定单个数值事件的登记数值合计,未指定单个 NUMBER 事件时为空 @JsonView(ResponseView.Public.class) private Long numberValueTotal; + + /// 查询条件下去重后的客户数量 + @JsonView(ResponseView.Public.class) + private Long customerCount; } diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockBatchRequest.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockBatchRequest.java new file mode 100644 index 0000000..3a6e6d1 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockBatchRequest.java @@ -0,0 +1,19 @@ +package com.imyeyu.api.modules.gao.vo; + +import lombok.Data; + +import java.util.List; + +/// GAO 产品库存批量查询请求 +/// +/// @author Codex +/// @since 2026-09-16 +@Data +public class GaoProductStockBatchRequest { + + /// 门店 ID + private String storeId; + + /// SKU ID 列表 + private List skuIdList; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockProductView.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockProductView.java index bb891d0..ed4ee30 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockProductView.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockProductView.java @@ -1,9 +1,11 @@ package com.imyeyu.api.modules.gao.vo; +import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.gao.entity.GaoProduct; import lombok.Data; import java.math.BigDecimal; +import java.util.List; /// 产品库存第一层汇总视图 /// @@ -21,6 +23,9 @@ public class GaoProductStockProductView { /// 产品名称 private String productName; + /// 产品图片 + private List attachmentList; + /// 产品类型 private GaoProduct.ProductType productType; diff --git a/src/main/resources/mapper/timi-server/gao/GaoEventRecordMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoEventRecordMapper.xml index 6cfed79..b4ffdf2 100644 --- a/src/main/resources/mapper/timi-server/gao/GaoEventRecordMapper.xml +++ b/src/main/resources/mapper/timi-server/gao/GaoEventRecordMapper.xml @@ -167,6 +167,13 @@ ORDER BY r.`registered_at` DESC, r.`created_at` DESC LIMIT #{offset}, #{limit} + + +