feat(channel-wecom): 实现企微联系人/群聊同步

This commit is contained in:
2026-07-07 19:57:02 +08:00
parent 7b16160bba
commit f5d07b7eb5
11 changed files with 682 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
package com.sino.mci.channel.wecom.sync;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 企业微信同步客户端的 mock 实现。
*
* <p>不发起真实企微 API 调用,仅返回固定样例数据,供开发与测试使用。</p>
*/
@Component
public class MockWeComSyncClient implements WeComSyncClient {
@Override
public List<WeComContact> 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<WeComConversation> 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()
);
}
}

View File

@@ -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<Long> departmentIds;
}

View File

@@ -0,0 +1,6 @@
package com.sino.mci.channel.wecom.sync;
public enum WeComContactType {
INTERNAL,
EXTERNAL
}

View File

@@ -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<WeComConversationMember> memberList;
}

View File

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

View File

@@ -0,0 +1,29 @@
package com.sino.mci.channel.wecom.sync;
import java.util.List;
/**
* 企业微信通讯录与群聊同步客户端。
*
* <p>封装企微「获取部门列表 / 部门成员详情 / 外部联系人列表 / 外部联系人详情 /
* 客户群列表 / 客户群详情」等接口调用。实际生产环境可替换为基于 {@code WxCpService}
* 的真实实现;默认提供 mock 实现用于开发/测试。</p>
*/
public interface WeComSyncClient {
/**
* 同步联系人(企业内部成员 + 外部联系人)。
*
* @param config 企微配置
* @return 联系人列表
*/
List<WeComContact> syncContacts(WeComSyncConfig config);
/**
* 同步群聊(客户群)。
*
* @param config 企微配置
* @return 群聊列表
*/
List<WeComConversation> syncConversations(WeComSyncConfig config);
}

View File

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

View File

@@ -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<List<ChannelAccount>> listAccounts() {
return ApiResponse.ok(accountService.listAccounts(AuthContext.currentTenantCode()));
}
@PostMapping("/{account_id}/sync-contacts")
public ApiResponse<SyncResult> 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<SyncResult> syncConversations(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("account_id") Long accountId) {
return ApiResponse.ok(accountService.syncConversations(accountId));
}
}

View File

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

View File

@@ -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<ChannelAccount> 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<WeComContact> 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<WeComConversation> 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<ChannelIdentity>()
.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<Conversation>()
.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<ConversationParticipant>()
.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<ChannelIdentity>()
.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<ChannelAccountConversation>()
.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;
}
}

View File

@@ -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<Person>()
.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<ChannelIdentity>()
.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<Person>()
.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<ChannelIdentity>()
.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<Conversation>()
.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<ConversationParticipant> participants = conversationParticipantMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ConversationParticipant>()
.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<ChannelAccountConversation>()
.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<ChannelAccountConversation>()
.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;
}
}
}