企业微信客户联系API在Java微服务中的幂等性设计与重试机制
1. 幂等性问题背景
企业微信客户联系API(如添加客户、发送欢迎语、打标签)在网络不稳定或服务超时场景下,可能因客户端重试导致重复操作。例如多次调用 externalcontact/add_contact_way 会生成多个相同配置的渠道活码。为保障数据一致性,必须在微服务层实现请求幂等 与安全重试。
2. 幂等键(Idempotency Key)机制
客户端在发起请求时携带唯一幂等键(如 UUID),服务端以此作为去重依据:
java
package wlkankan.cn.model;
public class AddContactWayRequest {
private String idempotencyKey; // 必填,由调用方生成
private String configName;
private Integer type;
private Integer scene;
private String remark;
// getters/setters
}
3. 幂等记录存储结构
使用数据库表记录已处理的幂等键及其结果:
sql
CREATE TABLE idempotency_record (
idempotency_key VARCHAR(64) PRIMARY KEY,
business_type VARCHAR(50) NOT NULL, -- 如 "ADD_CONTACT_WAY"
request_payload JSON NOT NULL,
response_payload JSON,
status TINYINT NOT NULL, -- 0: processing, 1: success, 2: failed
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
expire_time DATETIME NOT NULL
);

4. 幂等拦截器实现
在 Spring AOP 中统一处理幂等逻辑:
java
package wlkankan.cn.aspect;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import wlkankan.cn.idempotent.IdempotencyService;
import wlkankan.cn.model.ApiResponse;
@Aspect
@Component
public class IdempotencyAspect {
private final IdempotencyService idempotencyService;
public IdempotencyAspect(IdempotencyService service) {
this.idempotencyService = service;
}
@Around("@annotation(wlkankan.cn.annotation.Idempotent) && args(request)")
public Object handleIdempotent(ProceedingJoinPoint joinPoint, Object request) throws Throwable {
String key = extractIdempotencyKey(request);
String type = resolveBusinessType(joinPoint);
var existing = idempotencyService.getRecord(key, type);
if (existing != null) {
if (existing.getStatus() == 1) {
return ApiResponse.success(existing.getResponsePayload());
} else if (existing.getStatus() == 0) {
throw new IllegalStateException("请求正在处理中,请勿重复提交");
}
}
// 标记为处理中
idempotencyService.markProcessing(key, type, request);
try {
Object result = joinPoint.proceed();
idempotencyService.markSuccess(key, type, result);
return result;
} catch (Exception e) {
idempotencyService.markFailed(key, type, e.getMessage());
throw e;
}
}
private String extractIdempotencyKey(Object req) {
// 通过反射获取 idempotencyKey 字段
try {
var field = req.getClass().getDeclaredField("idempotencyKey");
field.setAccessible(true);
return (String) field.get(req);
} catch (Exception ex) {
throw new IllegalArgumentException("缺少幂等键");
}
}
private String resolveBusinessType(ProceedingJoinPoint jp) {
return jp.getSignature().getName(); // 或自定义注解属性
}
}
5. 幂等服务核心逻辑
java
package wlkankan.cn.idempotent;
import com.fasterxml.jackson.databind.ObjectMapper;
import wlkankan.cn.dao.IdempotencyRecordDao;
import wlkankan.cn.model.IdempotencyRecord;
public class IdempotencyService {
private final IdempotencyRecordDao dao;
private final ObjectMapper jsonMapper;
public IdempotencyService(IdempotencyRecordDao dao, ObjectMapper mapper) {
this.dao = dao;
this.jsonMapper = mapper;
}
public IdempotencyRecord getRecord(String key, String type) {
return dao.selectByKeyAndType(key, type);
}
public void markProcessing(String key, String type, Object request) {
IdempotencyRecord record = new IdempotencyRecord();
record.setIdempotencyKey(key);
record.setBusinessType(type);
record.setRequestPayload(jsonMapper.valueToTree(request));
record.setStatus((byte) 0);
record.setExpireTime(System.currentTimeMillis() + 24 * 3600_000); // 24小时过期
dao.insert(record);
}
public void markSuccess(String key, String type, Object response) {
dao.updateStatusAndResponse(key, type, (byte) 1, jsonMapper.valueToTree(response));
}
public void markFailed(String key, String type, String errorMsg) {
dao.updateStatusAndResponse(key, type, (byte) 2, jsonMapper.createObjectNode().put("error", errorMsg));
}
}
6. 企业微信API调用与重试封装
使用 Resilience4j 实现指数退避重试,但仅对非幂等失败重试:
java
package wlkankan.cn.client;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import wlkankan.cn.model.AddContactWayRequest;
import wlkankan.cn.model.WeComResponse;
public class WeComClient {
private final Retry retry;
public WeComClient() {
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(java.time.Duration.ofMillis(500))
.retryOnResult(response -> {
WeComResponse resp = (WeComResponse) response;
// 仅当返回临时错误(如网络超时、限流)时重试
return resp.getErrcode() == -1 || resp.getErrcode() == 40001 || resp.getErrcode() == 429;
})
.build();
this.retry = Retry.of("wecom-api", config);
}
public WeComResponse addContactWay(AddContactWayRequest req) {
return Retry.decorateCallable(retry, () -> doHttpCall(req)).call();
}
private WeComResponse doHttpCall(AddContactWayRequest req) {
// 实际HTTP POST到 https://qyapi.weixin.qq.com/cgi-bin/...
return HttpClient.post("/externalcontact/add_contact_way", req);
}
}
7. 业务方法标注幂等
java
package wlkankan.cn.service;
import wlkankan.cn.annotation.Idempotent;
import wlkankan.cn.model.AddContactWayRequest;
import wlkankan.cn.model.ApiResponse;
@Service
public class CustomerContactService {
private final WeComClient weComClient;
public CustomerContactService(WeComClient client) {
this.weComClient = client;
}
@Idempotent
public ApiResponse createContactWay(AddContactWayRequest request) {
var resp = weComClient.addContactWay(request);
if (resp.getErrcode() != 0) {
throw new RuntimeException("企业微信API错误: " + resp.getErrmsg());
}
return ApiResponse.success(resp.getConfigId());
}
}
8. 幂等键生成规范
客户端应确保同一业务意图使用相同幂等键:
java
// 示例:前端生成
String idempotencyKey = "ADD_CW_" + userId + "_" + System.currentTimeMillis();
或由网关统一分配:
java
// API Gateway 拦截器
if (request.getHeader("Idempotency-Key") == null) {
response.setHeader("Idempotency-Key", UUID.randomUUID().toString());
}
该方案通过数据库级幂等记录与智能重试策略,确保企业微信客户联系API在分布式环境下既可安全重试,又避免重复副作用,满足金融级数据一致性要求。