diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/WeComBotSender.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/WeComBotSender.java index 2005011..c94a23c 100644 --- a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/WeComBotSender.java +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/WeComBotSender.java @@ -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> 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 response = restTemplate.postForEntity(url, entity, String.class); + Map 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 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 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 buildBody(String contentType, Map content) { Map body = new HashMap<>(); switch (contentType) { diff --git a/backend/mci-channel-wecom/src/test/java/com/sino/mci/channel/wecom/WeComBotSenderTest.java b/backend/mci-channel-wecom/src/test/java/com/sino/mci/channel/wecom/WeComBotSenderTest.java index b94e4eb..2a5ce2d 100644 --- a/backend/mci-channel-wecom/src/test/java/com/sino/mci/channel/wecom/WeComBotSenderTest.java +++ b/backend/mci-channel-wecom/src/test/java/com/sino/mci/channel/wecom/WeComBotSenderTest.java @@ -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(); } } diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateConversationRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateConversationRequest.java index 893b0fb..0b0c1a3 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateConversationRequest.java +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateConversationRequest.java @@ -21,6 +21,7 @@ public class CreateConversationRequest { private Long ownerAccountId; private Long subjectId; private String conversationName; + private Integer conversationScene; private Integer createSource; private Map receiveConfig; } diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/UpdateConversationRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/UpdateConversationRequest.java index 739e9d6..b5abecd 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/UpdateConversationRequest.java +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/UpdateConversationRequest.java @@ -13,6 +13,7 @@ public class UpdateConversationRequest { private Long ownerAccountId; private Long subjectId; + private Integer conversationScene; private Integer status; private Map receiveConfig; } diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java index 885373a..445c8df 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java @@ -10,6 +10,7 @@ import com.sino.mci.channel.dto.UpdateConversationRequest; import com.sino.mci.channel.entity.ChannelAccount; import com.sino.mci.channel.entity.ChannelAccountConversation; 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.ConversationTagBinding; import com.sino.mci.channel.repository.ChannelAccountConversationMapper; @@ -78,6 +79,7 @@ public class ConversationService { conversation.setOwnerAccountId(request.getOwnerAccountId()); conversation.setSubjectId(request.getSubjectId()); conversation.setConversationName(request.getConversationName()); + conversation.setConversationScene(request.getConversationScene()); conversation.setCreateSource(request.getCreateSource() != null ? request.getCreateSource() : 2); conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig())); conversation.setStatus(1); @@ -109,6 +111,9 @@ public class ConversationService { if (request.getReceiveConfig() != null) { conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig())); } + if (request.getConversationScene() != null) { + conversation.setConversationScene(request.getConversationScene()); + } conversationMapper.updateById(conversation); return conversation; } @@ -127,7 +132,7 @@ public class ConversationService { if (account == null || !tenantCode.equals(account.getTenantCode())) { throw new BusinessException("账号不存在或不在当前租户"); } - if (!conversation.getChannelType().equalsIgnoreCase(account.getChannelType())) { + if (!isAccountCompatibleWithConversation(conversation, account)) { throw new BusinessException("账号渠道类型与会话不一致"); } @@ -144,6 +149,24 @@ public class ConversationService { binding.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0); binding.setStatus(1); 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 diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java b/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java index ffe7f82..aaf95f0 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java +++ b/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java @@ -853,6 +853,22 @@ public class SendService { public void dispatchRecord(SendRecord record) { ChannelType channelType = parseChannelType(record.getChannelType()); + Set 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); if (adapter == null) { record.setSendStatus(SEND_STATUS_FAILED); @@ -875,15 +891,6 @@ public class SendService { return; } - Set 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 candidateAccountIds = selectSendAccountCandidates(record, channelType, accountTypes); if (candidateAccountIds.isEmpty()) { record.setSendStatus(SEND_STATUS_FAILED);