首次提交

This commit is contained in:
tanglong 2026-06-01 19:09:40 +08:00
commit 3438971d10
25 changed files with 607 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target/
/.idea/
*.iml

30
README.md Normal file
View File

@ -0,0 +1,30 @@
# Agent Foundation
Java Spring Boot base platform for multiple enterprise agents (CRM assistant, knowledge-base assistant, customer service assistant).
## Core API
- POST `/v1/chat/completions`
- GET `/v1/healthz`
## WeCom CRM Bot Integration
- Callback URL: `/v1/integrations/wecom/callback`
- Receive enterprise WeCom text messages and route to `crm-assistant`
- Send the model response back to the same WeCom user
### Required env vars
- `DEEPSEEK_API_KEY`
- `WECOM_CORP_ID`
- `WECOM_AGENT_ID`
- `WECOM_SECRET`
- `WECOM_TOKEN`
### WeCom backend settings
1. Create a WeCom self-built app.
2. Enable message receive API and set callback URL to:
- `https://your-domain/v1/integrations/wecom/callback`
3. Set callback Token equal to `WECOM_TOKEN`.
4. Make sure app has send-message permission to internal users.
## Run
1. Set env vars.
2. Start app from IDE or `mvn spring-boot:run`.

23
pom.xml Normal file
View File

@ -0,0 +1,23 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<groupId>com.company</groupId>
<artifactId>agent-foundation</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.fasterxml.jackson.dataformat</groupId><artifactId>jackson-dataformat-xml</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>

View File

@ -0,0 +1,7 @@
package com.company.agentfoundation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AgentFoundationApplication {
public static void main(String[] args) { SpringApplication.run(AgentFoundationApplication.class, args); }
}

View File

@ -0,0 +1,5 @@
package com.company.agentfoundation.api;
import java.util.List;
public record DeepseekChatRequest(String model, List<Message> messages, Double temperature, Boolean stream) {
public record Message(String role, String content) {}
}

View File

@ -0,0 +1,7 @@
package com.company.agentfoundation.api;
import java.util.List;
public record DeepseekChatResponse(List<Choice> choices, Usage usage) {
public record Choice(Message message) {}
public record Message(String role, String content) {}
public record Usage(Integer prompt_tokens, Integer completion_tokens, Integer total_tokens) {}
}

View File

@ -0,0 +1,28 @@
package com.company.agentfoundation.api;
import com.company.agentfoundation.config.FoundationProperties;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.util.retry.Retry;
import java.time.Duration;
@Component
public class DeepseekClient {
private final FoundationProperties properties;
private final WebClient webClient;
public DeepseekClient(FoundationProperties properties, WebClient.Builder builder) {
this.properties = properties;
this.webClient = builder.baseUrl(properties.getDeepseek().getBaseUrl()).defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).build();
}
public DeepseekChatResponse chat(DeepseekChatRequest request) {
if (properties.getDeepseek().getApiKey() == null || properties.getDeepseek().getApiKey().isBlank()) throw new IllegalStateException("DEEPSEEK_API_KEY is missing");
return webClient.post()
.uri(properties.getDeepseek().getChatPath())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getDeepseek().getApiKey())
.bodyValue(request)
.retrieve()
.bodyToMono(DeepseekChatResponse.class)
.retryWhen(Retry.backoff(properties.getDeepseek().getMaxRetries(), Duration.ofMillis(300)))
.block(Duration.ofMillis(properties.getDeepseek().getReadTimeoutMs()));
}
}

View File

@ -0,0 +1,6 @@
package com.company.agentfoundation.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(FoundationProperties.class)
public class FoundationConfig {}

View File

