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}
+
+
+