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

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

回调事件重复投递问题

企业微信在审批状态变更(如"同意"、"驳回")时,会向配置的回调 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 + 数据库幂等表 + 状态机校验 + 乐观锁更新四重保障,企业微信审批回调可实现强一致性消费,杜绝重复处理与状态错乱。

相关推荐
2501_941982052 小时前
企微中台架构:非官方接口与企业私有化 CRM 的深度集成
架构·企业微信
vx-bot55566616 小时前
企业微信接口在微服务协同架构中的事件桥接与状态同步模式
微服务·架构·企业微信
2501_9419820516 小时前
企业微信外部群精准运营:API 主动推送消息开发指南
大数据·人工智能·企业微信
梦想的旅途22 天前
企微API开发实战:外部群流量来源的“自动精准归因
企业微信
u0104058362 天前
利用Java CompletableFuture优化企业微信批量消息发送的异步编排
java·开发语言·企业微信
h7ml2 天前
企业微信“群机器人”消息合并转发:用Disruptor做环形队列的Java实例
java·机器人·企业微信
coder6163 天前
如何监控数据表中的新记录并自动推送到企业微信群,同时在企业微信中发起处理流程?
java·服务器·企业微信
天空属于哈夫克33 天前
企微API+RPA(机器人流程自动化)高效实战指南
linux·运维·服务器·自动化·企业微信·rpa
梦想的旅途23 天前
企微API自动化:外部群消息高效推送
运维·自动化·企业微信
2501_941982053 天前
企微API自动化:外部群推送实现高效自动化
运维·自动化·企业微信