From f5d07b7eb5ad016ac556e2edcaf2604562f5c574 Mon Sep 17 00:00:00 2001 From: marsal Date: Tue, 7 Jul 2026 19:57:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel-wecom):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BC=81=E5=BE=AE=E8=81=94=E7=B3=BB=E4=BA=BA/=E7=BE=A4?= =?UTF-8?q?=E8=81=8A=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wecom/sync/MockWeComSyncClient.java | 54 ++++ .../mci/channel/wecom/sync/WeComContact.java | 21 ++ .../channel/wecom/sync/WeComContactType.java | 6 + .../channel/wecom/sync/WeComConversation.java | 20 ++ .../wecom/sync/WeComConversationMember.java | 16 ++ .../channel/wecom/sync/WeComSyncClient.java | 29 ++ .../channel/wecom/sync/WeComSyncConfig.java | 17 ++ .../controller/ChannelAccountController.java | 15 ++ .../com/sino/mci/channel/dto/SyncResult.java | 15 ++ .../service/ChannelAccountService.java | 249 ++++++++++++++++++ .../mci/channel/controller/WeComSyncTest.java | 240 +++++++++++++++++ 11 files changed, 682 insertions(+) create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/MockWeComSyncClient.java create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComContact.java create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComContactType.java create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComConversation.java create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComConversationMember.java create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncClient.java create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncConfig.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/dto/SyncResult.java create mode 100644 backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSyncTest.java diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/MockWeComSyncClient.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/MockWeComSyncClient.java new file mode 100644 index 0000000..bdf194e --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/MockWeComSyncClient.java @@ -0,0 +1,54 @@ +package com.sino.mci.channel.wecom.sync; + +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 企业微信同步客户端的 mock 实现。 + * + *

不发起真实企微 API 调用,仅返回固定样例数据,供开发与测试使用。

