fix: 渠道账号群聊同步缓存 key 及成员名称兜底;统一 Docker/Maven 项目约定

This commit is contained in:
2026-07-10 15:55:30 +08:00
parent 560bd4599b
commit 758ed73f9d
56 changed files with 7689 additions and 1127 deletions

View File

@@ -0,0 +1,2 @@
--toolchains
/Users/marsal/Projects/sino-project/sino-mci/backend/.mvn/toolchains.xml

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<toolchains>
<toolchain>
<type>jdk</type>
<provides>
<version>21</version>
<vendor>zulu</vendor>
</provides>
<configuration>
<jdkHome>/Library/Java/JavaVirtualMachines/zulu-21.jdk/Contents/Home</jdkHome>
</configuration>
</toolchain>
</toolchains>

View File

@@ -1,5 +1,6 @@
package com.sino.mci.channel.wecom.sync;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -10,10 +11,11 @@ import java.util.List;
* <p>不发起真实企微 API 调用,仅返回固定样例数据,供开发与测试使用。</p>
*/
@Component
@ConditionalOnProperty(name = "mci.wecom.sync.mock", havingValue = "true")
public class MockWeComSyncClient implements WeComSyncClient {
@Override
public List<WeComContact> syncContacts(WeComSyncConfig config) {
public List<WeComContact> syncEnterpriseContacts(WeComSyncConfig config) {
return List.of(
WeComContact.builder()
.userId("user001")
@@ -21,7 +23,13 @@ public class MockWeComSyncClient implements WeComSyncClient {
.mobile("13800138001")
.type(WeComContactType.INTERNAL)
.departmentIds(List.of(1L, 2L))
.build(),
.build()
);
}
@Override
public List<WeComContact> syncAccountContacts(WeComSyncConfig config, String userId) {
return List.of(
WeComContact.builder()
.userId("wmexternal001")
.name("客户李四")
@@ -32,15 +40,15 @@ public class MockWeComSyncClient implements WeComSyncClient {
}
@Override
public List<WeComConversation> syncConversations(WeComSyncConfig config) {
public List<WeComConversation> syncAccountConversations(WeComSyncConfig config, String userId) {
return List.of(
WeComConversation.builder()
.chatId("chat001")
.chatName("测试客户群")
.ownerUserId("user001")
.ownerUserId(userId)
.memberList(List.of(
WeComConversationMember.builder()
.userId("user001")
.userId(userId)
.name("张三")
.build(),
WeComConversationMember.builder()

View File

@@ -0,0 +1,258 @@
package com.sino.mci.channel.wecom.sync;
import com.sino.mci.channel.wecom.support.WeComServiceFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.WxCpDepart;
import me.chanjar.weixin.cp.bean.WxCpUser;
import me.chanjar.weixin.cp.bean.external.WxCpUserExternalGroupChatInfo;
import me.chanjar.weixin.cp.bean.external.WxCpUserExternalGroupChatList;
import me.chanjar.weixin.cp.bean.external.contact.WxCpExternalContactInfo;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 企业微信通讯录与群聊真实同步客户端。
*
* <p>基于 {@link WxCpService} 调用企微开放接口,拉取企业内部成员、外部联系人及客户群。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "mci.wecom.sync.mock", havingValue = "false", matchIfMissing = true)
public class RealWeComSyncClient implements WeComSyncClient {
private final WeComServiceFactory serviceFactory;
@Override
public List<WeComContact> syncEnterpriseContacts(WeComSyncConfig config) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过企业通讯录同步");
return Collections.emptyList();
}
List<WeComContact> contacts = new ArrayList<>();
contacts.addAll(syncInternalUsers(service, config));
contacts.addAll(syncExternalContacts(service, config));
log.info("[WeComSync] 同步企业通讯录完成,共 {} 条", contacts.size());
return contacts;
}
@Override
public List<WeComContact> syncAccountContacts(WeComSyncConfig config, String userId) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过账号客户同步");
return Collections.emptyList();
}
List<WeComContact> contacts = syncExternalContactsForUser(service, config, userId);
log.info("[WeComSync] 同步账号 {} 的客户完成,共 {} 条", userId, contacts.size());
return contacts;
}
@Override
public List<WeComConversation> syncAccountConversations(WeComSyncConfig config, String userId) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过账号群聊同步");
return Collections.emptyList();
}
List<WeComConversation> conversations = new ArrayList<>();
try {
String cursor = "";
do {
WxCpUserExternalGroupChatList result = service.getExternalContactService()
.listGroupChat(1000, cursor, 0, new String[]{userId});
if (result == null || result.getGroupChatList() == null) {
break;
}
for (WxCpUserExternalGroupChatList.ChatStatus chat : result.getGroupChatList()) {
WeComConversation conversation = fetchGroupChatDetail(service, chat.getChatId());
if (conversation != null) {
conversations.add(conversation);
}
}
cursor = result.getNextCursor();
} while (cursor != null && !cursor.isEmpty());
} catch (WxErrorException e) {
log.warn("[WeComSync] 拉取账号 {} 的客户群列表失败,可能应用缺少客户联系权限: {}", userId, e.getMessage());
} catch (Exception e) {
log.warn("[WeComSync] 拉取账号 {} 的客户群列表异常", userId, e);
}
log.info("[WeComSync] 同步账号 {} 的群聊完成,共 {} 条", userId, conversations.size());
return conversations;
}
private WxCpService resolveService(WeComSyncConfig config) {
if (config == null || config.getCorpId() == null || config.getAgentId() == null || config.getSecret() == null) {
return null;
}
try {
return serviceFactory.getOrCreateService(
config.getCorpId(), Integer.valueOf(config.getAgentId()), config.getSecret());
} catch (Exception e) {
log.warn("[WeComSync] 创建 WxCpService 失败", e);
return null;
}
}
private List<WeComContact> syncInternalUsers(WxCpService service, WeComSyncConfig config) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<WxCpDepart> departments = service.getDepartmentService().list(null);
if (departments == null || departments.isEmpty()) {
return contacts;
}
for (WxCpDepart dept : departments) {
try {
List<WxCpUser> users = service.getUserService().listByDepartment(dept.getId(), true, 0);
if (users == null) {
continue;
}
for (WxCpUser user : users) {
contacts.add(WeComContact.builder()
.userId(user.getUserId())
.name(user.getName())
.mobile(user.getMobile())
.type(WeComContactType.INTERNAL)
.departmentIds(user.getDepartIds() == null
? Collections.emptyList()
: Arrays.stream(user.getDepartIds()).collect(Collectors.toList()))
.build());
}
} catch (WxErrorException e) {
log.warn("[WeComSync] 获取部门 {} 成员失败: {}", dept.getId(), e.getMessage());
}
}
} catch (WxErrorException e) {
log.warn("[WeComSync] 拉取部门列表失败,可能应用缺少通讯录权限: {}", e.getMessage());
} catch (Exception e) {
log.warn("[WeComSync] 拉取内部成员异常", e);
}
return contacts;
}
private List<WeComContact> syncExternalContacts(WxCpService service, WeComSyncConfig config) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<String> followUsers = service.getExternalContactService().listFollowers();
if (followUsers == null || followUsers.isEmpty()) {
return contacts;
}
for (String userId : followUsers) {
contacts.addAll(syncExternalContactsForUser(service, config, userId));
}
} catch (WxErrorException e) {
log.warn("[WeComSync] 拉取客户联系成员失败,可能应用缺少客户联系权限: {}", e.getMessage());
} catch (Exception e) {
log.warn("[WeComSync] 拉取外部联系人异常", e);
}
return contacts;
}
private List<WeComContact> syncExternalContactsForUser(WxCpService service, WeComSyncConfig config, String userId) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<String> externalUserIds = service.getExternalContactService().listExternalContacts(userId);
if (externalUserIds == null) {
return contacts;
}
for (String externalUserId : externalUserIds) {
try {
WxCpExternalContactInfo info = service.getExternalContactService()
.getContactDetail(externalUserId, null);
if (info == null || info.getExternalContact() == null) {
continue;
}
contacts.add(WeComContact.builder()
.userId(externalUserId)
.name(info.getExternalContact().getName())
.mobile(null)
.type(WeComContactType.EXTERNAL)
.departmentIds(Collections.emptyList())
.build());
} catch (WxErrorException e) {
log.warn("[WeComSync] 获取外部联系人 {} 详情失败: {}", externalUserId, e.getMessage());
}
}
} catch (WxErrorException e) {
log.warn("[WeComSync] 获取成员 {} 的客户列表失败: {}", userId, e.getMessage());
}
return contacts;
}
private WeComConversation fetchGroupChatDetail(WxCpService service, String chatId) {
try {
WxCpUserExternalGroupChatInfo detail = service.getExternalContactService().getGroupChat(chatId, 0);
if (detail == null || detail.getGroupChat() == null) {
return null;
}
WxCpUserExternalGroupChatInfo.GroupChat groupChat = detail.getGroupChat();
List<WeComConversationMember> members = new ArrayList<>();
if (groupChat.getMemberList() != null) {
for (WxCpUserExternalGroupChatInfo.GroupMember member : groupChat.getMemberList()) {
members.add(WeComConversationMember.builder()
.userId(member.getUserId())
.name(resolveMemberName(service, member))
.build());
}
}
return WeComConversation.builder()
.chatId(groupChat.getChatId())
.chatName(groupChat.getName())
.ownerUserId(groupChat.getOwner())
.memberList(members)
.build();
} catch (WxErrorException e) {
log.warn("[WeComSync] 获取群聊 {} 详情失败: {}", chatId, e.getMessage());
return null;
} catch (Exception e) {
log.warn("[WeComSync] 获取群聊 {} 详情异常", chatId, e);
return null;
}
}
private String resolveMemberName(WxCpService service, WxCpUserExternalGroupChatInfo.GroupMember member) {
if (StringUtils.isNotBlank(member.getName())) {
return member.getName();
}
String userId = member.getUserId();
if (StringUtils.isBlank(userId)) {
return null;
}
// 外部联系人:通过客户详情接口补全名称
if (userId.startsWith("wm")) {
try {
WxCpExternalContactInfo info = service.getExternalContactService().getContactDetail(userId, null);
if (info != null && info.getExternalContact() != null
&& StringUtils.isNotBlank(info.getExternalContact().getName())) {
return info.getExternalContact().getName();
}
} catch (WxErrorException e) {
log.debug("[WeComSync] 获取外部联系人 {} 名称失败: {}", userId, e.getMessage());
}
}
// 内部成员:通过通讯录接口补全名称
try {
WxCpUser user = service.getUserService().getById(userId);
if (user != null && StringUtils.isNotBlank(user.getName())) {
return user.getName();
}
} catch (WxErrorException e) {
log.debug("[WeComSync] 获取内部成员 {} 名称失败: {}", userId, e.getMessage());
}
return userId;
}
}

