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:
@@ -1,12 +1,16 @@
|
|||||||
package com.sino.mci.channel.wecom;
|
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.SendMessageRequest;
|
||||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.HttpStatusCodeException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -17,8 +21,21 @@ import java.util.Map;
|
|||||||
public class WeComBotSender {
|
public class WeComBotSender {
|
||||||
|
|
||||||
private static final String DEFAULT_WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=%s";
|
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) {
|
public SendMessageResult send(SendMessageRequest request) {
|
||||||
SendMessageResult result = new SendMessageResult();
|
SendMessageResult result = new SendMessageResult();
|
||||||
@@ -42,9 +59,21 @@ public class WeComBotSender {
|
|||||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String url = String.format(DEFAULT_WEBHOOK_URL, key);
|
String url = resolveWebhookUrl(request.getExtra(), key);
|
||||||
restTemplate.postForEntity(url, entity, String.class);
|
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);
|
result.setSuccess(true);
|
||||||
|
} catch (HttpStatusCodeException e) {
|
||||||
|
log.warn("[WeComBot] 发送失败,HTTP 状态: {}", e.getStatusCode(), e);
|
||||||
|
result.setSuccess(false);
|
||||||
|
result.setMessage("企微群机器人发送失败,HTTP 状态: " + e.getStatusCode());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[WeComBot] 发送失败", e);
|
log.warn("[WeComBot] 发送失败", e);
|
||||||
result.setSuccess(false);
|
result.setSuccess(false);
|
||||||
@@ -53,6 +82,47 @@ public class WeComBotSender {
|
|||||||
return result;
|
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) {
|
private Map<String, Object> buildBody(String contentType, Map<String, Object> content) {
|
||||||
Map<String, Object> body = new HashMap<>();
|
Map<String, Object> body = new HashMap<>();
|
||||||
switch (contentType) {
|
switch (contentType) {
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
package com.sino.mci.channel.wecom;
|
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.SendMessageRequest;
|
||||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
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 java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
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 {
|
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
|
@Test
|
||||||
void sendRejectsMissingKey() {
|
void sendRejectsMissingKey() {
|
||||||
@@ -22,12 +40,73 @@ class WeComBotSenderTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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();
|
SendMessageRequest request = new SendMessageRequest();
|
||||||
request.setExtra(Map.of("account_id", "dummy"));
|
request.setExtra(Map.of("account_id", "dummy"));
|
||||||
request.setContentType("text");
|
request.setContentType("text");
|
||||||
request.setContent(Map.of("text", "hello"));
|
request.setContent(Map.of("text", "hello"));
|
||||||
// With a real key this hits WeCom; keep as shape validation only.
|
SendMessageResult result = sender.send(request);
|
||||||
assertNotNull(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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class CreateConversationRequest {
|
|||||||
private Long ownerAccountId;
|
private Long ownerAccountId;
|
||||||
private Long subjectId;
|
private Long subjectId;
|
||||||
private String conversationName;
|
private String conversationName;
|
||||||
|
private Integer conversationScene;
|
||||||
private Integer createSource;
|
private Integer createSource;
|
||||||
private Map<String, Object> receiveConfig;
|
private Map<String, Object> receiveConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class UpdateConversationRequest {
|
|||||||
|
|
||||||
private Long ownerAccountId;
|
private Long ownerAccountId;
|
||||||
private Long subjectId;
|
private Long subjectId;
|
||||||
|
private Integer conversationScene;
|
||||||
private Integer status;
|
private Integer status;
|
||||||
private Map<String, Object> receiveConfig;
|
private Map<String, Object> receiveConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.sino.mci.channel.dto.UpdateConversationRequest;
|
|||||||
import com.sino.mci.channel.entity.ChannelAccount;
|
import com.sino.mci.channel.entity.ChannelAccount;
|
||||||
import com.sino.mci.channel.entity.ChannelAccountConversation;
|
import com.sino.mci.channel.entity.ChannelAccountConversation;
|
||||||
import com.sino.mci.channel.entity.Conversation;
|
import com.sino.mci.channel.entity.Conversation;
|
||||||
|
import com.sino.mci.channel.common.model.AccountType;
|
||||||
import com.sino.mci.channel.entity.ConversationParticipant;
|
import com.sino.mci.channel.entity.ConversationParticipant;
|
||||||
import com.sino.mci.channel.entity.ConversationTagBinding;
|
import com.sino.mci.channel.entity.ConversationTagBinding;
|
||||||
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
|
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
|
||||||
@@ -78,6 +79,7 @@ public class ConversationService {
|
|||||||
conversation.setOwnerAccountId(request.getOwnerAccountId());
|
conversation.setOwnerAccountId(request.getOwnerAccountId());
|
||||||
conversation.setSubjectId(request.getSubjectId());
|
conversation.setSubjectId(request.getSubjectId());
|
||||||
conversation.setConversationName(request.getConversationName());
|
conversation.setConversationName(request.getConversationName());
|
||||||
|
conversation.setConversationScene(request.getConversationScene());
|
||||||
conversation.setCreateSource(request.getCreateSource() != null ? request.getCreateSource() : 2);
|
conversation.setCreateSource(request.getCreateSource() != null ? request.getCreateSource() : 2);
|
||||||
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
|
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
|
||||||
conversation.setStatus(1);
|
conversation.setStatus(1);
|
||||||
@@ -109,6 +111,9 @@ public class ConversationService {
|
|||||||
if (request.getReceiveConfig() != null) {
|
if (request.getReceiveConfig() != null) {
|
||||||
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
|
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
|
||||||
}
|
}
|
||||||
|
if (request.getConversationScene() != null) {
|
||||||
|
conversation.setConversationScene(request.getConversationScene());
|
||||||
|
}
|
||||||
conversationMapper.updateById(conversation);
|
conversationMapper.updateById(conversation);
|
||||||
return conversation;
|
return conversation;
|
||||||
}
|
}
|
||||||
@@ -127,7 +132,7 @@ public class ConversationService {
|
|||||||
if (account == null || !tenantCode.equals(account.getTenantCode())) {
|
if (account == null || !tenantCode.equals(account.getTenantCode())) {
|
||||||
throw new BusinessException("账号不存在或不在当前租户");
|
throw new BusinessException("账号不存在或不在当前租户");
|
||||||
}
|
}
|
||||||
if (!conversation.getChannelType().equalsIgnoreCase(account.getChannelType())) {
|
if (!isAccountCompatibleWithConversation(conversation, account)) {
|
||||||
throw new BusinessException("账号渠道类型与会话不一致");
|
throw new BusinessException("账号渠道类型与会话不一致");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +149,24 @@ public class ConversationService {
|
|||||||
binding.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
|
binding.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
|
||||||
binding.setStatus(1);
|
binding.setStatus(1);
|
||||||
bindingMapper.insert(binding);
|
bindingMapper.insert(binding);
|
||||||
|
|
||||||
|
// 主发送账号作为会话负责人
|
||||||
|
if (request.getBindingType() != null && request.getBindingType() == 1) {
|
||||||
|
conversation.setOwnerAccountId(request.getAccountId());
|
||||||
|
conversationMapper.updateById(conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAccountCompatibleWithConversation(Conversation conversation, ChannelAccount account) {
|
||||||
|
Integer scene = conversation.getConversationScene();
|
||||||
|
if (scene != null) {
|
||||||
|
return switch (scene) {
|
||||||
|
case 1 -> AccountType.WECOM_BOT.getCode() == account.getAccountType();
|
||||||
|
case 2 -> AccountType.WECHAT_PERSONAL.getCode() == account.getAccountType();
|
||||||
|
default -> conversation.getChannelType().equalsIgnoreCase(account.getChannelType());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return conversation.getChannelType().equalsIgnoreCase(account.getChannelType());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -853,6 +853,22 @@ public class SendService {
|
|||||||
public void dispatchRecord(SendRecord record) {
|
public void dispatchRecord(SendRecord record) {
|
||||||
|
|
||||||
ChannelType channelType = parseChannelType(record.getChannelType());
|
ChannelType channelType = parseChannelType(record.getChannelType());
|
||||||
|
Set<Integer> accountTypes;
|
||||||
|
if (record.getConversationId() != null) {
|
||||||
|
Conversation conversation = conversationMapper.selectById(record.getConversationId());
|
||||||
|
Integer scene = conversation != null ? conversation.getConversationScene() : null;
|
||||||
|
if (scene != null) {
|
||||||
|
channelType = resolveChannelTypeForScene(scene);
|
||||||
|
accountTypes = resolveAccountTypesForScene(scene);
|
||||||
|
} else {
|
||||||
|
accountTypes = null;
|
||||||
|
}
|
||||||
|
} else if (record.getPersonId() != null) {
|
||||||
|
accountTypes = ACCOUNT_TYPES_WECOM_AGENT;
|
||||||
|
} else {
|
||||||
|
accountTypes = null;
|
||||||
|
}
|
||||||
|
|
||||||
ChannelAdapter adapter = adapterRegistry.getAdapter(channelType);
|
ChannelAdapter adapter = adapterRegistry.getAdapter(channelType);
|
||||||
if (adapter == null) {
|
if (adapter == null) {
|
||||||
record.setSendStatus(SEND_STATUS_FAILED);
|
record.setSendStatus(SEND_STATUS_FAILED);
|
||||||
@@ -875,15 +891,6 @@ public class SendService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<Integer> accountTypes;
|
|
||||||
if (record.getConversationId() != null) {
|
|
||||||
Conversation conversation = conversationMapper.selectById(record.getConversationId());
|
|
||||||
accountTypes = resolveAccountTypesForScene(conversation != null ? conversation.getConversationScene() : null);
|
|
||||||
} else if (record.getPersonId() != null) {
|
|
||||||
accountTypes = ACCOUNT_TYPES_WECOM_AGENT;
|
|
||||||
} else {
|
|
||||||
accountTypes = null;
|
|
||||||
}
|
|
||||||
List<Long> candidateAccountIds = selectSendAccountCandidates(record, channelType, accountTypes);
|
List<Long> candidateAccountIds = selectSendAccountCandidates(record, channelType, accountTypes);
|
||||||
if (candidateAccountIds.isEmpty()) {
|
if (candidateAccountIds.isEmpty()) {
|
||||||
record.setSendStatus(SEND_STATUS_FAILED);
|
record.setSendStatus(SEND_STATUS_FAILED);
|
||||||
|
|||||||
Reference in New Issue
Block a user