@ -0,0 +1,78 @@
package com.company.agentfoundation.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.HashMap;
import java.util.Map;
@ConfigurationProperties(prefix = "foundation")
public class FoundationProperties {
private final Auth auth = new Auth();
private final ModelRouter modelRouter = new ModelRouter();
private final Deepseek deepseek = new Deepseek();
private final Wecom wecom = new Wecom();
public Auth getAuth() { return auth; }
public ModelRouter getModelRouter() { return modelRouter; }
public Deepseek getDeepseek() { return deepseek; }
public Wecom getWecom() { return wecom; }
public static class Auth {
private Map<String, String> keys = new HashMap<>();
public Map<String, String> getKeys() { return keys; }
public void setKeys(Map<String, String> keys) { this.keys = keys; }
}
public static class ModelRouter {
private String defaultModel = "deepseek-chat";
private Map<String, String> routeByAgent = new HashMap<>();
public String getDefaultModel() { return defaultModel; }
public void setDefaultModel(String defaultModel) { this.defaultModel = defaultModel; }
public Map<String, String> getRouteByAgent() { return routeByAgent; }
public void setRouteByAgent(Map<String, String> routeByAgent) { this.routeByAgent = routeByAgent; }
}
public static class Deepseek {
private String baseUrl;
private String chatPath;
private String apiKey;
private int readTimeoutMs = 60000;
private int maxRetries = 2;
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
public String getChatPath() { return chatPath; }
public void setChatPath(String chatPath) { this.chatPath = chatPath; }
public String getApiKey() { return apiKey; }
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
public int getReadTimeoutMs() { return readTimeoutMs; }
public void setReadTimeoutMs(int readTimeoutMs) { this.readTimeoutMs = readTimeoutMs; }
public int getMaxRetries() { return maxRetries; }
public void setMaxRetries(int maxRetries) { this.maxRetries = maxRetries; }
}
public static class Wecom {
private boolean enabled;
private String corpId;
private Integer agentId;
private String secret;
private String token;
private String crmAgentId = "crm-assistant";
private String callbackPath = "/v1/integrations/wecom/callback";
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getCorpId() { return corpId; }
public void setCorpId(String corpId) { this.corpId = corpId; }
public Integer getAgentId() { return agentId; }
public void setAgentId(Integer agentId) { this.agentId = agentId; }
public String getSecret() { return secret; }
public void setSecret(String secret) { this.secret = secret; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getCrmAgentId() { return crmAgentId; }
public void setCrmAgentId(String crmAgentId) { this.crmAgentId = crmAgentId; }
public String getCallbackPath() { return callbackPath; }
public void setCallbackPath(String callbackPath) { this.callbackPath = callbackPath; }
}
}

View File

@ -0,0 +1,23 @@
package com.company.agentfoundation.controller;
import com.company.agentfoundation.domain.ChatRequest;
import com.company.agentfoundation.domain.ChatResponse;
import com.company.agentfoundation.security.AgentAuthService;
import com.company.agentfoundation.security.AgentContext;
import com.company.agentfoundation.service.ChatService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/v1")
public class ChatController {
private final AgentAuthService authService;
private final ChatService chatService;
public ChatController(AgentAuthService authService, ChatService chatService) { this.authService = authService; this.chatService = chatService; }
@PostMapping("/chat/completions")
public ResponseEntity<ChatResponse> chat(@RequestHeader("X-Agent-Id") String agentId, @RequestHeader("X-Internal-Key") String internalKey, @Valid @RequestBody ChatRequest request) {
AgentContext context = authService.authenticate(agentId, internalKey);
return ResponseEntity.ok(chatService.chat(context, request));
}
@GetMapping("/healthz")
public ResponseEntity<String> healthz() { return ResponseEntity.ok("ok"); }
}

View File

@ -0,0 +1,38 @@
package com.company.agentfoundation.controller;
import com.company.agentfoundation.security.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<Map<String, Object>> unauthorized(UnauthorizedException ex) {
return build(HttpStatus.UNAUTHORIZED, ex.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> badRequest(IllegalArgumentException ex) {
return build(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> generic(Exception ex) {
return build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
}
private ResponseEntity<Map<String, Object>> build(HttpStatus status, String message) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", Instant.now());
body.put("status", status.value());
body.put("error", status.getReasonPhrase());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -0,0 +1,7 @@
package com.company.agentfoundation.domain;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record ChatRequest(String model, @NotEmpty List<Message> messages, Double temperature) {
public record Message(@NotBlank String role, @NotBlank String content) {}
}

View File

@ -0,0 +1,5 @@
package com.company.agentfoundation.domain;
import java.time.Instant;
public record ChatResponse(String traceId, String agentId, String model, String content, Usage usage, Instant timestamp) {
public record Usage(Integer promptTokens, Integer completionTokens, Integer totalTokens) {}
}

View File

@ -0,0 +1,7 @@
package com.company.agentfoundation.integration.wecom;
public record WecomApiResponse(Integer errcode, String errmsg) {
public boolean success() {
return errcode != null && errcode == 0;
}
}

View File

@ -0,0 +1,97 @@
package com.company.agentfoundation.integration.wecom;
import com.company.agentfoundation.config.FoundationProperties;
import com.company.agentfoundation.domain.ChatRequest;
import com.company.agentfoundation.domain.ChatResponse;
import com.company.agentfoundation.security.AgentContext;
import com.company.agentfoundation.service.ChatService;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
@RestController
@RequestMapping("/v1/integrations/wecom")
public class WecomCallbackController {
private final FoundationProperties properties;
private final ChatService chatService;
private final WecomClient wecomClient;
private final XmlMapper xmlMapper = new XmlMapper();
public WecomCallbackController(FoundationProperties properties, ChatService chatService, WecomClient wecomClient) {
this.properties = properties;
this.chatService = chatService;
this.wecomClient = wecomClient;
}
@GetMapping("/callback")
public String verify(@RequestParam("msg_signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr) {
if (!validSignature(signature, timestamp, nonce)) {
throw new IllegalArgumentException("Invalid WeCom signature");
}
return echostr;
}
@PostMapping(value = "/callback", consumes = {MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_XML_VALUE})
public String receive(@RequestParam("msg_signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestBody String body) throws Exception {
if (!validSignature(signature, timestamp, nonce)) {
throw new IllegalArgumentException("Invalid WeCom signature");
}
WecomIncomingMessage msg = xmlMapper.readValue(body, WecomIncomingMessage.class);
if (msg == null || msg.getMsgType() == null || !"text".equalsIgnoreCase(msg.getMsgType())) {
return "success";
}
String userText = msg.getContent() == null ? "" : msg.getContent().trim();
if (userText.isBlank()) {
return "success";
}
ChatRequest req = new ChatRequest(
null,
List.of(
new ChatRequest.Message("system", "你是企业CRM智能助手。请给出简洁、可执行的客户跟进建议。"),
new ChatRequest.Message("user", userText)
),
0.3
);
ChatResponse chat = chatService.chat(new AgentContext(properties.getWecom().getCrmAgentId()), req);
String reply = (chat.content() == null || chat.content().isBlank()) ? "我已收到你的请求,但暂时无法生成建议。" : chat.content();
wecomClient.sendText(msg.getFromUserName(), reply);
return "success";
}
private boolean validSignature(String signature, String timestamp, String nonce) {
String token = properties.getWecom().getToken();
if (token == null || token.isBlank()) {
return false;
}
String[] arr = new String[]{token, timestamp, nonce};
Arrays.sort(arr);
String raw = String.join("", arr);
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] sha1 = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
String localSignature = HexFormat.of().formatHex(sha1);
return signature.equalsIgnoreCase(localSignature);
} catch (Exception e) {
return false;
}
}
}

View File

@ -0,0 +1,68 @@
package com.company.agentfoundation.integration.wecom;
import com.company.agentfoundation.config.FoundationProperties;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Instant;
@Service
public class WecomClient {
private final FoundationProperties properties;
private final WebClient webClient;
private volatile String cachedToken;
private volatile Instant tokenExpireAt = Instant.EPOCH;
public WecomClient(FoundationProperties properties, WebClient.Builder builder) {
this.properties = properties;
this.webClient = builder.baseUrl("https://qyapi.weixin.qq.com").build();
}
public void sendText(String toUser, String content) {
String token = getAccessToken();
WecomSendMessageRequest req = new WecomSendMessageRequest();
req.setToUser(toUser);
req.setAgentId(properties.getWecom().getAgentId());
WecomSendMessageRequest.Text text = new WecomSendMessageRequest.Text();
text.setContent(content);
req.setText(text);
WecomApiResponse response = webClient.post()
.uri(uriBuilder -> uriBuilder.path("/cgi-bin/message/send").queryParam("access_token", token).build())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(req)
.retrieve()
.bodyToMono(WecomApiResponse.class)
.block();
if (response == null || !response.success()) {
throw new IllegalStateException("WeCom send failed: " + (response == null ? "null" : response.errmsg()));
}
}
private String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpireAt)) {
return cachedToken;
}
WecomTokenResponse response = webClient.get()
.uri(uriBuilder -> uriBuilder.path("/cgi-bin/gettoken")
.queryParam("corpid", properties.getWecom().getCorpId())
.queryParam("corpsecret", properties.getWecom().getSecret())
.build())
.retrieve()
.bodyToMono(WecomTokenResponse.class)
.block();
if (response == null || !response.success() || response.accessToken() == null) {
throw new IllegalStateException("WeCom gettoken failed: " + (response == null ? "null" : response.errmsg()));
}
cachedToken = response.accessToken();
int expires = response.expiresIn() == null ? 7200 : response.expiresIn();
tokenExpireAt = Instant.now().plusSeconds(Math.max(300, expires - 120));
return cachedToken;
}
}

View File

@ -0,0 +1,25 @@
package com.company.agentfoundation.integration.wecom;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
@JacksonXmlRootElement(localName = "xml")
public class WecomIncomingMessage {
@JacksonXmlProperty(localName = "ToUserName")
private String toUserName;
@JacksonXmlProperty(localName = "FromUserName")
private String fromUserName;
@JacksonXmlProperty(localName = "MsgType")
private String msgType;
@JacksonXmlProperty(localName = "Content")
private String content;
public String getToUserName() { return toUserName; }
public void setToUserName(String toUserName) { this.toUserName = toUserName; }
public String getFromUserName() { return fromUserName; }
public void setFromUserName(String fromUserName) { this.fromUserName = fromUserName; }
public String getMsgType() { return msgType; }
public void setMsgType(String msgType) { this.msgType = msgType; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
}

View File

@ -0,0 +1,39 @@
package com.company.agentfoundation.integration.wecom;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class WecomSendMessageRequest {
@JsonProperty("touser")
private String toUser;
@JsonProperty("msgtype")
private String msgType = "text";
@JsonProperty("agentid")
private Integer agentId;
private Text text;
@JsonProperty("safe")
private Integer safe = 0;
public String getToUser() { return toUser; }
public void setToUser(String toUser) { this.toUser = toUser; }
public String getMsgType() { return msgType; }
public void setMsgType(String msgType) { this.msgType = msgType; }
public Integer getAgentId() { return agentId; }
public void setAgentId(Integer agentId) { this.agentId = agentId; }
public Text getText() { return text; }
public void setText(Text text) { this.text = text; }
public Integer getSafe() { return safe; }
public void setSafe(Integer safe) { this.safe = safe; }
public static class Text {
private String content;
@JsonProperty("mentioned_list")
private List<String> mentionedList = List.of();
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public List<String> getMentionedList() { return mentionedList; }
public void setMentionedList(List<String> mentionedList) { this.mentionedList = mentionedList; }
}
}

View File

@ -0,0 +1,14 @@
package com.company.agentfoundation.integration.wecom;
import com.fasterxml.jackson.annotation.JsonProperty;
public record WecomTokenResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") Integer expiresIn,
Integer errcode,
String errmsg
) {
public boolean success() {
return errcode == null || errcode == 0;
}
}

View File

@ -0,0 +1,13 @@
package com.company.agentfoundation.security;
import com.company.agentfoundation.config.FoundationProperties;
import org.springframework.stereotype.Component;
@Component
public class AgentAuthService {
private final FoundationProperties properties;
public AgentAuthService(FoundationProperties properties) { this.properties = properties; }
public AgentContext authenticate(String agentId, String internalKey) {
String expected = properties.getAuth().getKeys().get(agentId);
if (expected == null || !expected.equals(internalKey)) throw new UnauthorizedException("Invalid agent credentials");
return new AgentContext(agentId);
}
}

View File

@ -0,0 +1,2 @@
package com.company.agentfoundation.security;
public record AgentContext(String agentId) {}

View File

@ -0,0 +1,4 @@
package com.company.agentfoundation.security;
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) { super(message); }
}