View File

@@ -12,18 +12,28 @@ import java.util.List;
public interface WeComSyncClient {
/**
* 同步联系人(企业内部成员 + 外部联系人)。
* 同步企业通讯录(部门 + 内部成员)。
*
* @param config 企微配置
* @return 联系人列表
*/
List<WeComContact> syncContacts(WeComSyncConfig config);
List<WeComContact> syncEnterpriseContacts(WeComSyncConfig config);
/**
* 同步群聊(客户群)。
* 同步某个成员的客户(外部联系人)。
*
* @param config 企微配置
* @param userId 成员 userId
* @return 联系人列表
*/
List<WeComContact> syncAccountContacts(WeComSyncConfig config, String userId);
/**
* 同步某个成员的客户群。
*
* @param config 企微配置
* @param userId 成员 userId
* @return 群聊列表
*/
List<WeComConversation> syncConversations(WeComSyncConfig config);
List<WeComConversation> syncAccountConversations(WeComSyncConfig config, String userId);
}

View File

@@ -70,6 +70,11 @@
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.39.0</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>

View File

@@ -4,9 +4,9 @@ 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.HeartbeatRequest;
import com.sino.mci.channel.dto.SyncResult;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.service.ChannelAccountService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
@@ -78,16 +78,16 @@ public class ChannelAccountController {
}
@PostMapping("/{account_id}/sync-contacts")
public ApiResponse<SyncResult> syncContacts(
public ApiResponse<SyncTask> syncContacts(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("account_id") Long accountId) {
return ApiResponse.ok(accountService.syncContacts(accountId));
return ApiResponse.ok(accountService.submitContactsSyncTask(accountId));
}
@PostMapping("/{account_id}/sync-conversations")
public ApiResponse<SyncResult> syncConversations(
public ApiResponse<SyncTask> syncConversations(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("account_id") Long accountId) {
return ApiResponse.ok(accountService.syncConversations(accountId));
return ApiResponse.ok(accountService.submitConversationsSyncTask(accountId));
}
}

View File

@@ -54,4 +54,11 @@ public class ChannelSubjectController {
public ApiResponse<List<ChannelSubject>> listSubjects() {
return ApiResponse.ok(subjectService.listSubjects(AuthContext.currentTenantCode()));
}
@PostMapping("/{id}/sync-contacts")
public ApiResponse<com.sino.mci.channel.entity.SyncTask> syncEnterpriseContacts(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
return ApiResponse.ok(subjectService.submitEnterpriseContactsSyncTask(id, tenantCode));
}
}

View File

@@ -0,0 +1,22 @@
package com.sino.mci.channel.controller;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.service.SyncTaskService;
import com.sino.mci.shared.web.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/sync-task")
@RequiredArgsConstructor
public class SyncTaskController {
private final SyncTaskService syncTaskService;
@GetMapping("/{id}")
public ApiResponse<SyncTask> getTask(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
return ApiResponse.ok(syncTaskService.getTask(id, tenantCode));
}
}

View File

@@ -85,7 +85,7 @@ public class WeComCallbackController {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
rabbitTemplate.convertAndSend(RabbitConfig.SESSION_ARCHIVE_EXCHANGE,

View File

@@ -16,7 +16,6 @@ public class CreateChannelSubjectRequest {
private String subjectName;
private String corpId;
private String corpSecret;
private String agentId;
private String agentSecret;
private Integer sessionArchiveEnabled;

View File

@@ -15,7 +15,6 @@ public class ChannelSubject extends BaseEntity {
private String subjectCode;
private String subjectName;
private String corpId;
private String corpSecret;
private String agentId;
private String agentSecret;
private Integer sessionArchiveEnabled;

View File

@@ -0,0 +1,30 @@
package com.sino.mci.channel.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.sino.mci.shared.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mci_sync_task")
public class SyncTask extends BaseEntity {
public static final int STATUS_PENDING = 0;
public static final int STATUS_RUNNING = 1;
public static final int STATUS_SUCCESS = 2;
public static final int STATUS_FAILED = 3;
private String tenantCode;
private String taskType;
private String channelType;
private Long targetId;
private String targetCode;
private Integer taskStatus;
private Integer syncedCount;
private Integer failedCount;
private String errorMessage;
private Integer retryCount;
private java.time.LocalDateTime finishTime;
private Integer deleted;
}

View File

@@ -0,0 +1,9 @@
package com.sino.mci.channel.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sino.mci.channel.entity.SyncTask;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SyncTaskMapper extends BaseMapper<SyncTask> {
}

View File

@@ -13,27 +13,12 @@ import com.sino.mci.channel.dto.HeartbeatRequest;
import com.sino.mci.channel.dto.SyncResult;
import com.sino.mci.channel.dto.UpdateStatusRequest;
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.entity.SyncTask;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -51,16 +36,6 @@ 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 static final int ONLINE_STATUS_OFFLINE = 0;
private static final int ONLINE_STATUS_ONLINE = 1;
private static final int ACCOUNT_STATUS_ENABLED = 1;
@@ -68,13 +43,10 @@ public class ChannelAccountService {
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;
private final ChannelAdapterRegistry adapterRegistry;
private final SyncTaskService syncTaskService;
private final SyncTaskExecutor syncTaskExecutor;
public List<ChannelAccount> listAccounts(String tenantCode) {
return accountMapper.selectList(
@@ -119,7 +91,7 @@ public class ChannelAccountService {
account.setAccountName(request.getAccountName());
account.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
account.setServerHost(request.getServerHost());
account.setExtInfo(request.getExtInfo());
account.setExtInfo(normalizeExtInfo(request.getExtInfo()));
account.setLoginStatus(0);
account.setOnlineStatus(0);
account.setRiskLevel(0);
@@ -157,11 +129,15 @@ public class ChannelAccountService {
account.setAccountName(request.getAccountName());
account.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
account.setServerHost(request.getServerHost());
account.setExtInfo(request.getExtInfo());
account.setExtInfo(normalizeExtInfo(request.getExtInfo()));
accountMapper.updateById(account);
return account;
}
private String normalizeExtInfo(String extInfo) {
return (extInfo == null || extInfo.isBlank()) ? null : extInfo;
}
@Transactional
public void deleteAccount(Long id, String tenantCode) {
ChannelAccount account = getAccount(id, tenantCode);
@@ -268,8 +244,8 @@ public class ChannelAccountService {
if (subject.getCorpId() != null) {
config.putIfAbsent("corp_id", subject.getCorpId());
}
if (subject.getCorpSecret() != null) {
config.putIfAbsent("secret", subject.getCorpSecret());
if (subject.getAgentSecret() != null) {
config.putIfAbsent("secret", subject.getAgentSecret());
}
if (subject.getAgentId() != null) {
config.putIfAbsent("agent_id", subject.getAgentId());
@@ -322,32 +298,20 @@ public class ChannelAccountService {
return result;
}
@Transactional
public SyncResult syncContacts(Long accountId) {
public SyncTask submitContactsSyncTask(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();
SyncTask task = syncTaskService.createTask(
account.getTenantCode(), "account_contacts", account.getChannelType(), accountId, account.getAccountId());
syncTaskExecutor.executeAccountContactsSync(task.getId());
return task;
}
@Transactional
public SyncResult syncConversations(Long accountId) {
public SyncTask submitConversationsSyncTask(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();
SyncTask task = syncTaskService.createTask(
account.getTenantCode(), "account_conversations", account.getChannelType(), accountId, account.getAccountId());
syncTaskExecutor.executeAccountConversationsSync(task.getId());
return task;
}
private ChannelAccount getWeComAccount(Long accountId) {
@@ -361,193 +325,4 @@ public class ChannelAccountService {
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());
if (person == null) {
log.warn("ChannelIdentity {} references missing person {}", identity.getId(), identity.getPersonId());
return;
}
Long originalInstitutionId = person.getInstitutionId();
Long originalSupplierId = person.getSupplierId();
Integer originalPersonType = person.getPersonType();
person.setPersonName(contact.getName());
person.setPhone(contact.getMobile());
person.setInstitutionId(originalInstitutionId);
person.setSupplierId(originalSupplierId);
person.setPersonType(originalPersonType);
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) {
Long originalInstitutionId = conversation.getInstitutionId();
Long originalSupplierId = conversation.getSupplierId();
Long originalContractId = conversation.getContractId();
String originalChannelCode = conversation.getChannelCode();
conversation.setConversationName(conv.getChatName());
conversation.setOwnerAccountId(account.getId());
conversation.setSubjectId(subjectId);
conversation.setInstitutionId(originalInstitutionId);
conversation.setSupplierId(originalSupplierId);
conversation.setContractId(originalContractId);
conversation.setChannelCode(originalChannelCode);
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

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.exception.BusinessException;
import lombok.RequiredArgsConstructor;
@@ -17,6 +18,8 @@ import java.util.List;
public class ChannelSubjectService {
private final ChannelSubjectMapper subjectMapper;
private final SyncTaskService syncTaskService;
private final SyncTaskExecutor syncTaskExecutor;
public List<ChannelSubject> listSubjects(String tenantCode) {
return subjectMapper.selectList(
@@ -51,7 +54,6 @@ public class ChannelSubjectService {
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
@@ -80,7 +82,6 @@ public class ChannelSubjectService {
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
@@ -103,4 +104,12 @@ public class ChannelSubjectService {
subjectMapper.updateById(subject);
return subject;
}
public SyncTask submitEnterpriseContactsSyncTask(Long id, String tenantCode) {
ChannelSubject subject = getSubject(id, tenantCode);
SyncTask task = syncTaskService.createTask(
tenantCode, "enterprise_contacts", subject.getChannelType(), id, subject.getSubjectCode());
syncTaskExecutor.executeEnterpriseContactsSync(task.getId());
return task;
}
}

View File

@@ -0,0 +1,226 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.entity.ConversationParticipant;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
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.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.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Slf4j
@Component
@RequiredArgsConstructor
public class SyncDataPersister {
private static final String CHANNEL_WECOM = "wecom";
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 PersonMapper personMapper;
private final ChannelIdentityMapper channelIdentityMapper;
private final ConversationMapper conversationMapper;
private final ConversationParticipantMapper conversationParticipantMapper;
private final ChannelAccountConversationMapper accountConversationMapper;
@Transactional
public 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());
if (person == null) {
log.warn("ChannelIdentity {} references missing person {}", identity.getId(), identity.getPersonId());
return;
}
Long originalInstitutionId = person.getInstitutionId();
Long originalSupplierId = person.getSupplierId();
Integer originalPersonType = person.getPersonType();
person.setPersonName(contact.getName());
person.setPhone(contact.getMobile());
person.setInstitutionId(originalInstitutionId);
person.setSupplierId(originalSupplierId);
person.setPersonType(originalPersonType);
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);
}
}
@Transactional
public 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) {
Long originalInstitutionId = conversation.getInstitutionId();
Long originalSupplierId = conversation.getSupplierId();
Long originalContractId = conversation.getContractId();
String originalChannelCode = conversation.getChannelCode();
conversation.setConversationName(conv.getChatName());
conversation.setOwnerAccountId(account.getId());
conversation.setSubjectId(subjectId);
conversation.setInstitutionId(originalInstitutionId);
conversation.setSupplierId(originalSupplierId);
conversation.setContractId(originalContractId);
conversation.setChannelCode(originalChannelCode);
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;
}
@Transactional
public 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);
}
}
@Transactional
public void bindAccountAsDefault(Long accountId, Long conversationId) {
ensureBinding(accountId, conversationId, BINDING_SEND_MASTER);
ensureBinding(accountId, conversationId, BINDING_RECEIVE_MASTER);
}
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;
}
String displayName = resolveDisplayName(member.getName(), member.getUserId());
Person person = new Person();
person.setTenantCode(tenantCode);
person.setPersonCode(member.getUserId());
person.setPersonName(displayName);
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(displayName);
newIdentity.setSubjectId(subjectId);
newIdentity.setStatus(1);
channelIdentityMapper.insert(newIdentity);
return newIdentity;
}
private String resolveDisplayName(String name, String userId) {
if (name != null && !name.isBlank()) {
return name;
}
return userId != null && !userId.isBlank() ? userId : "未知";
}
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,124 @@
package com.sino.mci.channel.service;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.channel.wecom.sync.WeComContact;
import com.sino.mci.channel.wecom.sync.WeComConversation;
import com.sino.mci.channel.wecom.sync.WeComSyncClient;
import com.sino.mci.channel.wecom.sync.WeComSyncConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class SyncTaskExecutor {
private final SyncTaskService syncTaskService;
private final ChannelAccountMapper accountMapper;
private final ChannelSubjectMapper subjectMapper;
private final SyncDataPersister syncDataPersister;
private final WeComSyncClient weComSyncClient;
@Async("syncTaskPool")
public void executeEnterpriseContactsSync(Long taskId) {
SyncTask task = syncTaskService.getTask(taskId, null);
if (task == null) {
return;
}
syncTaskService.markRunning(taskId);
try {
ChannelSubject subject = subjectMapper.selectById(task.getTargetId());
if (subject == null) {
throw new RuntimeException("渠道主体不存在");
}
WeComSyncConfig config = WeComSyncConfig.builder()
.corpId(subject.getCorpId())
.agentId(subject.getAgentId())
.secret(subject.getAgentSecret())
.build();
List<WeComContact> contacts = weComSyncClient.syncEnterpriseContacts(config);
for (WeComContact contact : contacts) {
syncDataPersister.upsertContact(subject.getTenantCode(), subject.getId(), contact);
}
syncTaskService.markSuccess(taskId, contacts.size());
} catch (Exception e) {
log.error("[SyncTask] 企业通讯录同步失败 taskId={}", taskId, e);
syncTaskService.markFailed(taskId, e.getMessage());
}
}
@Async("syncTaskPool")
public void executeAccountContactsSync(Long taskId) {
SyncTask task = syncTaskService.getTask(taskId, null);
if (task == null) {
return;
}
syncTaskService.markRunning(taskId);
try {
ChannelAccount account = accountMapper.selectById(task.getTargetId());
if (account == null) {
throw new RuntimeException("渠道账号不存在");
}
ChannelSubject subject = subjectMapper.selectById(account.getSubjectId());
if (subject == null) {
throw new RuntimeException("渠道主体不存在");
}
WeComSyncConfig config = WeComSyncConfig.builder()
.corpId(subject.getCorpId())
.agentId(subject.getAgentId())
.secret(subject.getAgentSecret())
.build();
List<WeComContact> contacts = weComSyncClient.syncAccountContacts(config, account.getAccountId());
for (WeComContact contact : contacts) {
syncDataPersister.upsertContact(account.getTenantCode(), account.getSubjectId(), contact);
}
syncTaskService.markSuccess(taskId, contacts.size());
} catch (Exception e) {
log.error("[SyncTask] 账号客户同步失败 taskId={}", taskId, e);
syncTaskService.markFailed(taskId, e.getMessage());
}
}
@Async("syncTaskPool")
public void executeAccountConversationsSync(Long taskId) {
SyncTask task = syncTaskService.getTask(taskId, null);
if (task == null) {
return;
}
syncTaskService.markRunning(taskId);
try {
ChannelAccount account = accountMapper.selectById(task.getTargetId());
if (account == null) {
throw new RuntimeException("渠道账号不存在");
}
ChannelSubject subject = subjectMapper.selectById(account.getSubjectId());
if (subject == null) {
throw new RuntimeException("渠道主体不存在");
}
WeComSyncConfig config = WeComSyncConfig.builder()
.corpId(subject.getCorpId())
.agentId(subject.getAgentId())
.secret(subject.getAgentSecret())
.build();
List<WeComConversation> conversations = weComSyncClient.syncAccountConversations(config, account.getAccountId());
for (WeComConversation conversation : conversations) {
Conversation saved = syncDataPersister.upsertConversation(account, subject.getId(), conversation);
syncDataPersister.refreshParticipants(account.getTenantCode(), account.getSubjectId(), saved, conversation);
syncDataPersister.bindAccountAsDefault(account.getId(), saved.getId());
}
syncTaskService.markSuccess(taskId, conversations.size());
} catch (Exception e) {
log.error("[SyncTask] 账号群聊同步失败 taskId={}", taskId, e);
syncTaskService.markFailed(taskId, e.getMessage());
}
}
}

View File

@@ -0,0 +1,97 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.SyncTaskMapper;
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 SyncTaskService {
private final SyncTaskMapper syncTaskMapper;
@Transactional
public SyncTask createTask(String tenantCode, String taskType, String channelType, Long targetId, String targetCode) {
SyncTask task = new SyncTask();
task.setTenantCode(tenantCode);
task.setTaskType(taskType);
task.setChannelType(channelType);
task.setTargetId(targetId);
task.setTargetCode(targetCode);
task.setTaskStatus(SyncTask.STATUS_PENDING);
task.setSyncedCount(0);
task.setFailedCount(0);
task.setRetryCount(0);
task.setDeleted(0);
syncTaskMapper.insert(task);
return task;
}
@Transactional
public void markRunning(Long taskId) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
return;
}
task.setTaskStatus(SyncTask.STATUS_RUNNING);
task.setUpdateTime(LocalDateTime.now());
syncTaskMapper.updateById(task);
}
@Transactional
public void markSuccess(Long taskId, int syncedCount) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
return;
}
task.setTaskStatus(SyncTask.STATUS_SUCCESS);
task.setSyncedCount(syncedCount);
task.setFinishTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
syncTaskMapper.updateById(task);
}
@Transactional
public void markFailed(Long taskId, String errorMessage) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
return;
}
task.setTaskStatus(SyncTask.STATUS_FAILED);
task.setErrorMessage(errorMessage);
task.setFinishTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
syncTaskMapper.updateById(task);
}
public SyncTask getTask(Long taskId, String tenantCode) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
return null;
}
if (tenantCode != null && !tenantCode.equals(task.getTenantCode())) {
return null;
}
return task;
}
public SyncTask getTaskById(Long taskId) {
return syncTaskMapper.selectById(taskId);
}
public List<SyncTask> listRecentTasks(String tenantCode, String taskType, Long targetId, int limit) {
return syncTaskMapper.selectList(
new LambdaQueryWrapper<SyncTask>()
.eq(SyncTask::getTenantCode, tenantCode)
.eq(SyncTask::getTaskType, taskType)
.eq(targetId != null, SyncTask::getTargetId, targetId)
.orderByDesc(SyncTask::getId)
.last("LIMIT " + limit));
}
}

View File

@@ -0,0 +1,26 @@
package com.sino.mci.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("syncTaskPool")
public Executor syncTaskPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("sync-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE mci_channel_subject DROP COLUMN corp_secret;

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS mci_sync_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户',
task_type VARCHAR(32) NOT NULL COMMENT '任务类型enterprise_contacts / account_contacts / account_conversations',
channel_type VARCHAR(32) NOT NULL COMMENT '渠道wecom / wechat_personal / dingtalk / feishu',
target_id BIGINT COMMENT '关联目标 ID主体 ID 或账号 ID',
target_code VARCHAR(128) COMMENT '关联目标编码:如 userId',
task_status TINYINT NOT NULL DEFAULT 0 COMMENT '任务状态0待执行 1执行中 2成功 3失败',
synced_count INT NOT NULL DEFAULT 0 COMMENT '已同步数量',
failed_count INT NOT NULL DEFAULT 0 COMMENT '失败数量',
error_message VARCHAR(1024) COMMENT '错误信息',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
finish_time DATETIME COMMENT '完成时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
KEY idx_tenant_status (tenant_code, task_status),
KEY idx_target (task_type, target_id),
KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同步任务表';

View File

@@ -48,7 +48,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.main.allow-bean-definition-overriding=true",
"sino.channel.wecom.session-archive.listener-enabled=false"
"sino.channel.wecom.session-archive.listener-enabled=false",
"mci.wecom.sync.mock=true"
})
@Transactional
class WeComSessionArchiveTest {
@@ -133,7 +134,7 @@ class WeComSessionArchiveTest {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
sessionArchivePullService.onPullCommand(command);
@@ -177,7 +178,7 @@ class WeComSessionArchiveTest {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
sessionArchivePullService.onPullCommand(command);
@@ -202,7 +203,7 @@ class WeComSessionArchiveTest {
subject.setSubjectCode("archive");
subject.setSubjectName("会话存档主体");
subject.setCorpId(CORP_ID);
subject.setCorpSecret("secret-" + UUID.randomUUID());
subject.setAgentSecret("secret-" + UUID.randomUUID());
subject.setSessionArchiveEnabled(1);
subject.setSessionArchivePrivateKey("private-key");
subject.setStatus(1);

View File

@@ -1,16 +1,19 @@
package com.sino.mci.channel.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
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.entity.SyncTask;
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.service.SyncTaskService;
import com.sino.mci.channel.wecom.sync.WeComContact;
import com.sino.mci.channel.wecom.sync.WeComContactType;
import com.sino.mci.channel.wecom.sync.WeComConversation;
@@ -29,7 +32,7 @@ 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 org.springframework.test.web.servlet.MvcResult;
import java.util.List;
import java.util.UUID;
@@ -47,14 +50,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.main.allow-bean-definition-overriding=true"
"spring.main.allow-bean-definition-overriding=true",
"mci.wecom.sync.mock=true"
})
@Transactional
class WeComSyncTest {
private static final String TENANT_CODE = "wecom-sync-" + UUID.randomUUID().toString().substring(0, 8);
private final String tenantCode = "wecom-sync-" + UUID.randomUUID().toString().substring(0, 8);
private static final String CHANNEL_TYPE = ChannelType.WECOM.name().toLowerCase();
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private MockMvc mockMvc;
@@ -79,23 +84,31 @@ class WeComSyncTest {
@Autowired
private ChannelAccountConversationMapper accountConversationMapper;
@Test
void shouldSyncContactsAndPersistPersonAndIdentity() throws Exception {
String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
ChannelAccount account = createWeComAccount();
@Autowired
private SyncTaskService syncTaskService;
mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
@Test
void shouldSyncEnterpriseContactsViaSubjectAndPersistInternalPerson() throws Exception {
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelSubject subject = createWeComSubject();
MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-subject/{id}/sync-contacts", subject.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", TENANT_CODE)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.syncedCount").value(2));
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
assertThat(task.getSyncedCount()).isEqualTo(2);
Person internalPerson = personMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Person>()
.eq(Person::getTenantCode, TENANT_CODE)
.eq(Person::getTenantCode, tenantCode)
.eq(Person::getPersonCode, "user001"));
assertThat(internalPerson).isNotNull();
assertThat(internalPerson.getPersonName()).isEqualTo("张三");
@@ -104,23 +117,42 @@ class WeComSyncTest {
ChannelIdentity internalIdentity = channelIdentityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, TENANT_CODE)
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_TYPE)
.eq(ChannelIdentity::getChannelUserId, "user001"));
assertThat(internalIdentity).isNotNull();
assertThat(internalIdentity.getPersonId()).isEqualTo(internalPerson.getId());
assertThat(internalIdentity.getChannelUserName()).isEqualTo("张三");
}
@Test
void shouldSyncAccountContactsAndPersistExternalPerson() throws Exception {
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
MvcResult result = 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", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Person externalPerson = personMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Person>()
.eq(Person::getTenantCode, TENANT_CODE)
.eq(Person::getTenantCode, tenantCode)
.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::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_TYPE)
.eq(ChannelIdentity::getChannelUserId, "wmexternal001"));
assertThat(externalIdentity).isNotNull();
@@ -129,21 +161,25 @@ class WeComSyncTest {
@Test
void shouldSyncConversationsAndPersistParticipantsAndBindings() throws Exception {
String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
MvcResult result = 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)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.syncedCount").value(1));
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Conversation conversation = conversationMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Conversation>()
.eq(Conversation::getTenantCode, TENANT_CODE)
.eq(Conversation::getTenantCode, tenantCode)
.eq(Conversation::getChannelType, CHANNEL_TYPE)
.eq(Conversation::getChannelConversationId, "chat001"));
assertThat(conversation).isNotNull();
@@ -181,53 +217,57 @@ class WeComSyncTest {
@Test
void shouldPreservePersonBusinessFieldsDuringContactSync() throws Exception {
String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
Person person = new Person();
person.setTenantCode(TENANT_CODE);
person.setPersonCode("user001");
person.setTenantCode(tenantCode);
person.setPersonCode("wmexternal001");
person.setPersonName("原始姓名");
person.setPhone("13800138001");
// Seed with a personType that differs from what resolvePersonType(user001/INTERNAL) would assign (1).
person.setPersonType(3);
person.setPhone("13900139001");
person.setPersonType(1);
person.setInstitutionId(100L);
person.setSupplierId(200L);
person.setStatus(1);
personMapper.insert(person);
ChannelIdentity identity = new ChannelIdentity();
identity.setTenantCode(TENANT_CODE);
identity.setTenantCode(tenantCode);
identity.setPersonId(person.getId());
identity.setChannelType(CHANNEL_TYPE);
identity.setChannelUserId("user001");
identity.setChannelUserId("wmexternal001");
identity.setChannelUserName("原始姓名");
identity.setSubjectId(account.getSubjectId());
identity.setStatus(1);
channelIdentityMapper.insert(identity);
mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
MvcResult result = 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)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
.andExpect(jsonPath("$.code").value(0))
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Person updated = personMapper.selectById(person.getId());
assertThat(updated.getPersonName()).isEqualTo("张三");
assertThat(updated.getPersonName()).isEqualTo("客户李四");
assertThat(updated.getInstitutionId()).isEqualTo(100L);
assertThat(updated.getSupplierId()).isEqualTo(200L);
assertThat(updated.getPersonType()).isEqualTo(3);
assertThat(updated.getPersonType()).isEqualTo(1);
}
@Test
void shouldPreserveConversationBusinessFieldsDuringConversationSync() throws Exception {
String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
Conversation conversation = new Conversation();
conversation.setTenantCode(TENANT_CODE);
conversation.setTenantCode(tenantCode);
conversation.setConversationType(2);
conversation.setChannelType(CHANNEL_TYPE);
conversation.setChannelConversationId("chat001");
@@ -242,13 +282,18 @@ class WeComSyncTest {
conversation.setStatus(1);
conversationMapper.insert(conversation);
mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
MvcResult result = 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)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
.andExpect(jsonPath("$.code").value(0))
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Conversation updated = conversationMapper.selectById(conversation.getId());
assertThat(updated.getConversationName()).isEqualTo("测试客户群");
@@ -258,20 +303,40 @@ class WeComSyncTest {
assertThat(updated.getChannelCode()).isEqualTo("ORIGINAL");
}
private ChannelAccount createWeComAccount() {
private Long extractTaskId(MvcResult result) throws Exception {
return objectMapper.readTree(result.getResponse().getContentAsString()).path("data").path("id").asLong();
}
private SyncTask waitForTask(Long taskId) throws InterruptedException {
for (int i = 0; i < 50; i++) {
SyncTask task = syncTaskService.getTaskById(taskId);
if (task != null && (task.getTaskStatus() == SyncTask.STATUS_SUCCESS || task.getTaskStatus() == SyncTask.STATUS_FAILED)) {
return task;
}
Thread.sleep(200);
}
throw new RuntimeException("同步任务未在预期时间内完成: taskId=" + taskId);
}
private ChannelSubject createWeComSubject() {
ChannelSubject subject = new ChannelSubject();
subject.setTenantCode(TENANT_CODE);
subject.setTenantCode(tenantCode);
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.setAgentSecret("secret-" + UUID.randomUUID());
subject.setStatus(1);
subjectMapper.insert(subject);
return subject;
}
private ChannelAccount createWeComAccount() {
ChannelSubject subject = createWeComSubject();
ChannelAccount account = new ChannelAccount();
account.setTenantCode(TENANT_CODE);
account.setTenantCode(tenantCode);
account.setAccountType(1);
account.setChannelType(CHANNEL_TYPE);
account.setSubjectId(subject.getId());
@@ -288,7 +353,7 @@ class WeComSyncTest {
@Bean
public WeComSyncClient weComSyncClient() {
WeComSyncClient client = mock(WeComSyncClient.class);
when(client.syncContacts(any())).thenReturn(List.of(
when(client.syncEnterpriseContacts(any())).thenReturn(List.of(
WeComContact.builder()
.userId("user001")
.name("张三")
@@ -302,7 +367,15 @@ class WeComSyncTest {
.type(WeComContactType.EXTERNAL)
.build()
));
when(client.syncConversations(any())).thenReturn(List.of(
when(client.syncAccountContacts(any(), any())).thenReturn(List.of(
WeComContact.builder()
.userId("wmexternal001")
.name("客户李四")
.mobile("13900139001")
.type(WeComContactType.EXTERNAL)
.build()
));
when(client.syncAccountConversations(any(), any())).thenReturn(List.of(
WeComConversation.builder()
.chatId("chat001")
.chatName("测试客户群")

View File

@@ -84,6 +84,28 @@
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-toolchains-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<toolchains>
<jdk>
<version>21</version>
<vendor>zulu</vendor>
</jdk>
</toolchains>
</configuration>
<executions>
<execution>
<goals>
<goal>toolchain</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>