diff --git a/docs/assistant-api.md b/docs/assistant-api.md new file mode 100644 index 0000000..2711274 --- /dev/null +++ b/docs/assistant-api.md @@ -0,0 +1,55 @@ +# 企业微信智能机器人 API 插件 + +两个接口均暂不校验 Authorization 或 API Key。 + +## 问答小助手 + +- OpenAPI 配置地址:`/api/assistant/openapi/general.json` +- 业务接口:`POST /api/assistant/general/query` +- 请求: + +```json +{ + "query": "测试表" +} +``` + +- `query` 同时兼容 `content`、`userText`、`message` 和 `input`。 +- 处理逻辑保持不变:直接调用原有扣子 `wecom-default` 工作流,不经过 DeepSeek 语义识别。 + +## 运动馆智能助手 + +- OpenAPI 配置地址:`/api/assistant/openapi/sports-venue.json` +- 业务接口:`POST /api/assistant/sports-venue/query` +- 请求: + +```json +{ + "query": "查看本月EC电话合规监控日报", + "userId": "YiNianYiQingJing" +} +``` + +- `query` 同时兼容 `content`、`userText`、`message` 和 `input`。 +- `userId` 同时兼容 `wecomUserId`、`userid` 和 `user_id`。 +- 处理逻辑保持不变:DeepSeek 语义识别 → 日期解析 → UserID 数据权限 → 对应日报处理器。 +- 同一个 UserID 同时只能执行一条运动馆指令。 + +## 返回结构 + +```json +{ + "success": true, + "code": 0, + "content": "机器人直接回复给用户的内容", + "attachments": [ + { + "type": "excel", + "name": "日报.xlsx", + "downloadUrl": "http://agent.yuedongsports.com/api/assistant/files/文件ID" + } + ] +} +``` + +机器人应将 `content` 作为最终回复。Excel 下载链接会同时附加到 `content` 中,文件链接有效期为 30 分钟。 diff --git a/src/main/java/com/company/agentfoundation/assistant/api/AssistantApiController.java b/src/main/java/com/company/agentfoundation/assistant/api/AssistantApiController.java new file mode 100644 index 0000000..323121c --- /dev/null +++ b/src/main/java/com/company/agentfoundation/assistant/api/AssistantApiController.java @@ -0,0 +1,124 @@ +package com.company.agentfoundation.assistant.api; + +import com.company.agentfoundation.integration.coze.service.CozeWorkflowService; +import com.company.agentfoundation.semantic.SemanticDispatchResult; +import com.company.agentfoundation.semantic.SemanticDispatchService; +import com.company.agentfoundation.semantic.SemanticReplyAttachment; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@RestController +@RequestMapping("/api/assistant") +public class AssistantApiController { + private static final Logger log = LoggerFactory.getLogger(AssistantApiController.class); + + private final CozeWorkflowService cozeWorkflowService; + private final SemanticDispatchService semanticDispatchService; + private final AssistantFileStore fileStore; + private final Set runningUsers = ConcurrentHashMap.newKeySet(); + + public AssistantApiController( + CozeWorkflowService cozeWorkflowService, + SemanticDispatchService semanticDispatchService, + AssistantFileStore fileStore + ) { + this.cozeWorkflowService = cozeWorkflowService; + this.semanticDispatchService = semanticDispatchService; + this.fileStore = fileStore; + } + + @PostMapping("/general/query") + public AssistantQueryResponse generalQuery(@RequestBody AssistantQueryRequest request) { + String query = request == null ? "" : request.normalizedQuery(); + if (query.isEmpty()) return AssistantQueryResponse.failure("query不能为空"); + try { + return AssistantQueryResponse.success(cozeWorkflowService.chatDefault(query), List.of()); + } catch (RuntimeException exception) { + log.error("assistant general api failed err={}", exception.getMessage(), exception); + return AssistantQueryResponse.failure("扣子问答调用失败,请稍后重试。"); + } + } + + @PostMapping("/sports-venue/query") + public AssistantQueryResponse sportsVenueQuery(@RequestBody AssistantQueryRequest request) { + String query = request == null ? "" : request.normalizedQuery(); + String userId = request == null ? "" : request.normalizedUserId(); + if (query.isEmpty()) return AssistantQueryResponse.failure("query不能为空"); + if (userId.isEmpty()) return AssistantQueryResponse.failure("userId不能为空,数据权限需要企业微信UserID"); + + String runningKey = userId.toLowerCase(Locale.ROOT); + if (!runningUsers.add(runningKey)) { + return AssistantQueryResponse.failure("正在处理上一条指令,请等待结果返回后再试。"); + } + try { + SemanticDispatchResult result = semanticDispatchService.dispatchWithAttachments(userId, query); + List attachments = store(result.attachments()); + return AssistantQueryResponse.success(appendAttachmentLinks(result.reply(), attachments), attachments); + } catch (RuntimeException exception) { + log.error("assistant sports venue api failed userId={} err={}", + userId, exception.getMessage(), exception); + return AssistantQueryResponse.failure("语义识别或业务查询失败,请稍后重试。"); + } finally { + runningUsers.remove(runningKey); + } + } + + @GetMapping("/files/{fileId}") + public ResponseEntity download(@PathVariable String fileId) { + return fileStore.find(fileId) + .map(file -> ResponseEntity.ok() + .contentType(file.mediaType()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment() + .filename(file.fileName(), StandardCharsets.UTF_8) + .build().toString()) + .body(file.content())) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + private List store(List values) { + List result = new ArrayList<>(); + for (SemanticReplyAttachment value : values) { + String id = fileStore.save(value); + String url = ServletUriComponentsBuilder.fromCurrentContextPath() + .path("/api/assistant/files/") + .path(id) + .build() + .toUriString(); + result.add(new AssistantQueryResponse.Attachment( + value.type().name().toLowerCase(Locale.ROOT), value.fileName(), url)); + } + return List.copyOf(result); + } + + private String appendAttachmentLinks( + String reply, + List attachments + ) { + if (attachments.isEmpty()) return reply == null ? "" : reply; + StringBuilder content = new StringBuilder(reply == null ? "" : reply); + content.append("\n\n附件:"); + for (AssistantQueryResponse.Attachment attachment : attachments) { + content.append("\n- [").append(attachment.name()).append("](") + .append(attachment.downloadUrl()).append(")"); + } + return content.toString(); + } +} diff --git a/src/main/java/com/company/agentfoundation/assistant/api/AssistantFileStore.java b/src/main/java/com/company/agentfoundation/assistant/api/AssistantFileStore.java new file mode 100644 index 0000000..8378d9c --- /dev/null +++ b/src/main/java/com/company/agentfoundation/assistant/api/AssistantFileStore.java @@ -0,0 +1,75 @@ +package com.company.agentfoundation.assistant.api; + +import com.company.agentfoundation.semantic.SemanticReplyAttachment; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class AssistantFileStore { + private static final Duration FILE_TTL = Duration.ofMinutes(30); + private static final int MAX_FILES = 200; + private static final String EXCEL_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + private final Map files = new ConcurrentHashMap<>(); + + public String save(SemanticReplyAttachment attachment) { + cleanup(); + String id = UUID.randomUUID().toString().replace("-", ""); + String contentType = switch (attachment.type()) { + case EXCEL -> EXCEL_CONTENT_TYPE; + }; + files.put(id, new StoredFile( + attachment.fileName(), + contentType, + attachment.content() == null ? new byte[0] : attachment.content().clone(), + Instant.now().plus(FILE_TTL))); + trimToLimit(); + return id; + } + + public Optional find(String id) { + cleanup(); + StoredFile value = files.get(id); + if (value == null || value.expiresAt().isBefore(Instant.now())) { + if (value != null) files.remove(id); + return Optional.empty(); + } + return Optional.of(value); + } + + private void cleanup() { + Instant now = Instant.now(); + files.entrySet().removeIf(entry -> entry.getValue().expiresAt().isBefore(now)); + } + + private void trimToLimit() { + int overflow = files.size() - MAX_FILES; + if (overflow <= 0) return; + files.entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getValue().expiresAt())) + .limit(overflow) + .map(Map.Entry::getKey) + .toList() + .forEach(files::remove); + } + + public record StoredFile( + String fileName, + String contentType, + byte[] content, + Instant expiresAt + ) { + public MediaType mediaType() { + return MediaType.parseMediaType(contentType); + } + } +} diff --git a/src/main/java/com/company/agentfoundation/assistant/api/AssistantOpenApiController.java b/src/main/java/com/company/agentfoundation/assistant/api/AssistantOpenApiController.java new file mode 100644 index 0000000..60b8538 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/assistant/api/AssistantOpenApiController.java @@ -0,0 +1,96 @@ +package com.company.agentfoundation.assistant.api; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/assistant/openapi") +public class AssistantOpenApiController { + @GetMapping({"/general", "/general.json"}) + public Map general() { + return specification( + "问答小助手API", + "/api/assistant/general/query", + "generalAssistantQuery", + "将用户输入发送给扣子问答工作流并返回回答", + false); + } + + @GetMapping({"/sports-venue", "/sports-venue.json"}) + public Map sportsVenue() { + return specification( + "运动馆智能助手API", + "/api/assistant/sports-venue/query", + "sportsVenueAssistantQuery", + "根据用户输入进行语义识别,并按企业微信UserID权限查询客资、学员、企业微信客户运营或EC日报", + true); + } + + private Map specification( + String title, + String path, + String operationId, + String description, + boolean requireUserId + ) { + Map properties = new LinkedHashMap<>(); + properties.put("query", Map.of( + "type", "string", + "description", "用户在企业微信中输入的完整问题或指令", + "example", requireUserId ? "查看今日客资管理日报" : "测试表")); + if (requireUserId) { + properties.put("userId", Map.of( + "type", "string", + "description", "当前提问人的企业微信UserID,用于数据权限判断", + "example", "YiNianYiQingJing")); + } + + Map requestSchema = new LinkedHashMap<>(); + requestSchema.put("type", "object"); + requestSchema.put("required", requireUserId ? List.of("query", "userId") : List.of("query")); + requestSchema.put("properties", properties); + + Map attachmentProperties = Map.of( + "type", Map.of("type", "string"), + "name", Map.of("type", "string"), + "downloadUrl", Map.of("type", "string")); + Map attachmentSchema = Map.of( + "type", "object", + "properties", attachmentProperties); + Map responseProperties = Map.of( + "success", Map.of("type", "boolean"), + "code", Map.of("type", "integer"), + "content", Map.of("type", "string", "description", "机器人应直接回复给用户的内容"), + "attachments", Map.of( + "type", "array", + "items", attachmentSchema)); + Map responseSchema = Map.of( + "type", "object", + "properties", responseProperties); + + Map operation = new LinkedHashMap<>(); + operation.put("operationId", operationId); + operation.put("summary", title); + operation.put("description", description); + operation.put("requestBody", Map.of( + "required", true, + "content", Map.of("application/json", Map.of("schema", requestSchema)))); + operation.put("responses", Map.of( + "200", Map.of( + "description", "处理结果", + "content", Map.of("application/json", Map.of("schema", responseSchema))))); + + String server = ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString(); + return Map.of( + "openapi", "3.0.1", + "info", Map.of("title", title, "version", "1.0.0", "description", description), + "servers", List.of(Map.of("url", server)), + "paths", Map.of(path, Map.of("post", operation))); + } +} diff --git a/src/main/java/com/company/agentfoundation/assistant/api/AssistantQueryRequest.java b/src/main/java/com/company/agentfoundation/assistant/api/AssistantQueryRequest.java new file mode 100644 index 0000000..f6e91c6 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/assistant/api/AssistantQueryRequest.java @@ -0,0 +1,16 @@ +package com.company.agentfoundation.assistant.api; + +import com.fasterxml.jackson.annotation.JsonAlias; + +public record AssistantQueryRequest( + @JsonAlias({"content", "userText", "message", "input"}) String query, + @JsonAlias({"wecomUserId", "userid", "user_id"}) String userId +) { + public String normalizedQuery() { + return query == null ? "" : query.trim(); + } + + public String normalizedUserId() { + return userId == null ? "" : userId.trim(); + } +} diff --git a/src/main/java/com/company/agentfoundation/assistant/api/AssistantQueryResponse.java b/src/main/java/com/company/agentfoundation/assistant/api/AssistantQueryResponse.java new file mode 100644 index 0000000..f9ba83b --- /dev/null +++ b/src/main/java/com/company/agentfoundation/assistant/api/AssistantQueryResponse.java @@ -0,0 +1,29 @@ +package com.company.agentfoundation.assistant.api; + +import java.util.List; + +public record AssistantQueryResponse( + boolean success, + int code, + String content, + List attachments +) { + public AssistantQueryResponse { + content = content == null ? "" : content; + attachments = attachments == null ? List.of() : List.copyOf(attachments); + } + + public static AssistantQueryResponse success(String content, List attachments) { + return new AssistantQueryResponse(true, 0, content, attachments); + } + + public static AssistantQueryResponse failure(String content) { + return new AssistantQueryResponse(false, 1, content, List.of()); + } + + public record Attachment( + String type, + String name, + String downloadUrl + ) {} +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8152f7e..83fce0e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,6 @@ server: port: 8880 + forward-headers-strategy: framework spring: application: name: agent-foundation diff --git a/src/test/java/com/company/agentfoundation/assistant/api/AssistantApiControllerTest.java b/src/test/java/com/company/agentfoundation/assistant/api/AssistantApiControllerTest.java new file mode 100644 index 0000000..8c8465b --- /dev/null +++ b/src/test/java/com/company/agentfoundation/assistant/api/AssistantApiControllerTest.java @@ -0,0 +1,101 @@ +package com.company.agentfoundation.assistant.api; + +import com.company.agentfoundation.integration.coze.service.CozeWorkflowService; +import com.company.agentfoundation.semantic.SemanticDispatchResult; +import com.company.agentfoundation.semantic.SemanticDispatchService; +import com.company.agentfoundation.semantic.SemanticReplyAttachment; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AssistantApiControllerTest { + @Test + void generalApiCallsOriginalCozeWorkflowWithoutAuthorizationHeader() throws Exception { + CozeWorkflowService coze = mock(CozeWorkflowService.class); + when(coze.chatDefault("测试表")).thenReturn("扣子回答"); + MockMvc mvc = mvc(coze, mock(SemanticDispatchService.class), new AssistantFileStore()); + + mvc.perform(post("/api/assistant/general/query") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"测试表\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.content").value("扣子回答")); + + verify(coze).chatDefault("测试表"); + } + + @Test + void sportsVenueApiRunsSemanticRoutingAndReturnsDownloadableExcel() throws Exception { + SemanticDispatchService semantic = mock(SemanticDispatchService.class); + byte[] excel = {80, 75, 1, 2}; + when(semantic.dispatchWithAttachments("YiNianYiQingJing", "查看今日EC日报")) + .thenReturn(new SemanticDispatchResult( + "EC日报内容", + List.of(SemanticReplyAttachment.excel("EC日报.xlsx", excel)))); + MockMvc mvc = mvc(mock(CozeWorkflowService.class), semantic, new AssistantFileStore()); + + MvcResult result = mvc.perform(post("/api/assistant/sports-venue/query") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"content":"查看今日EC日报","wecomUserId":"YiNianYiQingJing"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.content").value(org.hamcrest.Matchers.containsString("EC日报内容"))) + .andExpect(jsonPath("$.content").value(org.hamcrest.Matchers.containsString("EC日报.xlsx"))) + .andExpect(jsonPath("$.attachments[0].name").value("EC日报.xlsx")) + .andReturn(); + + String response = result.getResponse().getContentAsString(); + String url = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response) + .path("attachments").get(0).path("downloadUrl").asText(); + String path = java.net.URI.create(url).getPath(); + MvcResult download = mvc.perform(get(path)) + .andExpect(status().isOk()) + .andReturn(); + assertThat(download.getResponse().getContentAsByteArray()).isEqualTo(excel); + } + + @Test + void sportsVenueApiRequiresUserIdForPermissionScope() throws Exception { + MockMvc mvc = mvc( + mock(CozeWorkflowService.class), + mock(SemanticDispatchService.class), + new AssistantFileStore()); + + mvc.perform(post("/api/assistant/sports-venue/query") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"查看今日客资日报\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.content").value(org.hamcrest.Matchers.containsString("userId"))); + } + + private MockMvc mvc( + CozeWorkflowService coze, + SemanticDispatchService semantic, + AssistantFileStore files + ) { + return MockMvcBuilders.standaloneSetup(new AssistantApiController(coze, semantic, files)) + .setMessageConverters( + new MappingJackson2HttpMessageConverter(), + new ByteArrayHttpMessageConverter()) + .build(); + } +} diff --git a/src/test/java/com/company/agentfoundation/assistant/api/AssistantOpenApiControllerTest.java b/src/test/java/com/company/agentfoundation/assistant/api/AssistantOpenApiControllerTest.java new file mode 100644 index 0000000..30c8c44 --- /dev/null +++ b/src/test/java/com/company/agentfoundation/assistant/api/AssistantOpenApiControllerTest.java @@ -0,0 +1,37 @@ +package com.company.agentfoundation.assistant.api; + +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AssistantOpenApiControllerTest { + private final MockMvc mvc = + MockMvcBuilders.standaloneSetup(new AssistantOpenApiController()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .build(); + + @Test + void exposesGeneralAssistantOpenApiDocument() throws Exception { + mvc.perform(get("/api/assistant/openapi/general.json")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.openapi").value("3.0.1")) + .andExpect(jsonPath("$.paths['/api/assistant/general/query'].post.operationId") + .value("generalAssistantQuery")); + } + + @Test + void exposesSportsVenueOpenApiDocumentWithUserId() throws Exception { + mvc.perform(get("/api/assistant/openapi/sports-venue.json")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paths['/api/assistant/sports-venue/query'].post.operationId") + .value("sportsVenueAssistantQuery")) + .andExpect(jsonPath( + "$.paths['/api/assistant/sports-venue/query'].post.requestBody.content['application/json'].schema.required[1]") + .value("userId")); + } +}