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

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

回调事件重复投递问题

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

相关推荐
七夜zippoe1 天前
Agent 接入企业微信/钉钉:渠道对接实
ai·钉钉·企业微信·agent·渠道对接
杨婷1232 天前
企业微信 SCRM 客户流失提醒:实时告警 + 自动流失标签,保护私域客户资产
企业微信
小鹿研究点东西2 天前
SCRM系统哪家好?先分清5类产品,再按业务场景选
企业微信·私域运营·scrm工具·客户运营
天空属于哈夫克33 天前
企业微信API开发:如何实现外部群消息关键词统计?
数据库·企业微信
金融Tech趋势派3 天前
企业微信客户营销怎么做?WorkBuddy+微盛·企微管家Skill连接器,一句话让AI把客户营销做起来
人工智能·企业微信
道一云黑板报3 天前
WorkBuddy 进入企业微信侧边栏:入口、执行与连接的分工
人工智能·ai·企业微信
随性而行3603 天前
企业微信API调用案例:如何通过接口实现多账号统一管理与业务接入?
运维·微信·机器人·自动化·企业微信
随性而行3603 天前
企业微信API接口开发实战:如何实现消息回调接收与业务事件处理?
java·前端·python·微信·机器人·企业微信
杨婷1234 天前
2026 企业微信 SCRM 与服务商测评:私域获客、会话存档、AI 能力全维度评估
大数据·人工智能·企业微信
天空属于哈夫克34 天前
企业微信 API 开发:定时任务如何避免重复执行?
企业微信