fix(send-routing): address final review findings

- Persist conversationScene in create/update conversation
- Make bindAccount validate by conversation scene (1=WECOM_BOT, 2=WECHAT_PERSONAL)
- Re-derive channelType from conversation_scene in dispatchRecord
- Check WeCom response errcode and HTTP status in WeComBotSender
- Support custom webhook_url from ext_info in WeComBotSender
- Configure RestTemplate with 5s connect / 10s read timeouts
- Expand WeComBotSenderTest with MockRestServiceServer coverage
This commit is contained in:
2026-07-14 18:35:50 +08:00
parent e1c7c582ee
commit 11ee7384b3
6 changed files with 198 additions and 17 deletions

View File

@@ -1,12 +1,16 @@
package com.sino.mci.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
@@ -17,8 +21,21 @@ import java.util.Map;
public class WeComBotSender {
private static final String DEFAULT_WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=%s";
private static final int CONNECT_TIMEOUT_MS = 5000;
private static final int READ_TIMEOUT_MS = 10000;
private final RestTemplate restTemplate = new RestTemplate();
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public WeComBotSender() {
this.restTemplate = createRestTemplate();
this.objectMapper = new ObjectMapper();
}
public WeComBotSender(RestTemplate restTemplate, ObjectMapper objectMapper) {
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
public SendMessageResult send(SendMessageRequest request) {
SendMessageResult result = new SendMessageResult();
@@ -42,9 +59,21 @@ public class WeComBotSender {
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
String url = String.format(DEFAULT_WEBHOOK_URL, key);
restTemplate.postForEntity(url, entity, String.class);
String url = resolveWebhookUrl(request.getExtra(), key);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
Map<String, Object> responseBody = parseResponseBody(response.getBody());
int errcode = parseInt(responseBody.get("errcode"));
if (errcode != 0) {
result.setSuccess(false);
String errmsg = getString(responseBody, "errmsg");
result.setMessage("企微群机器人发送失败: [" + errcode + "] " + errmsg);
return result;
}
result.setSuccess(true);
} catch (HttpStatusCodeException e) {
log.warn("[WeComBot] 发送失败HTTP 状态: {}", e.getStatusCode(), e);
result.setSuccess(false);
result.setMessage("企微群机器人发送失败HTTP 状态: " + e.getStatusCode());
} catch (Exception e) {
log.warn("[WeComBot] 发送失败", e);
result.setSuccess(false);
@@ -53,6 +82,47 @@ public class WeComBotSender {
return result;
}
private String resolveWebhookUrl(Map<String, Object> extra, String key) {
String customUrl = getString(extra, "webhook_url");
if (customUrl != null && !customUrl.isBlank()) {
return String.format(customUrl, key);
}
return String.format(DEFAULT_WEBHOOK_URL, key);
}
private Map<String, Object> parseResponseBody(String body) {
if (body == null || body.isBlank()) {
return new HashMap<>();
}
try {
return objectMapper.readValue(body, Map.class);
} catch (Exception e) {
log.warn("[WeComBot] 解析响应失败: {}", body, e);
return new HashMap<>();
}
}
private int parseInt(Object value) {
if (value == null) {
return 0;
}
if (value instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return 0;
}
}
private RestTemplate createRestTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
factory.setReadTimeout(READ_TIMEOUT_MS);
return new RestTemplate(factory);
}
private Map<String, Object> buildBody(String contentType, Map<String, Object> content) {
Map<String, Object> body = new HashMap<>();
switch (contentType) {

View File

@@ -1,16 +1,34 @@
package com.sino.mci.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
class WeComBotSenderTest {
private final WeComBotSender sender = new WeComBotSender();
private RestTemplate restTemplate;
private MockRestServiceServer server;
private WeComBotSender sender;
@BeforeEach
void setUp() {
restTemplate = new RestTemplate();
server = MockRestServiceServer.createServer(restTemplate);
sender = new WeComBotSender(restTemplate, new ObjectMapper());
}
@Test
void sendRejectsMissingKey() {
@@ -22,12 +40,73 @@ class WeComBotSenderTest {
}
@Test
void sendBuildsTextBody() {
void sendBuildsTextBody() throws Exception {
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().json("{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"))
.andRespond(withSuccess("{\"errcode\":0,\"errmsg\":\"ok\"}", MediaType.APPLICATION_JSON));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
// With a real key this hits WeCom; keep as shape validation only.
assertNotNull(request);
SendMessageResult result = sender.send(request);
assertTrue(result.isSuccess(), result.getMessage());
server.verify();
}
@Test
void sendRejectsUnsupportedContentType() {
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("image");
request.setContent(Map.of("media_id", "xxx"));
SendMessageResult result = sender.send(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("不支持的消息类型"));
}
@Test
void sendReportsWeComErrorResponse() {
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
.andRespond(withSuccess("{\"errcode\":40008,\"errmsg\":\"invalid message type\"}", MediaType.APPLICATION_JSON));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("40008"));
assertTrue(result.getMessage().contains("invalid message type"));
}
@Test
void sendReportsHttpErrorStatus() {
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
.andRespond(withStatus(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("HTTP 状态"));
}
@Test
void sendUsesCustomWebhookUrl() throws Exception {
server.expect(requestTo("https://custom.example.com/webhook/send?key=dummy"))
.andExpect(content().json("{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"))
.andRespond(withSuccess("{\"errcode\":0,\"errmsg\":\"ok\"}", MediaType.APPLICATION_JSON));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy", "webhook_url", "https://custom.example.com/webhook/send?key=%s"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertTrue(result.isSuccess(), result.getMessage());
server.verify();
}
}