企业微信审批回调事件的幂等消费与状态一致性保障

企业微信审批回调事件的幂等消费与状态一致性保障

回调事件重复投递问题

企业微信在审批状态变更(如"同意"、"驳回")时,会向配置的回调 URL 发送 HTTP POST 通知。由于网络超时或响应延迟,同一事件可能被多次投递 。若业务系统未做幂等处理,将导致重复审批、重复打款等严重后果。必须通过唯一事件 ID + 状态机校验确保一次且仅一次处理。

事件结构与唯一标识提取

企业微信审批回调体包含 SpNo(审批单号)和 ApprovalNodes,但关键幂等依据是整个事件内容的摘要企业微信提供的 MsgId(如有) 。若无 MsgId,则使用 SpNo + timestamp + 审批节点 hash 构造唯一键:

java 复制代码
package wlkankan.cn.event;

import com.fasterxml.jackson.databind.JsonNode;
import wlkankan.cn.crypto.Sha256Util;

public class ApprovalEvent {
    private final String spNo;          // 审批单号
    private final long eventTime;       // 事件时间戳(来自审批记录)
    private final JsonNode rawPayload;  // 原始 JSON
    private final String eventId;       // 幂等ID

    public ApprovalEvent(JsonNode payload) {
        this.rawPayload = payload;
        this.spNo = payload.get("SpNo").asText();
        this.eventTime = payload.get("StatuChangeEvent").get("TimeStamp").asLong();
        // 使用完整 payload 计算哈希,避免 SpNo 相同但状态不同的事件被误判
        this.eventId = Sha256Util.sha256Base64(payload.toString());
    }

    // getters...
}

幂等性检查与状态机校验

使用数据库唯一索引防止重复插入,并结合审批单当前状态判断是否可执行:

java 复制代码
package wlkankan.cn.service;

import wlkankan.cn.event.ApprovalEvent;
import wlkankan.cn.repo.ApprovalRecordRepository;
import wlkankan.cn.repo.IdempotentEventRepository;

public class ApprovalEventHandler {

    private final IdempotentEventRepository eventRepo;
    private final ApprovalRecordRepository recordRepo;

    public void handle(ApprovalEvent event) {
        // 1. 幂等检查:尝试插入事件ID
        if (!eventRepo.insertIfAbsent(event.getEventId(), event.getSpNo())) {
            return; // 已处理过
        }

        // 2. 获取当前审批单状态
        ApprovalRecord record = recordRepo.findBySpNo(event.getSpNo());
        if (record == null) {
            throw new IllegalArgumentException("Unknown approval spNo: " + event.getSpNo());
        }

        // 3. 状态机校验:仅允许合法状态转移
        String newStatus = extractNewStatus(event);
        if (!isValidTransition(record.getStatus(), newStatus)) {
            // 例如:已通过的单子再次收到"同意"事件,直接忽略
            return;
        }

        // 4. 执行业务逻辑(如更新财务状态、发送通知)
        executeBusinessLogic(record, newStatus);

        // 5. 更新审批单状态(含版本号乐观锁)
        recordRepo.updateStatusWithVersion(record.getId(), newStatus, record.getVersion());
    }

    private boolean isValidTransition(String current, String target) {
        if ("PENDING".equals(current) && ("APPROVED".equals(target) || "REJECTED".equals(target))) {
            return true;
        }
        if ("APPROVED".equals(current) && "COMPLETED".equals(target)) {
            return true;
        }
        return false; // 非法转移,如 REJECTED → APPROVED
    }

    private String extractNewStatus(ApprovalEvent event) {
        // 解析 event.getRawPayload() 中的审批结果
        return event.getRawPayload().get("StatuChangeEvent").get("Status").asText();
    }
}

数据库表设计

sql 复制代码
-- 幂等事件表(唯一约束)
CREATE TABLE idempotent_approval_event (
    event_id VARCHAR(256) PRIMARY KEY,
    sp_no VARCHAR(64) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 审批单主表(含版本号)
CREATE TABLE approval_record (
    id BIGINT PRIMARY KEY,
    sp_no VARCHAR(64) UNIQUE NOT NULL,
    status VARCHAR(32) NOT NULL,
    version INT NOT NULL DEFAULT 0
);

IdempotentEventRepository.insertIfAbsent() 实现:

java 复制代码
package wlkankan.cn.repo;

import org.springframework.dao.DuplicateKeyException;

public class IdempotentEventRepository {

    public boolean insertIfAbsent(String eventId, String spNo) {
        try {
            // 使用 INSERT IGNORE 或 ON CONFLICT DO NOTHING
            jdbcTemplate.update(
                "INSERT INTO idempotent_approval_event (event_id, sp_no) VALUES (?, ?)",
                eventId, spNo
            );
            return true;
        } catch (DuplicateKeyException e) {
            return false;
        }
    }
}

异常重试与补偿机制

executeBusinessLogic() 失败(如调用下游支付接口超时),需支持重试。但重试时仍受幂等表保护,不会重复执行业务:

java 复制代码
// 在消息队列消费者中
public void onMessage(String jsonPayload) {
    ApprovalEvent event = new ApprovalEvent(objectMapper.readTree(jsonPayload));
    try {
        approvalEventHandler.handle(event);
    } catch (Exception e) {
        // 记录失败,由定时任务或 DLQ 重试
        failedEventQueue.enqueue(event, e.getMessage());
    }
}

重试时,因 event_id 已存在,handle() 直接返回,避免副作用。

企业微信回调验证与安全

接收回调前需验证签名,防止伪造请求:

java 复制代码
@PostMapping("/wechat/approval/callback")
public ResponseEntity<String> handleCallback(
    @RequestHeader("Msg-Signature") String signature,
    @RequestBody String body) {

    if (!wlkankan.cn.security.WechatSignature.verify(signature, body, nonce, timestamp)) {
        return ResponseEntity.status(403).build();
    }

    ApprovalEvent event = parseEvent(body);
    approvalEventHandler.handle(event);
    return ResponseEntity.ok("success");
}

通过唯一事件 ID + 数据库幂等表 + 状态机校验 + 乐观锁更新四重保障,企业微信审批回调可实现强一致性消费,杜绝重复处理与状态错乱。

相关推荐
h7ml17 小时前
企业微信通讯录同步服务的增量更新算法与冲突解决策略
服务器·算法·企业微信
天空属于哈夫克317 小时前
从“骚扰”回归“服务”:企业微信外部群主动推送的自动化实践与合规架构
架构·自动化·企业微信
u01040583618 小时前
企业微信自建应用权限模型与 RBAC 在 Spring Security 中的映射
java·spring·企业微信
梦想的旅途22 天前
企业微信外部群消息主动推送开发指南
企业微信
梦想的旅途22 天前
基于企业微信 API 的外部群消息异步推送机制实现
企业微信
vx-bot5556662 天前
企业微信协议接口的安全调用与性能优化规范
安全·性能优化·企业微信
天空属于哈夫克32 天前
通过企业微信二次开发构建外部群主动推送体系
企业微信
h7ml3 天前
企业微信审批回调事件的幂等接收与业务解耦设计
企业微信
企微自动化3 天前
企业微信二次开发实战:突破限制,实现外部群消息主动推送
自动化·企业微信