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;
+ }
+ }
+}