问答小助手

This commit is contained in:
tanglong 2026-07-21 13:21:48 +08:00
parent 48e111afa9
commit ff8f30bee1
5 changed files with 119 additions and 55 deletions

View File

@ -1,7 +1,10 @@
# 客资日报企业微信回调
客资日报助手复用现有企业微信通用配置,不新增第二套 CorpId、AgentId、Secret、Token 或 EncodingAESKey。
每个助手只配置自己的回调地址,服务端使用独立的回调方法接收消息:
同一企业下的助手共用 CorpId;每个自建应用分别配置 AgentId、Secret 和回调地址。运动馆智能助手使用:
`GET/POST /v1/integrations/wecom/sports-venue/callback`
原客资日报地址继续作为兼容入口:
`GET/POST /v1/integrations/wecom/customer-report/callback`
@ -9,8 +12,9 @@ GET 用于企业微信保存回调配置时验签,POST 用于接收用户消
部署前配置:
- 沿用 `foundation.wecom` 下现有的 CorpId、AgentId、Secret、Token、EncodingAESKey。
- 沿用 `foundation.wecom` 下现有的 CorpId、Token、EncodingAESKey。
- 问答小助手和运动馆智能助手分别配置在 `foundation.wecom.apps.general`、`foundation.wecom.apps.sports-venue`。
- `DEEPSEEK_API_KEY`(沿用项目现有 DeepSeek 配置)。
- `CRM_REPORT_BASE_URL`,指向学邦同步服务,例如 `http://127.0.0.1:9999`。
`/v1/integrations/wecom/callback` 和 `/v1/integrations/wecom/customer-report/callback` 分别对应独立的 GET/POST 回调方法,但复用签名验证、解密和语义路由逻辑。后续增加助手时只新增回调路径和入口方法,不重复企业微信公共配置。
`/v1/integrations/wecom/callback` 保持原有扣子默认工作流;`/v1/integrations/wecom/sports-venue/callback` 使用 DeepSeek 语义识别并调用客资日报接口。回复消息时,服务端会根据入口选择对应应用的 AgentId 和 Secret。

View File