View File

@ -0,0 +1,30 @@
package com.company.agentfoundation.service;
import com.company.agentfoundation.api.DeepseekChatRequest;
import com.company.agentfoundation.api.DeepseekChatResponse;
import com.company.agentfoundation.api.DeepseekClient;
import com.company.agentfoundation.domain.ChatRequest;
import com.company.agentfoundation.domain.ChatResponse;
import com.company.agentfoundation.security.AgentContext;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Service
public class ChatService {
private final DeepseekClient deepseekClient;
private final ModelRouter modelRouter;
public ChatService(DeepseekClient deepseekClient, ModelRouter modelRouter) { this.deepseekClient = deepseekClient; this.modelRouter = modelRouter; }
public ChatResponse chat(AgentContext context, ChatRequest request) {
String traceId = UUID.randomUUID().toString();
String model = modelRouter.routeModel(context.agentId(), request.model());
DeepseekChatResponse resp = deepseekClient.chat(new DeepseekChatRequest(
model,
request.messages().stream().map(m -> new DeepseekChatRequest.Message(m.role(), m.content())).toList(),
request.temperature(),
false));
String content = "";
if (resp != null && resp.choices() != null && !resp.choices().isEmpty() && resp.choices().get(0).message() != null) content = resp.choices().get(0).message().content();
ChatResponse.Usage usage = null;
if (resp != null && resp.usage() != null) usage = new ChatResponse.Usage(resp.usage().prompt_tokens(), resp.usage().completion_tokens(), resp.usage().total_tokens());
return new ChatResponse(traceId, context.agentId(), model, content, usage, Instant.now());
}
}

