This commit is contained in:
tanglong 2026-06-02 10:27:21 +08:00
parent 0f08fe882a
commit 4588888604
5 changed files with 168 additions and 39 deletions

View File

@ -57,6 +57,7 @@ public class FoundationProperties {
private Integer agentId;
private String secret;
private String token;
private String encodingAesKey;
private String crmAgentId = "crm-assistant";
private String callbackPath = "/v1/integrations/wecom/callback";
@ -70,6 +71,8 @@ public class FoundationProperties {
public void setSecret(String secret) { this.secret = secret; }
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 getCrmAgentId() { return crmAgentId; }
public void setCrmAgentId(String crmAgentId) { this.crmAgentId = crmAgentId; }
public String getCallbackPath() { return callbackPath; }

View File

@ -7,12 +7,13 @@ 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 org.springframework.web.bind.annotation.GetMapping;
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 java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
@RestController
@ -21,35 +22,43 @@ public class WecomCallbackController {
private final FoundationProperties properties;
private final ChatService chatService;
private final WecomClient wecomClient;
private final WecomCryptoService wecomCryptoService;
private final XmlMapper xmlMapper = new XmlMapper();
public WecomCallbackController(FoundationProperties properties, ChatService chatService, WecomClient wecomClient) {
public WecomCallbackController(
FoundationProperties properties,
ChatService chatService,
WecomClient wecomClient,
WecomCryptoService wecomCryptoService
) {
this.properties = properties;
this.chatService = chatService;
this.wecomClient = wecomClient;
this.wecomCryptoService = wecomCryptoService;
}
@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)) {
public String verify(
@RequestParam("msg_signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr
) {
String payload = wecomCryptoService.isSafeModeEnabled() ? echostr : null;
if (!wecomCryptoService.validateSignature(signature, timestamp, nonce, payload)) {
throw new IllegalArgumentException("Invalid WeCom signature");
}
return echostr;
return wecomCryptoService.isSafeModeEnabled() ? wecomCryptoService.decrypt(echostr) : 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);
public String receive(
@RequestParam("msg_signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestBody String body
) throws Exception {
WecomIncomingMessage msg = parseIncomingMessage(signature, timestamp, nonce, body);
if (msg == null || msg.getMsgType() == null || !"text".equalsIgnoreCase(msg.getMsgType())) {
return "success";
}
@ -62,36 +71,36 @@ public class WecomCallbackController {
ChatRequest req = new ChatRequest(
null,
List.of(
new ChatRequest.Message("system", "你是企业CRM智能助手。请给出简洁、可执行的客户跟进建议。"),
new ChatRequest.Message("system", "You are an enterprise CRM assistant. Provide concise, actionable follow-up advice."),
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();
String reply = (chat.content() == null || chat.content().isBlank())
? "Request received, but I could not generate advice right now."
: 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;
private WecomIncomingMessage parseIncomingMessage(String signature, String timestamp, String nonce, String body) throws Exception {
if (wecomCryptoService.isSafeModeEnabled()) {
WecomEncryptedEnvelope envelope = xmlMapper.readValue(body, WecomEncryptedEnvelope.class);
if (envelope == null || envelope.getEncrypt() == null || envelope.getEncrypt().isBlank()) {
throw new IllegalArgumentException("Missing WeCom encrypted payload");
}
if (!wecomCryptoService.validateSignature(signature, timestamp, nonce, envelope.getEncrypt())) {
throw new IllegalArgumentException("Invalid WeCom signature");
}
String plainXml = wecomCryptoService.decrypt(envelope.getEncrypt());
return xmlMapper.readValue(plainXml, WecomIncomingMessage.class);
}
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;
if (!wecomCryptoService.validateSignature(signature, timestamp, nonce, null)) {
throw new IllegalArgumentException("Invalid WeCom signature");
}
return xmlMapper.readValue(body, WecomIncomingMessage.class);
}
}

View File

@ -0,0 +1,93 @@
package com.company.agentfoundation.integration.wecom;
import com.company.agentfoundation.config.FoundationProperties;
import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Base64;
import java.util.HexFormat;
@Service
public class WecomCryptoService {
private final FoundationProperties properties;
public WecomCryptoService(FoundationProperties properties) {
this.properties = properties;
}
public boolean isSafeModeEnabled() {
String key = properties.getWecom().getEncodingAesKey();
return key != null && !key.isBlank();
}
public boolean validateSignature(String signature, String timestamp, String nonce, String payload) {
String token = properties.getWecom().getToken();
if (token == null || token.isBlank()) {
return false;
}
String[] values = payload == null
? new String[]{token, timestamp, nonce}
: new String[]{token, timestamp, nonce, payload};
Arrays.sort(values);
String raw = String.join("", values);
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
String local = HexFormat.of().formatHex(digest.digest(raw.getBytes(StandardCharsets.UTF_8)));
return local.equalsIgnoreCase(signature);
} catch (Exception e) {
return false;
}
}
public String decrypt(String cipherText) {
try {
byte[] aesKey = getAesKey();
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16)));
byte[] plain = cipher.doFinal(Base64.getDecoder().decode(cipherText));
byte[] unpadded = removePadding(plain);
ByteBuffer buffer = ByteBuffer.wrap(unpadded, 16, unpadded.length - 16);
int xmlLength = buffer.getInt();
byte[] xmlBytes = new byte[xmlLength];
buffer.get(xmlBytes);
byte[] receiveIdBytes = new byte[buffer.remaining()];
buffer.get(receiveIdBytes);
String receiveId = new String(receiveIdBytes, StandardCharsets.UTF_8);
String expected = properties.getWecom().getCorpId();
if (expected != null && !expected.isBlank() && !expected.equals(receiveId)) {
throw new IllegalArgumentException("WeCom receiveId mismatch");
}
return new String(xmlBytes, StandardCharsets.UTF_8);
} catch (IllegalArgumentException ex) {
throw ex;
} catch (Exception ex) {
throw new IllegalArgumentException("Failed to decrypt WeCom payload", ex);
}
}
private byte[] getAesKey() {
String key = properties.getWecom().getEncodingAesKey();
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("WECOM_ENCODING_AES_KEY is missing");
}
return Base64.getDecoder().decode(key + "=");
}
private byte[] removePadding(byte[] plainText) {
int pad = plainText[plainText.length - 1] & 0xFF;
if (pad < 1 || pad > 32) {
pad = 0;
}
return Arrays.copyOfRange(plainText, 0, plainText.length - pad);
}
}

View File

@ -0,0 +1,23 @@
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 WecomEncryptedEnvelope {
@JacksonXmlProperty(localName = "ToUserName")
private String toUserName;
@JacksonXmlProperty(localName = "AgentID")
private String agentId;
@JacksonXmlProperty(localName = "Encrypt")
private String encrypt;
public String getToUserName() { return toUserName; }
public void setToUserName(String toUserName) { this.toUserName = toUserName; }
public String getAgentId() { return agentId; }
public void setAgentId(String agentId) { this.agentId = agentId; }
public String getEncrypt() { return encrypt; }
public void setEncrypt(String encrypt) { this.encrypt = encrypt; }
}

View File

@ -32,5 +32,6 @@ foundation:
agent-id: ${WECOM_AGENT_ID:1000002}
secret: ${WECOM_SECRET:}
token: ${WECOM_TOKEN:}
encoding-aes-key: ${WECOM_ENCODING_AES_KEY:}
crm-agent-id: crm-assistant
callback-path: /v1/integrations/wecom/callback