@ -100,29 +100,42 @@ public class FoundationProperties {
public static class Wecom {
private boolean enabled;
private String corpId;
private Integer agentId;
private String secret;
private String defaultApp = "general";
private String studentContactSecret;
private String token;
private String encodingAesKey;
private String callbackPath = "/v1/integrations/wecom/callback";
private Map<String, App> apps = new HashMap<>();
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 getDefaultApp() { return defaultApp; }
public void setDefaultApp(String defaultApp) { this.defaultApp = defaultApp; }
public String getStudentContactSecret() { return studentContactSecret; }
public void setStudentContactSecret(String studentContactSecret) { this.studentContactSecret = studentContactSecret; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getEncodingAesKey() { return encodingAesKey; }
public void setEncodingAesKey(String encodingAesKey) { this.encodingAesKey = encodingAesKey; }
public String getCallbackPath() { return callbackPath; }
public void setCallbackPath(String callbackPath) { this.callbackPath = callbackPath; }
public Map<String, App> getApps() { return apps; }
public void setApps(Map<String, App> apps) { this.apps = apps; }
public static class App {
private String name;
private Integer agentId;
private String secret;
private String callbackPath;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
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 getCallbackPath() { return callbackPath; }
public void setCallbackPath(String callbackPath) { this.callbackPath = callbackPath; }
}
}
public static class Semantic {

View File

@ -27,8 +27,6 @@ public class WecomClient {
private final FoundationProperties properties;
private final WebClient webClient;
private volatile String cachedToken;
private volatile Instant tokenExpireAt = Instant.EPOCH;
private final Map<String, TokenCache> tokenCaches = new ConcurrentHashMap<>();
public WecomClient(FoundationProperties properties, WebClient.Builder builder) {
@ -37,11 +35,17 @@ public class WecomClient {
}
public void sendText(String toUser, String content) {
log.info("wecom sendText start toUser={} agentId={} contentPreview={}", toUser, properties.getWecom().getAgentId(), preview(content));
String token = getAccessToken();
sendText(properties.getWecom().getDefaultApp(), toUser, content);
}
public void sendText(String appKey, String toUser, String content) {
FoundationProperties.Wecom.App app = app(appKey);
log.info("wecom sendText start appKey={} toUser={} agentId={} contentPreview={}",
appKey, toUser, app.getAgentId(), preview(content));
String token = getAccessToken(app.getSecret());
WecomSendMessageRequest req = new WecomSendMessageRequest();
req.setToUser(toUser);
req.setAgentId(properties.getWecom().getAgentId());
req.setAgentId(app.getAgentId());
WecomSendMessageRequest.Text text = new WecomSendMessageRequest.Text();
text.setContent(content);
req.setText(text);
@ -118,35 +122,8 @@ public class WecomClient {
return response;
}
private String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpireAt)) {
log.info("wecom gettoken use cached token expireAt={}", 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) {
log.error("wecom gettoken failed err={}", response == null ? "null" : response.errmsg());
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));
log.info("wecom gettoken success expireAt={}", tokenExpireAt);
return cachedToken;
}
private String getAccessToken(String secret) {
String safeSecret = secret == null || secret.isBlank() ? properties.getWecom().getSecret() : secret.trim();
String safeSecret = requireText(secret, "WeCom app secret is missing");
TokenCache cache = tokenCaches.computeIfAbsent(safeSecret, ignored -> new TokenCache());
if (cache.token != null && Instant.now().isBefore(cache.expireAt)) {
log.info("wecom gettoken use cached token expireAt={}", cache.expireAt);
@ -176,7 +153,27 @@ public class WecomClient {
private String studentContactSecret() {
String secret = properties.getWecom().getStudentContactSecret();
return secret == null || secret.isBlank() ? properties.getWecom().getSecret() : secret;
return secret == null || secret.isBlank() ? app(properties.getWecom().getDefaultApp()).getSecret() : secret;
}
private FoundationProperties.Wecom.App app(String appKey) {
String safeKey = requireText(appKey, "WeCom app key is missing");
FoundationProperties.Wecom.App app = properties.getWecom().getApps().get(safeKey);
if (app == null) {
throw new IllegalStateException("WeCom app is not configured: " + safeKey);
}
if (app.getAgentId() == null || app.getAgentId() <= 0) {
throw new IllegalStateException("WeCom agent-id is missing: " + safeKey);
}
requireText(app.getSecret(), "WeCom secret is missing: " + safeKey);
return app;
}
private String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new IllegalStateException(message);
}
return value.trim();
}
private String preview(String text) {

View File

@ -1,5 +1,6 @@
package com.company.agentfoundation.integration.wecom.controller;
import com.company.agentfoundation.integration.coze.service.CozeWorkflowService;
import com.company.agentfoundation.integration.wecom.client.WecomClient;
import com.company.agentfoundation.integration.wecom.model.WecomEncryptedEnvelope;
import com.company.agentfoundation.integration.wecom.model.WecomIncomingMessage;
@ -27,7 +28,10 @@ public class WecomCallbackController {
private static final Logger log = LoggerFactory.getLogger(WecomCallbackController.class);
private static final long MESSAGE_DEDUP_TTL_SECONDS = 600;
private static final String ERROR_REPLY = "语义识别或业务查询失败,请稍后重试";
private static final String GENERAL_APP = "general";
private static final String SPORTS_VENUE_APP = "sports-venue";
private final SemanticDispatchService semanticDispatchService;
private final CozeWorkflowService cozeWorkflowService;
private final WecomClient wecomClient;
private final WecomCryptoService wecomCryptoService;
private final XmlMapper xmlMapper = new XmlMapper();
@ -35,10 +39,12 @@ public class WecomCallbackController {
public WecomCallbackController(
SemanticDispatchService semanticDispatchService,
CozeWorkflowService cozeWorkflowService,
WecomClient wecomClient,
WecomCryptoService wecomCryptoService
) {
this.semanticDispatchService = semanticDispatchService;
this.cozeWorkflowService = cozeWorkflowService;
this.wecomClient = wecomClient;
this.wecomCryptoService = wecomCryptoService;
}
@ -82,7 +88,27 @@ public class WecomCallbackController {
@RequestParam("nonce") String nonce,
@RequestBody String body
) throws Exception {
return receiveCallback(signature, timestamp, nonce, body, "default");
return receiveCallback(signature, timestamp, nonce, body, "default", GENERAL_APP, CallbackMode.GENERAL_CHAT);
}
@GetMapping("/sports-venue/callback")
public String verifySportsVenueCallback(
@RequestParam("msg_signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr
) {
return verifyCallback(signature, timestamp, nonce, echostr, "sports-venue");
}
@PostMapping(value = "/sports-venue/callback", consumes = {MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_XML_VALUE})
public String receiveSportsVenueCallback(
@RequestParam("msg_signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestBody String body
) throws Exception {
return receiveCallback(signature, timestamp, nonce, body, "sports-venue", SPORTS_VENUE_APP, CallbackMode.SEMANTIC_ROUTING);
}
@PostMapping(value = "/customer-report/callback", consumes = {MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_XML_VALUE})
@ -92,10 +118,18 @@ public class WecomCallbackController {
@RequestParam("nonce") String nonce,
@RequestBody String body
) throws Exception {
return receiveCallback(signature, timestamp, nonce, body, "customer-report");
return receiveCallback(signature, timestamp, nonce, body, "customer-report", SPORTS_VENUE_APP, CallbackMode.SEMANTIC_ROUTING);
}
private String receiveCallback(String signature, String timestamp, String nonce, String body, String callbackName) throws Exception {
private String receiveCallback(
String signature,
String timestamp,
String nonce,
String body,
String callbackName,
String appKey,
CallbackMode callbackMode
) throws Exception {
log.info("wecom callback route={} message received", callbackName);
log.info("wecom callback message received safeMode={} timestamp={} nonce={} bodyLength={}",
wecomCryptoService.isSafeModeEnabled(), timestamp, nonce, body == null ? 0 : body.length());
@ -124,7 +158,9 @@ public class WecomCallbackController {
String reply;
try {
reply = semanticDispatchService.dispatch(msg.getFromUserName(), userText);
reply = callbackMode == CallbackMode.GENERAL_CHAT
? cozeWorkflowService.chatDefault(userText)
: semanticDispatchService.dispatch(msg.getFromUserName(), userText);
} catch (Exception e) {
log.error("wecom callback semantic dispatch failed fromUser={} err={}", msg.getFromUserName(), e.getMessage(), e);
reply = ERROR_REPLY;
@ -134,7 +170,7 @@ public class WecomCallbackController {
reply = ERROR_REPLY;
}
log.info("wecom callback sending semantic reply toUser={} replyPreview={}", msg.getFromUserName(), preview(reply));
wecomClient.sendText(msg.getFromUserName(), reply);
wecomClient.sendText(appKey, msg.getFromUserName(), reply);
log.info("wecom callback reply sent toUser={}", msg.getFromUserName());
return "success";
}
@ -192,4 +228,9 @@ public class WecomCallbackController {
}
return text.length() <= 80 ? text : text.substring(0, 80) + "...";
}
private enum CallbackMode {
GENERAL_CHAT,
SEMANTIC_ROUTING
}
}

View File

@ -50,11 +50,20 @@ foundation:
wecom:
enabled: true
corp-id: wwaec174f56094f2b5
agent-id: 1000003
secret: Sq76WOBNf3S85RoSzrJ_3u5iQrl8DRTvjTv9X0-z7HE
default-app: general
token: crmAgentToken2026
encoding-aes-key: k3F8mN2vQ7xLp9sTd4YwH6cRb1JuEz5AaZq7BnVcK2D
callback-path: /v1/integrations/wecom/callback
apps:
general:
name: 问答小助手
agent-id: 1000003
secret: Sq76WOBNf3S85RoSzrJ_3u5iQrl8DRTvjTv9X0-z7HE
callback-path: /v1/integrations/wecom/callback
sports-venue:
name: 运动馆智能助手
agent-id: 1000006
secret: V-fh1ROzjb06Hi9XB8VPb4HhE2ApI4j99NAjAXKYOb8
callback-path: /v1/integrations/wecom/sports-venue/callback
semantic:
agent-id: crm-assistant
minimum-confidence: 0.60