+ */ +@Component +public class MockWeComSyncClient implements WeComSyncClient { + + @Override + public List syncContacts(WeComSyncConfig config) { + return List.of( + WeComContact.builder() + .userId("user001") + .name("张三") + .mobile("13800138001") + .type(WeComContactType.INTERNAL) + .departmentIds(List.of(1L, 2L)) + .build(), + WeComContact.builder() + .userId("wmexternal001") + .name("客户李四") + .mobile("13900139001") + .type(WeComContactType.EXTERNAL) + .build() + ); + } + + @Override + public List syncConversations(WeComSyncConfig config) { + return List.of( + WeComConversation.builder() + .chatId("chat001") + .chatName("测试客户群") + .ownerUserId("user001") + .memberList(List.of( + WeComConversationMember.builder() + .userId("user001") + .name("张三") + .build(), + WeComConversationMember.builder() + .userId("wmexternal001") + .name("客户李四") + .build() + )) + .build() + ); + } +} diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComContact.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComContact.java new file mode 100644 index 0000000..5e583fc --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComContact.java @@ -0,0 +1,21 @@ +package com.sino.mci.channel.wecom.sync; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WeComContact { + + private String userId; + private String name; + private String mobile; + private WeComContactType type; + private List departmentIds; +} diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComContactType.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComContactType.java new file mode 100644 index 0000000..d32b7a4 --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComContactType.java @@ -0,0 +1,6 @@ +package com.sino.mci.channel.wecom.sync; + +public enum WeComContactType { + INTERNAL, + EXTERNAL +} diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComConversation.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComConversation.java new file mode 100644 index 0000000..086e2e9 --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComConversation.java @@ -0,0 +1,20 @@ +package com.sino.mci.channel.wecom.sync; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WeComConversation { + + private String chatId; + private String chatName; + private String ownerUserId; + private List memberList; +} diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComConversationMember.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComConversationMember.java new file mode 100644 index 0000000..b9f2db5 --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComConversationMember.java @@ -0,0 +1,16 @@ +package com.sino.mci.channel.wecom.sync; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WeComConversationMember { + + private String userId; + private String name; +} diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncClient.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncClient.java new file mode 100644 index 0000000..ea5873c --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncClient.java @@ -0,0 +1,29 @@ +package com.sino.mci.channel.wecom.sync; + +import java.util.List; + +/** + * 企业微信通讯录与群聊同步客户端。 + * + *

封装企微「获取部门列表 / 部门成员详情 / 外部联系人列表 / 外部联系人详情 / + * 客户群列表 / 客户群详情」等接口调用。实际生产环境可替换为基于 {@code WxCpService} + * 的真实实现;默认提供 mock 实现用于开发/测试。

+ */ +public interface WeComSyncClient { + + /** + * 同步联系人(企业内部成员 + 外部联系人)。 + * + * @param config 企微配置 + * @return 联系人列表 + */ + List syncContacts(WeComSyncConfig config); + + /** + * 同步群聊(客户群)。 + * + * @param config 企微配置 + * @return 群聊列表 + */ + List syncConversations(WeComSyncConfig config); +} diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncConfig.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncConfig.java new file mode 100644 index 0000000..e64f02b --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncConfig.java @@ -0,0 +1,17 @@ +package com.sino.mci.channel.wecom.sync; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WeComSyncConfig { + + private String corpId; + private String agentId; + private String secret; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java index e86a33a..3ce7216 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java @@ -3,6 +3,7 @@ package com.sino.mci.channel.controller; import com.sino.mci.admin.security.AuthContext; import com.sino.mci.channel.dto.ChannelAccountInitRequest; import com.sino.mci.channel.dto.CreateChannelAccountRequest; +import com.sino.mci.channel.dto.SyncResult; import com.sino.mci.channel.entity.ChannelAccount; import com.sino.mci.channel.service.ChannelAccountService; import com.sino.mci.shared.web.ApiResponse; @@ -37,4 +38,18 @@ public class ChannelAccountController { public ApiResponse> listAccounts() { return ApiResponse.ok(accountService.listAccounts(AuthContext.currentTenantCode())); } + + @PostMapping("/{account_id}/sync-contacts") + public ApiResponse syncContacts( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("account_id") Long accountId) { + return ApiResponse.ok(accountService.syncContacts(accountId)); + } + + @PostMapping("/{account_id}/sync-conversations") + public ApiResponse syncConversations( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("account_id") Long accountId) { + return ApiResponse.ok(accountService.syncConversations(accountId)); + } } diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/SyncResult.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/SyncResult.java new file mode 100644 index 0000000..3829b6a --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/SyncResult.java @@ -0,0 +1,15 @@ +package com.sino.mci.channel.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SyncResult { + + private int syncedCount; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java index e5860c7..459e709 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java @@ -1,25 +1,63 @@ package com.sino.mci.channel.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.sino.mci.channel.common.model.ChannelType; import com.sino.mci.channel.dto.ChannelAccountInitRequest; import com.sino.mci.channel.dto.CreateChannelAccountRequest; +import com.sino.mci.channel.dto.SyncResult; import com.sino.mci.channel.entity.ChannelAccount; +import com.sino.mci.channel.entity.ChannelAccountConversation; import com.sino.mci.channel.entity.ChannelSubject; +import com.sino.mci.channel.entity.Conversation; +import com.sino.mci.channel.entity.ConversationParticipant; +import com.sino.mci.channel.repository.ChannelAccountConversationMapper; import com.sino.mci.channel.repository.ChannelAccountMapper; import com.sino.mci.channel.repository.ChannelSubjectMapper; +import com.sino.mci.channel.repository.ConversationMapper; +import com.sino.mci.channel.repository.ConversationParticipantMapper; +import com.sino.mci.channel.wecom.sync.WeComContact; +import com.sino.mci.channel.wecom.sync.WeComContactType; +import com.sino.mci.channel.wecom.sync.WeComConversation; +import com.sino.mci.channel.wecom.sync.WeComConversationMember; +import com.sino.mci.channel.wecom.sync.WeComSyncClient; +import com.sino.mci.channel.wecom.sync.WeComSyncConfig; +import com.sino.mci.crm.entity.ChannelIdentity; +import com.sino.mci.crm.entity.Person; +import com.sino.mci.crm.repository.ChannelIdentityMapper; +import com.sino.mci.crm.repository.PersonMapper; import com.sino.mci.exception.BusinessException; +import com.sino.mci.shared.util.JsonUtils; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; @Service @RequiredArgsConstructor public class ChannelAccountService { + private static final String CHANNEL_WECOM = ChannelType.WECOM.name().toLowerCase(); + + private static final int BINDING_SEND_MASTER = 1; + private static final int BINDING_RECEIVE_MASTER = 3; + + private static final int PERSON_TYPE_INTERNAL = 1; + private static final int PERSON_TYPE_CUSTOMER = 3; + + private static final int CONVERSATION_TYPE_GROUP = 2; + private static final int PARTICIPANT_TYPE_MEMBER = 1; + private static final int PARTICIPANT_TYPE_OWNER = 2; + private final ChannelAccountMapper accountMapper; private final ChannelSubjectMapper subjectMapper; + private final PersonMapper personMapper; + private final ChannelIdentityMapper channelIdentityMapper; + private final ConversationMapper conversationMapper; + private final ConversationParticipantMapper conversationParticipantMapper; + private final ChannelAccountConversationMapper accountConversationMapper; + private final WeComSyncClient weComSyncClient; public List listAccounts(String tenantCode) { return accountMapper.selectList( @@ -69,4 +107,215 @@ public class ChannelAccountService { // Phase 2 placeholder: actual adapter dispatch will be implemented later throw new BusinessException("渠道账号初始化适配器尚未实现: " + request.getChannelType()); } + + @Transactional + public SyncResult syncContacts(Long accountId) { + ChannelAccount account = getWeComAccount(accountId); + ChannelSubject subject = getSubject(account); + WeComSyncConfig config = buildSyncConfig(subject); + + List contacts = weComSyncClient.syncContacts(config); + for (WeComContact contact : contacts) { + upsertContact(account.getTenantCode(), account.getSubjectId(), contact); + } + return SyncResult.builder().syncedCount(contacts.size()).build(); + } + + @Transactional + public SyncResult syncConversations(Long accountId) { + ChannelAccount account = getWeComAccount(accountId); + ChannelSubject subject = getSubject(account); + WeComSyncConfig config = buildSyncConfig(subject); + + List conversations = weComSyncClient.syncConversations(config); + for (WeComConversation conversation : conversations) { + Conversation saved = upsertConversation(account, subject.getId(), conversation); + refreshParticipants(account.getTenantCode(), account.getSubjectId(), saved, conversation); + bindAccountAsDefault(accountId, saved.getId()); + } + return SyncResult.builder().syncedCount(conversations.size()).build(); + } + + private ChannelAccount getWeComAccount(Long accountId) { + ChannelAccount account = accountMapper.selectById(accountId); + if (account == null) { + throw new BusinessException("渠道账号不存在: " + accountId); + } + if (!CHANNEL_WECOM.equals(account.getChannelType())) { + throw new BusinessException("仅支持企微渠道账号同步"); + } + return account; + } + + private ChannelSubject getSubject(ChannelAccount account) { + if (account.getSubjectId() == null) { + throw new BusinessException("企微账号未绑定渠道主体"); + } + ChannelSubject subject = subjectMapper.selectById(account.getSubjectId()); + if (subject == null) { + throw new BusinessException("渠道主体不存在: " + account.getSubjectId()); + } + if (subject.getCorpId() == null || subject.getCorpSecret() == null) { + throw new BusinessException("渠道主体缺少 corp_id / corp_secret"); + } + return subject; + } + + private WeComSyncConfig buildSyncConfig(ChannelSubject subject) { + return WeComSyncConfig.builder() + .corpId(subject.getCorpId()) + .agentId(subject.getAgentId()) + .secret(subject.getCorpSecret()) + .build(); + } + + private void upsertContact(String tenantCode, Long subjectId, WeComContact contact) { + ChannelIdentity identity = channelIdentityMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChannelIdentity::getTenantCode, tenantCode) + .eq(ChannelIdentity::getChannelType, CHANNEL_WECOM) + .eq(ChannelIdentity::getChannelUserId, contact.getUserId())); + + Person person; + if (identity != null) { + person = personMapper.selectById(identity.getPersonId()); + person.setPersonName(contact.getName()); + person.setPhone(contact.getMobile()); + personMapper.updateById(person); + + identity.setChannelUserName(contact.getName()); + identity.setExtInfo(JsonUtils.toJson(contact)); + channelIdentityMapper.updateById(identity); + } else { + person = new Person(); + person.setTenantCode(tenantCode); + person.setPersonCode(contact.getUserId()); + person.setPersonName(contact.getName()); + person.setPhone(contact.getMobile()); + person.setPersonType(resolvePersonType(contact)); + person.setStatus(1); + personMapper.insert(person); + + ChannelIdentity newIdentity = new ChannelIdentity(); + newIdentity.setTenantCode(tenantCode); + newIdentity.setPersonId(person.getId()); + newIdentity.setChannelType(CHANNEL_WECOM); + newIdentity.setChannelUserId(contact.getUserId()); + newIdentity.setChannelUserName(contact.getName()); + newIdentity.setSubjectId(subjectId); + newIdentity.setStatus(1); + newIdentity.setExtInfo(JsonUtils.toJson(contact)); + channelIdentityMapper.insert(newIdentity); + } + } + + private Conversation upsertConversation(ChannelAccount account, Long subjectId, WeComConversation conv) { + Conversation conversation = conversationMapper.selectOne( + new LambdaQueryWrapper() + .eq(Conversation::getTenantCode, account.getTenantCode()) + .eq(Conversation::getChannelType, CHANNEL_WECOM) + .eq(Conversation::getChannelConversationId, conv.getChatId())); + + if (conversation != null) { + conversation.setConversationName(conv.getChatName()); + conversation.setOwnerAccountId(account.getId()); + conversation.setSubjectId(subjectId); + conversationMapper.updateById(conversation); + return conversation; + } + + Conversation newConversation = new Conversation(); + newConversation.setTenantCode(account.getTenantCode()); + newConversation.setConversationType(CONVERSATION_TYPE_GROUP); + newConversation.setChannelType(CHANNEL_WECOM); + newConversation.setChannelConversationId(conv.getChatId()); + newConversation.setOwnerAccountId(account.getId()); + newConversation.setSubjectId(subjectId); + newConversation.setConversationName(conv.getChatName()); + newConversation.setCreateSource(1); + newConversation.setStatus(1); + conversationMapper.insert(newConversation); + return newConversation; + } + + private void refreshParticipants(String tenantCode, Long subjectId, Conversation conversation, WeComConversation conv) { + conversationParticipantMapper.delete( + new LambdaQueryWrapper() + .eq(ConversationParticipant::getConversationId, conversation.getId())); + + for (WeComConversationMember member : conv.getMemberList()) { + ChannelIdentity identity = findOrCreateIdentity(tenantCode, subjectId, member); + + ConversationParticipant participant = new ConversationParticipant(); + participant.setConversationId(conversation.getId()); + participant.setPersonId(identity.getPersonId()); + participant.setChannelIdentityId(identity.getId()); + participant.setParticipantType(resolveParticipantType(member, conv.getOwnerUserId())); + participant.setJoinTime(LocalDateTime.now()); + conversationParticipantMapper.insert(participant); + } + } + + private ChannelIdentity findOrCreateIdentity(String tenantCode, Long subjectId, WeComConversationMember member) { + ChannelIdentity identity = channelIdentityMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChannelIdentity::getTenantCode, tenantCode) + .eq(ChannelIdentity::getChannelType, CHANNEL_WECOM) + .eq(ChannelIdentity::getChannelUserId, member.getUserId())); + + if (identity != null) { + return identity; + } + + Person person = new Person(); + person.setTenantCode(tenantCode); + person.setPersonCode(member.getUserId()); + person.setPersonName(member.getName()); + person.setPersonType(PERSON_TYPE_CUSTOMER); + person.setStatus(1); + personMapper.insert(person); + + ChannelIdentity newIdentity = new ChannelIdentity(); + newIdentity.setTenantCode(tenantCode); + newIdentity.setPersonId(person.getId()); + newIdentity.setChannelType(CHANNEL_WECOM); + newIdentity.setChannelUserId(member.getUserId()); + newIdentity.setChannelUserName(member.getName()); + newIdentity.setSubjectId(subjectId); + newIdentity.setStatus(1); + channelIdentityMapper.insert(newIdentity); + return newIdentity; + } + + private void bindAccountAsDefault(Long accountId, Long conversationId) { + ensureBinding(accountId, conversationId, BINDING_SEND_MASTER); + ensureBinding(accountId, conversationId, BINDING_RECEIVE_MASTER); + } + + private void ensureBinding(Long accountId, Long conversationId, int bindingType) { + ChannelAccountConversation existing = accountConversationMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChannelAccountConversation::getAccountId, accountId) + .eq(ChannelAccountConversation::getConversationId, conversationId) + .eq(ChannelAccountConversation::getBindingType, bindingType)); + if (existing != null) { + return; + } + + ChannelAccountConversation binding = new ChannelAccountConversation(); + binding.setAccountId(accountId); + binding.setConversationId(conversationId); + binding.setBindingType(bindingType); + binding.setSortOrder(0); + binding.setStatus(1); + accountConversationMapper.insert(binding); + } + + private int resolvePersonType(WeComContact contact) { + return contact.getType() == WeComContactType.INTERNAL ? PERSON_TYPE_INTERNAL : PERSON_TYPE_CUSTOMER; + } + + private int resolveParticipantType(WeComConversationMember member, String ownerUserId) { + return ownerUserId != null && ownerUserId.equals(member.getUserId()) ? PARTICIPANT_TYPE_OWNER : PARTICIPANT_TYPE_MEMBER; + } } diff --git a/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSyncTest.java b/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSyncTest.java new file mode 100644 index 0000000..0a29874 --- /dev/null +++ b/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSyncTest.java @@ -0,0 +1,240 @@ +package com.sino.mci.channel.controller; + +import com.sino.mci.channel.common.model.ChannelType; +import com.sino.mci.channel.entity.ChannelAccount; +import com.sino.mci.channel.entity.ChannelAccountConversation; +import com.sino.mci.channel.entity.ChannelSubject; +import com.sino.mci.channel.entity.Conversation; +import com.sino.mci.channel.entity.ConversationParticipant; +import com.sino.mci.channel.repository.ChannelAccountConversationMapper; +import com.sino.mci.channel.repository.ChannelAccountMapper; +import com.sino.mci.channel.repository.ChannelSubjectMapper; +import com.sino.mci.channel.repository.ConversationMapper; +import com.sino.mci.channel.repository.ConversationParticipantMapper; +import com.sino.mci.channel.wecom.sync.WeComContact; +import com.sino.mci.channel.wecom.sync.WeComContactType; +import com.sino.mci.channel.wecom.sync.WeComConversation; +import com.sino.mci.channel.wecom.sync.WeComConversationMember; +import com.sino.mci.channel.wecom.sync.WeComSyncClient; +import com.sino.mci.crm.entity.ChannelIdentity; +import com.sino.mci.crm.entity.Person; +import com.sino.mci.crm.repository.ChannelIdentityMapper; +import com.sino.mci.crm.repository.PersonMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = { + "spring.rabbitmq.listener.simple.auto-startup=false", + "spring.main.allow-bean-definition-overriding=true" +}) +@Transactional +class WeComSyncTest { + + private static final String TENANT_CODE = "wecom-sync-" + UUID.randomUUID().toString().substring(0, 8); + private static final String CHANNEL_TYPE = ChannelType.WECOM.name().toLowerCase(); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ChannelSubjectMapper subjectMapper; + + @Autowired + private ChannelAccountMapper accountMapper; + + @Autowired + private PersonMapper personMapper; + + @Autowired + private ChannelIdentityMapper channelIdentityMapper; + + @Autowired + private ConversationMapper conversationMapper; + + @Autowired + private ConversationParticipantMapper conversationParticipantMapper; + + @Autowired + private ChannelAccountConversationMapper accountConversationMapper; + + @Test + void shouldSyncContactsAndPersistPersonAndIdentity() throws Exception { + String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户"); + ChannelAccount account = createWeComAccount(); + + mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId()) + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", TENANT_CODE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.syncedCount").value(2)); + + Person internalPerson = personMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(Person::getTenantCode, TENANT_CODE) + .eq(Person::getPersonCode, "user001")); + assertThat(internalPerson).isNotNull(); + assertThat(internalPerson.getPersonName()).isEqualTo("张三"); + assertThat(internalPerson.getPhone()).isEqualTo("13800138001"); + assertThat(internalPerson.getPersonType()).isEqualTo(1); + + ChannelIdentity internalIdentity = channelIdentityMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ChannelIdentity::getTenantCode, TENANT_CODE) + .eq(ChannelIdentity::getChannelType, CHANNEL_TYPE) + .eq(ChannelIdentity::getChannelUserId, "user001")); + assertThat(internalIdentity).isNotNull(); + assertThat(internalIdentity.getPersonId()).isEqualTo(internalPerson.getId()); + assertThat(internalIdentity.getChannelUserName()).isEqualTo("张三"); + + Person externalPerson = personMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(Person::getTenantCode, TENANT_CODE) + .eq(Person::getPersonCode, "wmexternal001")); + assertThat(externalPerson).isNotNull(); + assertThat(externalPerson.getPersonType()).isEqualTo(3); + + ChannelIdentity externalIdentity = channelIdentityMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ChannelIdentity::getTenantCode, TENANT_CODE) + .eq(ChannelIdentity::getChannelType, CHANNEL_TYPE) + .eq(ChannelIdentity::getChannelUserId, "wmexternal001")); + assertThat(externalIdentity).isNotNull(); + assertThat(externalIdentity.getPersonId()).isEqualTo(externalPerson.getId()); + } + + @Test + void shouldSyncConversationsAndPersistParticipantsAndBindings() throws Exception { + String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户"); + ChannelAccount account = createWeComAccount(); + + mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId()) + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", TENANT_CODE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.syncedCount").value(1)); + + Conversation conversation = conversationMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(Conversation::getTenantCode, TENANT_CODE) + .eq(Conversation::getChannelType, CHANNEL_TYPE) + .eq(Conversation::getChannelConversationId, "chat001")); + assertThat(conversation).isNotNull(); + assertThat(conversation.getConversationType()).isEqualTo(2); + assertThat(conversation.getConversationName()).isEqualTo("测试客户群"); + assertThat(conversation.getOwnerAccountId()).isEqualTo(account.getId()); + + List participants = conversationParticipantMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ConversationParticipant::getConversationId, conversation.getId())); + assertThat(participants).hasSize(2); + + ConversationParticipant ownerParticipant = participants.stream() + .filter(p -> p.getParticipantType() == 2) + .findFirst() + .orElseThrow(); + Person ownerPerson = personMapper.selectById(ownerParticipant.getPersonId()); + assertThat(ownerPerson.getPersonCode()).isEqualTo("user001"); + + ChannelAccountConversation sendMaster = accountConversationMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ChannelAccountConversation::getAccountId, account.getId()) + .eq(ChannelAccountConversation::getConversationId, conversation.getId()) + .eq(ChannelAccountConversation::getBindingType, 1)); + assertThat(sendMaster).isNotNull(); + assertThat(sendMaster.getStatus()).isEqualTo(1); + + ChannelAccountConversation receiveMaster = accountConversationMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ChannelAccountConversation::getAccountId, account.getId()) + .eq(ChannelAccountConversation::getConversationId, conversation.getId()) + .eq(ChannelAccountConversation::getBindingType, 3)); + assertThat(receiveMaster).isNotNull(); + } + + private ChannelAccount createWeComAccount() { + ChannelSubject subject = new ChannelSubject(); + subject.setTenantCode(TENANT_CODE); + subject.setChannelType(CHANNEL_TYPE); + subject.setSubjectCode("sino"); + subject.setSubjectName("Sino"); + subject.setCorpId("ww-sync-" + UUID.randomUUID().toString().substring(0, 8)); + subject.setCorpSecret("secret-" + UUID.randomUUID()); + subject.setAgentId("1000002"); + subject.setStatus(1); + subjectMapper.insert(subject); + + ChannelAccount account = new ChannelAccount(); + account.setTenantCode(TENANT_CODE); + account.setAccountType(1); + account.setChannelType(CHANNEL_TYPE); + account.setSubjectId(subject.getId()); + account.setAccountId("sync-account"); + account.setAccountName("同步账号"); + account.setStatus(1); + accountMapper.insert(account); + return account; + } + + @TestConfiguration + static class MockConfig { + + @Bean + public WeComSyncClient weComSyncClient() { + WeComSyncClient client = mock(WeComSyncClient.class); + when(client.syncContacts(any())).thenReturn(List.of( + WeComContact.builder() + .userId("user001") + .name("张三") + .mobile("13800138001") + .type(WeComContactType.INTERNAL) + .build(), + WeComContact.builder() + .userId("wmexternal001") + .name("客户李四") + .mobile("13900139001") + .type(WeComContactType.EXTERNAL) + .build() + )); + when(client.syncConversations(any())).thenReturn(List.of( + WeComConversation.builder() + .chatId("chat001") + .chatName("测试客户群") + .ownerUserId("user001") + .memberList(List.of( + WeComConversationMember.builder().userId("user001").name("张三").build(), + WeComConversationMember.builder().userId("wmexternal001").name("客户李四").build() + )) + .build() + )); + return client; + } + } +}