View File

@ -0,0 +1,12 @@
package com.company.agentfoundation.service;
import com.company.agentfoundation.config.FoundationProperties;
import org.springframework.stereotype.Component;
@Component
public class ModelRouter {
private final FoundationProperties properties;
public ModelRouter(FoundationProperties properties) { this.properties = properties; }
public String routeModel(String agentId, String requestedModel) {
if (requestedModel != null && !requestedModel.isBlank()) return requestedModel;
return properties.getModelRouter().getRouteByAgent().getOrDefault(agentId, properties.getModelRouter().getDefaultModel());
}
}

View File

@ -0,0 +1,36 @@
server:
port: 8080
spring:
application:
name: agent-foundation
management:
endpoints:
web:
exposure:
include: health,info,metrics
foundation:
auth:
keys:
crm-assistant: crm-dev-key
kb-assistant: kb-dev-key
cs-assistant: cs-dev-key
model-router:
default-model: deepseek-chat
route-by-agent:
crm-assistant: deepseek-chat
kb-assistant: deepseek-chat
cs-assistant: deepseek-chat
deepseek:
base-url: https://api.deepseek.com
chat-path: /chat/completions
api-key: ${DEEPSEEK_API_KEY:}
read-timeout-ms: 60000
max-retries: 2
wecom:
enabled: true
corp-id: ${WECOM_CORP_ID:}
agent-id: ${WECOM_AGENT_ID:1000002}
secret: ${WECOM_SECRET:}
token: ${WECOM_TOKEN:}
crm-agent-id: crm-assistant
callback-path: /v1/integrations/wecom/callback