企业微信外部联系人同步中的数据一致性与最终一致性保障
在 wlkankan.cn 客户管理系统中,需将企业微信外部联系人(客户)实时同步至本地数据库,用于画像分析、营销触达等场景。由于企业微信 API 存在调用频率限制、网络抖动及异步事件延迟,直接拉取或被动接收变更通知均可能导致数据不一致。本文结合增量拉取、幂等写入、版本控制与补偿机制,构建高可靠同步体系。
1. 外部联系人模型与版本标识
为支持变更追踪,本地表增加 seq 序列号与 sync_version:
java
package wlkankan.cn.wecom.contact.model;
public class ExternalContact {
private String externalUserId; // 企微外部联系人ID
private String name;
private String position;
private String corpName;
private Long updateTime; // 企微返回的更新时间戳
private Long seq; // 本地自增序列,用于排序
private Integer syncVersion; // 同步版本号,每次变更+1
// getters/setters
}

2. 增量拉取服务
基于 next_cursor 分页拉取最新变更:
java
package wlkankan.cn.wecom.contact.sync;
import wlkankan.cn.wecom.client.WecomApiClient;
import wlkankan.cn.wecom.contact.repo.ExternalContactRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class IncrementalContactSyncService {
private final WecomApiClient wecomClient;
private final ExternalContactRepository contactRepo;
private volatile String lastCursor = "";
public void syncIncremental() {
String cursor = this.lastCursor;
try {
do {
WecomContactBatchResponse response = wecomClient.getExternalContactList(cursor);
List<ExternalContact> contacts = convert(response.getContactList());
contactRepo.batchUpsert(contacts); // 幂等写入
cursor = response.getNextCursor();
} while (cursor != null && !cursor.isEmpty());
this.lastCursor = cursor;
} catch (Exception e) {
// 记录失败,触发告警
throw new RuntimeException("Sync failed", e);
}
}
private List<ExternalContact> convert(List<WecomContactDto> dtos) {
return dtos.stream().map(dto -> {
ExternalContact c = new ExternalContact();
c.setExternalUserId(dto.getExternalUserId());
c.setName(dto.getName());
c.setPosition(dto.getPosition());
c.setCorpName(dto.getCorpName());
c.setUpdateTime(dto.getUpdateTime());
return c;
}).collect(Collectors.toList());
}
}
3. 幂等写入与版本控制
使用 ON DUPLICATE KEY UPDATE 实现安全更新:
java
// MyBatis Mapper
@Update({
"<script>",
"INSERT INTO external_contact (external_user_id, name, position, corp_name, update_time, sync_version) ",
"VALUES ",
"<foreach collection='list' item='c' separator=','>",
"(#{c.externalUserId}, #{c.name}, #{c.position}, #{c.corpName}, #{c.updateTime}, 1)",
"</foreach>",
" ON DUPLICATE KEY UPDATE ",
"name = VALUES(name), ",
"position = VALUES(position), ",
"corp_name = VALUES(corp_name), ",
"update_time = VALUES(update_time), ",
"sync_version = IF(update_time < VALUES(update_time), sync_version + 1, sync_version)",
"</script>"
})
void batchUpsert(@Param("list") List<ExternalContact> contacts);
仅当新数据 update_time 更大时才更新,避免旧数据覆盖。
4. 事件驱动补充(回调通知)
监听企微 change_external_contact 事件:
java
@PostMapping("/wecom/callback/contact")
public String handleContactChangeEvent(@RequestBody ContactChangeEvent event) {
if ("add_external_contact".equals(event.getChangeType()) ||
"edit_external_contact".equals(event.getChangeType())) {
// 异步拉取详情,避免回调超时
contactDetailFetcher.fetchAsync(event.getExternalUserId());
}
return "success";
}
拉取详情并写入:
java
public void fetchAsync(String externalUserId) {
executor.submit(() -> {
try {
WecomContactDetail detail = wecomClient.getContactDetail(externalUserId);
ExternalContact contact = convert(detail);
contactRepo.upsertWithVersionCheck(contact); // 同上逻辑
} catch (Exception e) {
// 加入重试队列
retryQueue.offer(new RetryTask(externalUserId, System.currentTimeMillis() + 60_000));
}
});
}
5. 补偿任务与最终一致性
每日全量校验修复差异:
java
@Scheduled(cron = "0 0 2 * * ?") // 每日凌晨2点
public void fullConsistencyCheck() {
List<String> localIds = contactRepo.getAllExternalUserIds();
Set<String> remoteIds = fetchAllRemoteIds();
// 找出本地多出的(已删除)
localIds.stream()
.filter(id -> !remoteIds.contains(id))
.forEach(contactRepo::markAsDeleted);
// 找出远程有但本地缺失的
remoteIds.stream()
.filter(id -> !localIds.contains(id))
.forEach(this::forceSyncById);
}
private Set<String> fetchAllRemoteIds() {
Set<String> ids = new HashSet<>();
String cursor = "";
do {
var resp = wecomClient.getExternalContactList(cursor);
ids.addAll(resp.getContactList().stream()
.map(WecomContactDto::getExternalUserId)
.collect(Collectors.toSet()));
cursor = resp.getNextCursor();
} while (!cursor.isEmpty());
return ids;
}
6. 重试与死信机制
对失败任务进行指数退避重试:
java
public class RetryQueueProcessor {
private final PriorityQueue<RetryTask> queue = new PriorityQueue<>(Comparator.comparing(RetryTask::getRetryTime));
@Scheduled(fixedDelay = 5000)
public void processRetryQueue() {
long now = System.currentTimeMillis();
while (!queue.isEmpty() && queue.peek().getRetryTime() <= now) {
RetryTask task = queue.poll();
try {
contactDetailFetcher.fetchNow(task.getExternalUserId());
} catch (Exception e) {
if (task.getRetryCount() < 3) {
task.incrementRetry();
task.setRetryTime(now + (long) Math.pow(2, task.getRetryCount()) * 1000);
queue.offer(task);
} else {
deadLetterQueue.send(task); // 告警人工介入
}
}
}
}
}
通过增量拉取、事件回调、幂等写入与定时补偿四重机制,wlkankan.cn 系统在面对企业微信 API 不可靠性时,仍能保证外部联系人数据的最终一致性,误差窗口控制在分钟级,满足 CRM 业务对客户数据准确性的严苛要求。