机器人版本

This commit is contained in:
tanglong 2026-07-31 16:29:10 +08:00
parent 32a42546f3
commit 52d5bdacf3
9 changed files with 534 additions and 0 deletions

55
docs/assistant-api.md Normal file
View File

@ -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 分钟。

View File

@ -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<String> 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<AssistantQueryResponse.Attachment> 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<byte[]> 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<AssistantQueryResponse.Attachment> store(List<SemanticReplyAttachment> values) {
List<AssistantQueryResponse.Attachment> 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<AssistantQueryResponse.Attachment> 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();
}
}

View File

@ -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<String, StoredFile> 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<StoredFile> 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);
}
}
}

View File

@ -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<String, Object> general() {
return specification(
"问答小助手API",
"/api/assistant/general/query",
"generalAssistantQuery",
"将用户输入发送给扣子问答工作流并返回回答",
false);
}
@GetMapping({"/sports-venue", "/sports-venue.json"})
public Map<String, Object> sportsVenue() {
return specification(
"运动馆智能助手API",
"/api/assistant/sports-venue/query",
"sportsVenueAssistantQuery",
"根据用户输入进行语义识别,并按企业微信UserID权限查询客资、学员、企业微信客户运营或EC日报",
true);
}
private Map<String, Object> specification(
String title,
String path,
String operationId,
String description,
boolean requireUserId
) {
Map<String, Object> 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<String, Object> requestSchema = new LinkedHashMap<>();
requestSchema.put("type", "object");
requestSchema.put("required", requireUserId ? List.of("query", "userId") : List.of("query"));
requestSchema.put("properties", properties);
Map<String, Object> attachmentProperties = Map.of(
"type", Map.of("type", "string"),
"name", Map.of("type", "string"),
"downloadUrl", Map.of("type", "string"));
Map<String, Object> attachmentSchema = Map.of(
"type", "object",
"properties", attachmentProperties);
Map<String, Object> 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<String, Object> responseSchema = Map.of(
"type", "object",
"properties", responseProperties);
Map<String, Object> 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)));
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,29 @@
package com.company.agentfoundation.assistant.api;
import java.util.List;
public record AssistantQueryResponse(
boolean success,
int code,
String content,
List<Attachment> attachments
) {
public AssistantQueryResponse {
content = content == null ? "" : content;
attachments = attachments == null ? List.of() : List.copyOf(attachments);
}
public static AssistantQueryResponse success(String content, List<Attachment> 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
) {}
}

View File

@ -1,5 +1,6 @@
server:
port: 8880
forward-headers-strategy: framework
spring:
application:
name: agent-foundation

View File

@ -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();
}
}

View File

@ -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"));
}
}