支付回调出错时系统一声不吭

杜恒 2 阅读 技术沉淀

用户付了 128 元,订单页还显示待支付。月底对账时,渠道流水里有这一笔,本地账上没有。系统全程没报过错:回调接口返回 200,日志里一切正常,钱和状态就是没对上。

很多这类问题是从把支付回调当普通 Webhook 处理开始的:收到、改状态、回 ACK,就认为结束了。普通事件丢了可以补发,晚到半小时顶多是体验差一点;支付回调背后是一笔已经发生的资金变动,接口返回成功不等于账已经成立。报文存证、业务校验、主动对账,少一层就会丢钱,而丢的时候系统不报错。

回调推送与主动查单汇入 pay_notify_raw 流次表,推进状态并记账;渠道账单单独走对账勾稽,差异落 recon_diff

进门先验明这条回调属于谁

验签只解决了报文来源,没解决它对应哪一单。验完之后信哪个字段,是另一回事。

@PostMapping("/notify/wechat")
public String wechatNotify(HttpServletRequest req, @RequestBody String body) {
    if (!verifier.verify(req)) {
        log.warn("notify signature failed, orderHint={}", peek(body, "out_trade_no"));
        return ackFail();                     // v2 回 XML FAIL,v3 回 4xx + JSON,别写死一个字符串
    }
    Notify n = parser.parse(body);            // 只处理成功态;中间态走事件表,别让空 transaction_id 进这张表
    NotifyRaw raw = notifyRawService.findOrCreate(n);   // REQUIRES_NEW,重复回调取回旧行
    if (raw.isSettled()) return ackSuccess();           // 1 已入账、2 待人工,都算渠道可以停手

    Order o = orderMapper.findByMerchantAndOrderNo(n.getMchid(), n.getOutTradeNo());
    if (o == null) {
        log.warn("unknown order, mchid={}, orderNo={}", n.getMchid(), n.getOutTradeNo());
        return ackFail();                     // 连续 N 次查不到就标 2 回成功,计数这里省略
    }
    if (!n.matchesAmountAndMerchant(o)) {     // 商户号、币种、应付金额三项一起比
        notifyRawService.markPending(raw.getId());   // 标 2 进人工,别留在 0
        alarm.on("notify_mismatch", raw.getId());
        return ackSuccess();                  // 对不上是确定的,重试一万次也对不上
    }
    paymentService.apply(o, n, raw.getId());   // 业务失败会抛异常,由统一异常处理回 ackFail
    return ackSuccess();
}

findOrCreate 要放在另一个 bean 里,REQUIRES_NEW 才生效;同类自调用不会开新事务。ackSuccess() 和 ackFail() 也别省:微信 v2 成功回 XML SUCCESS,v3 成功回 200/204、失败回 4xx/5xx 加 JSON,支付宝成功回小写 success,三家不是一个协议,写混一次就会让渠道一直重试,或者该重试的时候你回了成功。订单查不到可以回 fail 让渠道重试,但要设次数上限;连续几次还查不到就标 2 回成功,否则它会一直重试到窗口结束,raw 也永远停在 0。

验签到底证明了什么?它证明这条报文是渠道发的、内容没被改。它不证明这条报文对应的是你库里这一单。直连微信和支付宝时,攻击者改不了 out_trade_no 或金额——改一个字节签名就过不了。

真正常见的串单不是改报文,是同一个服务商商户号下多个子商户共用一个回调入口。各租户订单号各自编号,存在撞号时,A 租户的一分钱回调被 B 租户的处理器按 out_trade_no 查到 B 的高价单,就可能把 B 的单推进成已支付。接了聚合支付的又是另一种,回调由中间层转发,字段保真和验签强度都取决于那一层。

所以查单的键要带上报文里的商户号(服务商模式下是 sub_mchid)和订单号,金额和币种是校验条件,不是主键的一部分。金额字段也不止一个:微信 v3 的 amount.total 是订单总额,payer_total 是用户实付,可能被代金券抵扣;支付宝的 total_amount、receipt_amount、buyer_pay_amount 也各有口径。

拿 payer_total 去比订单应付,遇到代金券抵扣就会判成金额不符;拿 total 去入账,用户实付少于应付时又会多记应收。入口比的是订单应付,入账记的是渠道实收,两个口径都要留。

单位是另一个静默错误源。微信的金额字段以分计、是整数,支付宝是带小数点的元。多渠道路由时如果内部统一用元,入口就得除 100,而除错一次是 100 倍。这种事故的特征是账对不上但没人报错,因为每一单都“支付成功”了。

落到库里两个金额口径各占一列:order_amount_cent 是下单应付,入口拿它比;pay_amount_cent 是渠道实收,入账记它,两个都是换算过的整数分。原始报文整段留在 raw_body,对账时把原始金额串从里面捞出来对,换算错了也能查回来。这一版按人民币跑,currency 列已经有、默认 CNY,但账务分录还没按币种拆;接多币种时要把它带进分录,不能只留在回调表里。

peek(body, "out_trade_no") 那句只当日志提示,不当业务依据:验签失败时你至少要知道它在说哪一单,否则日志里只有一句“签名不对”。

支付认渠道的号,退款认自己的号

订单状态机那篇用 transition 表的唯一键挡住了同一事件重复推进,那是订单侧。渠道侧重复得更密,它按自己的节奏重发多次,跨度几小时到一天;有的系统还把支付和退款两个方向的回调塞进同一个接口。真正的难点不在唯一键怎么建,在键取哪个号。

事件 该用哪个号 不该用哪个号 用错的后果
支付成功 渠道流水号 transaction_id 商户订单号 out_trade_no 同一订单在微信和支付宝各付了一次,两笔都是真钱;用订单号做唯一键会把第二笔吞掉
退款 你自己的退款单号 refund_no(微信 out_refund_no / 支付宝 out_request_no) 渠道退款流水号、原订单号 用渠道号判重,同一笔退款可能落多条;用原订单号做唯一键,第二次部分退款会被挡住

支付方向用渠道的号,退款方向用自己的号,因为两件事的信任来源不同。支付是你发起后只能等结果的事,唯一能证明“就是这一笔”的是渠道单号;退款是你自己发起的,渠道认你的退款单号做幂等,重试必须复用同一个号。真正会造成多退的,是代码在重试时重新生成了一个退款号,渠道给不给新流水号倒在其次。

退款还有一层支付没有的复杂度:一笔支付可以退多次。100 元的单先退 20、过两天再退 30,每次退款都有自己的 refund_no,本地唯一键要建在 (channel, refund_no) 上,不能建在 out_trade_no 上。建在订单号上,第一笔部分退款成功后,第二笔会被唯一键直接挡住,表现成“退款失败”,但用户那边确实没收到第二笔。

两个方向分开落两张原始表,各自带唯一键,比在一张表里塞一个 type 字段好查得多。支付表用 (channel, transaction_id),退款表用 (channel, refund_no);渠道退款号只存一列用来对账,不参与判重。

CREATE TABLE pay_notify_raw (
  id             BIGINT       NOT NULL AUTO_INCREMENT,
  channel        VARCHAR(16)  NOT NULL,   -- wechat / alipay
  mchid          VARCHAR(32)  NOT NULL,   -- 直连是商户号,服务商模式下是 sub_mchid
  sp_mchid       VARCHAR(32)  NULL,       -- 服务商号;直连为空,服务商模式下查单要两个号一起带
  out_trade_no   VARCHAR(32)  NOT NULL,
  transaction_id VARCHAR(64)  NOT NULL,
  currency       CHAR(3)      NOT NULL DEFAULT 'CNY',
  order_amount_cent BIGINT    NOT NULL,   -- 下单应付,入口校验口径
  pay_amount_cent BIGINT      NOT NULL,   -- 渠道实收,入账口径
  trade_state    VARCHAR(24)  NOT NULL,
  raw_body       MEDIUMTEXT   NOT NULL,
  handled        TINYINT      NOT NULL DEFAULT 0,   -- 0 未处理 1 已入账 2 待人工
  notified_at    DATETIME     NOT NULL,   -- 本地落库时间;渠道通知时间从 raw_body 取
  PRIMARY KEY (id),
  UNIQUE KEY uk_pay (channel, transaction_id),
  KEY idx_pending (handled, notified_at)
);

这张表放的是能拿到交易号的终态回调:成功、关闭、撤销、支付失败都进;中间态没有交易号,另记事件表,别用空串去撞唯一键。mchid 存下来,服务商模式下另存 sp_mchid,查单和串单校验两个号都要带。

这张表不只是存证,还是重试的锚点。handled 三个值:0 未处理、1 已入账、2 待人工;0 会继续走业务,1 和 2 都回成功让渠道停手,区别只在日报里。findOrCreate 命中唯一键时,别直接抛异常:把旧行取回来,按这个值决定往下走还是直接确认。

否则业务失败一次后,渠道每次重试都撞唯一键,订单一直推不动。后面主动查单、对账、转人工,判断的也都是 handled 这一列;没有它,你只能靠日志去猜那条回调到底处理了没有。对账的时候你需要的是一个数,能 select count(*) where handled = 0 数出来、并且每天有人看的那个数。

退款那张表形状一样,唯一键换成你自己生成的 refund_no:

CREATE TABLE pay_refund_raw (
  id            BIGINT      NOT NULL AUTO_INCREMENT,
  channel       VARCHAR(16) NOT NULL,
  mchid         VARCHAR(32) NOT NULL,   -- 同 pay_notify_raw:直连是商户号,服务商模式下是 sub_mchid
  sp_mchid      VARCHAR(32) NULL,       -- 服务商号;直连为空
  refund_no     VARCHAR(64) NOT NULL,   -- 本地生成;微信 out_refund_no、支付宝 out_request_no 都落这一列
  refund_id     VARCHAR(64) NULL,       -- 渠道退款流水号,只对账,不判重
  out_trade_no  VARCHAR(32) NOT NULL,
  amount_cent   BIGINT      NOT NULL,   -- 本次退款金额(分)
  status        VARCHAR(16) NOT NULL,   -- ACCEPTED / SUCCESS / FAIL
  raw_body      MEDIUMTEXT  NOT NULL,
  notified_at   DATETIME    NOT NULL,   -- 本地落库时间;渠道通知时间从 raw_body 取
  PRIMARY KEY (id),
  UNIQUE KEY uk_refund (channel, refund_no),
  KEY idx_order (out_trade_no)
);

微信的 out_refund_no 和支付宝的 out_request_no 是同一个东西的两个名字,落库统一叫 refund_no,别在代码里按渠道分叉。

回了 success,账却没动

这类问题的表现形式是成功:接口返回 200,日志里没有 ERROR,监控也不响。异常监控盯的是报错,账没落这件事不在它的视野里。

微信支付 v3 的通知要求在 5 秒内应答,超时它就认为失败,按自己的策略重发。这一条决定了回调里能干什么:落原文、推进状态、标 handled,三件都是本地库操作,做完就回。扣库存、发通知、算分账这类动作要么异步出去要么进下游任务,把整条链路塞进回调,链路里最慢的那一步就成了你的应答时间,而它慢的那天,往往正是渠道重试最密的那天。

很多实现把订单状态推进直接放在回调接口里,链路拉得很长:验签、落原文、改订单、扣库存、发通知、返回成功。中间任何一步抛异常,你的 catch 又写得宽,接口照样返回成功串,渠道从此不再重发。扣库存、发通知这类副作用应该异步出去,留在回调事务里,一次超时就会把事务拉长、把回调拖死。钱收了,订单停在待支付(WAIT_PAY),客服第二天接到电话。

同一类问题的另一半在事务边界上。@Transactional 的方法内部 try { ... } catch (Exception e) { log.error(...) },异常被吞掉,Spring 看不到异常就不回滚,于是“改状态成功、记账失败”这种半套结果会提交进库。反过来,把原始报文和业务放在同一个事务里,一次业务校验失败会把报文一起回滚,你连收到过它都不知道。

所以顺序要定死:验签、解析、原始报文用独立事务落库,然后才是业务事务(推进状态、记账、标记 handled)。原始报文不能和业务同事务,但业务事务里要更新同一条 raw 的 handled,让“已入账”这个结论和账本身同时成立。

@Transactional(rollbackFor = Exception.class)
public void apply(Order o, Notify n, long rawId) {
    if (!stateWhitelist.contains(n.getTradeState())) {
        notifyRawRepo.markPending(rawId);      // 1. 不认识的态:不推进,标记待人工
        alarm.on("notify_unknown_state", rawId);
        return;                                // 渠道不用再重试,人来处理
    }
    TransitResult r = transitService.transit(o.getId(), OrderEvent.PAY_SUCCESS,
            n.getTransactionId(), Operator.channel(n.getChannel()));  // 2. 推进
    if (r == TransitResult.DUPLICATE) {        // 同一事件同一凭证,已经落过
        notifyRawRepo.markHandled(rawId);
        return;
    }
    if (r == TransitResult.ILLEGAL) {          // 已取消、已退款:钱收了,但没入账
        notifyRawRepo.markPending(rawId);      // 不能标 handled
        refundTask.enqueue(o.getId(), n.getTransactionId());
        return;
    }
    paymentRepo.record(n, o.getId());          // 3. 记账
    notifyRawRepo.markHandled(rawId);          // 4. 标记,同一个事务
}

不认识的态要单独处理。渠道发来的不只是成功:微信有 CLOSED、REVOKED、PAYERROR,支付宝有 TRADE_CLOSED。能映射成关单或失败事件的先映射;映射不出来的,默认策略是“不推进、留痕、告警”,把 handled 标成 2,不能留在 0:0 是“还没处理”,2 是“等人工”,两个数在日报里含义不同。

中间态(NOTPAY、USERPAYING)不进这张 raw 表:它们没有交易号,进了就是拿空串撞唯一键;能拿到交易号的 CLOSED、REVOKED、PAYERROR 进表并标 2。写成 else 什么都不做,半年后你会在业务表里发现一批停在奇怪状态、原始记录齐全的单子,而告警一条也没发过。

重复回调在支付系统里是常态,代码要按常态写。transit 的返回值要分开用:DUPLICATE 是同一事件同一凭证已经落过,raw 直接标 handled;ILLEGAL 是订单当前状态没有这条边——已取消、已退款,钱收了但没入账,raw 要留成待人工,另落一条退款任务,不能跟着重复的分支一起吞掉。ILLEGAL 不写迁移记录,同一个回调再来一次还是 ILLEGAL,所以退款任务表要用 (tenant_id, order_id, transaction_id) 做唯一键,两次入队只留一条待办:

CREATE TABLE pay_refund_task (
  id             BIGINT      NOT NULL AUTO_INCREMENT,
  tenant_id      BIGINT      NOT NULL,
  order_id       BIGINT      NOT NULL,
  channel        VARCHAR(16) NOT NULL,
  transaction_id VARCHAR(64) NOT NULL,   -- 那笔 ILLEGAL 回调带来的渠道流水号
  status         VARCHAR(16) NOT NULL DEFAULT 'PENDING',   -- PENDING / DONE / MANUAL
  created_at     DATETIME(3) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_task (tenant_id, order_id, transaction_id)
);

入队直接 INSERT,撞键即视为已有待办,不要先查后插——并发下两个线程都查到没有,各建一条,就是两次退款。

transit 内部的 UPDATE ... WHERE status=? 只负责挡并发,CAS 返回 0 是有人先推进了,抛出去让上层重试;它不是判重的依据。记账那一步也一样,记账表的唯一键建在 (channel, transaction_id) 上,和 raw 表同号;INSERT ... ON DUPLICATE KEY UPDATE id=id 或先查后插,把重复做成幂等,唯一键只当兜底。要是还有唯一键异常漏出来,让事务回滚、raw 留在 0,下一次重试会重新判断;别在外层直接把 raw 标成 handled,那会把已经回滚的状态推进一起吞掉。

markHandled 必须和记账在同一个事务里。拆开的话,标记先落、账没落,下一次回调会看到 handled=1 直接回成功,这笔钱就再也补不上了;反过来账落了标记没落,最坏是重复处理一次。重复处理本来就是常态。

回调是 Push,资金状态还得有一条 Pull

回调会丢,常见的是这几种:服务在那一分钟重启,验签因为证书轮换失败,网关把超时的连接切断,也可能就是渠道那边的推送队列积压了两小时。渠道会重发,但重发次数和窗口由它定,不由你定。Push 管时效,Pull 补漏;只靠 Push,等于把状态推进的上限交给一个你控制不了的窗口。所以还得有一条主动查单兜底。

@Scheduled(fixedDelay = 60_000)          // 60_000 毫秒是 1 分钟;简单版固定,退避档位和上限见本节末
public void queryPending() {
    for (Payment p : paymentRepo.pendingOlderThan(Duration.ofMinutes(3), 200)) {
        QueryResult r = channelGateway.query(p.getOutTradeNo(), p.getMerchantId());
        if (r.isPaid())      paymentService.confirmFromQuery(p, r);   // 走同一个 transit 入口
        else if (r.isClosed()) paymentService.close(p, "channel_closed");
        // 仍在等待:什么都不做,下一轮再看
    }
}

这段任务代码本身没几行,出事基本出在它带的参数和它走的出口上。

查单要带当初下单用的商户号和证书,多商户部署时错一个商户号,查回来的是 ORDER_NOT_EXIST 这类错误,而你库里那单正在等着被确认。这种误判如果被直接当成失败落进业务表,单子就被关掉了。渠道的返回码不是你的状态枚举,中间得有一份自己的映射,哪些算未付、哪些算关单要写死,剩下的先挂起来等人工。

查单结果和回调冲突时,按订单状态机的方向决定,不按消息谁先到。两边都从同一个 transit 进来:已经走到支付成功的单,不会因为一条更早的未支付查询被推回去;查询查到已支付、回调还没来,就由查询把这一步推进。

查单是某一刻的快照,回调也可能是几秒前生成的,两者提供的都只是“渠道在某个时刻说什么”,推不推进交给同一张迁移表判断。对账处理的是另一层,账和分录对不上时以渠道账单为准修正,那不是让一条旧快照去改状态。

confirmFromQuery 必须走和回调同一个 transit 入口、同一个幂等键。如果查询这条路径自己写一句 updateStatus,你就有了两条链路两套状态判断,重复支付正好从这条缝里进来。查到的金额要以查询结果记账,别拿库里的应付金额顶替。

轮询要有频控、退避和上限。pendingOlderThan 那个查询要落在 (status, created_at) 这样的索引上,别每次扫全表;间隔可以按 30 秒、1 分钟、5 分钟、15 分钟递增,别固定一分钟扫一天;超过一定时间的单转人工而不是无限重试,比如扫码支付的单可以设两小时过期,你查三天没意义。

退款受理和退款成功之间隔着一轮查询

支付方向的钱丢了是少收,退款方向的钱丢了是多付,后者更贵,而且财务更难解释。

退款调用返回的是受理结果,不是资金结果。你发起退款,渠道给你一个 refund_id 和“已受理”,钱还没出你的商户账户;真正的成功由退款回调或者退款查询给出。失败态也存在:商户账户余额不足,或者这一笔超过了原渠道的单笔限额,或者收款银行卡已注销、渠道把退款退回商户账户。把它当成功记账,结果是系统显示已退款,用户那边没收到钱,月底对账时你这边有一笔退款,渠道账单里没有。

public RefundResult refund(long orderId, BigDecimal amount, String reason) {
    Refund r = refundService.createPending(orderId, amount, reason); // 事务内:守卫加锁 + 落 refund_no
    ChannelReceipt rec = channelGateway.refund(r.toRequest());       // 事务外:别拿着行锁等渠道
    refundService.markAccepted(r.getId(), rec.getRefundId());        // 受理,不是成功
    return r.asResult();
}

refund_no 必须自己生成并在库里唯一。createPending 写的是退款单,pay_refund_raw 存的是渠道回调原文,两张表都拿 refund_no 关联。渠道回来的 refund_id 存在原始表另一列,用来对账,不用来判重。

createPending 里先 select ... for update 锁住订单行,再按“已退 + 受理中 + 本次 ≤ 实付”校验并插入退款单。校验和插入必须在同一个事务里,否则两个请求同时读到同一个已退金额,还是会一起通过。渠道调用放在事务外,别拿着行锁等几秒的网络往返。渠道受理后进程挂了也不要紧,退款单停在待确认,下一次拿同一个 refund_no 去查或重试,渠道认这个号。

退款状态要三个值起步:ACCEPTED 是渠道已受理、SUCCESS 是钱已出账、FAIL 是渠道明确失败。只有两个值(退了/没退)的系统,很容易在 FAIL 那天把状态改回“未退款”,然后有人再点一次按钮,退第二遍。FAIL 之后能不能重新发起,得由 refund_no 是否已消耗来判断,不是由状态字段回滚。

累计退款能不能守住,是另一类容易漏掉的问题:多次部分退款加起来超过实付,或者因为四舍五入少退一分。守卫条件要写成“已退 + 受理中 + 本次 ≤ 实付”,并且比较的是同一个单位(分)。只算已成功的退款不够:两笔 ACCEPTED 同时进来,各自都通过“已退 + 本次 ≤ 实付”,等它们都成功就已经超退。写成“单次 ≤ 实付”的守卫更松,退三次每次都不超标,总额早就超了。

守卫算的是金额,挡不住同一笔请求发两遍。两个一模一样的退款请求,金额都合法、累计也没超,守卫两次都放行;真正挡住第二次的是 refund_no 的唯一键,以及“发起前先把退款单落成 ACCEPTED”这个顺序。所以退款要先生成号、先落单、再调渠道,不是调成功了再补记录。顺序一倒,守卫就白算了。

防重放那套(时间戳窗口、随机串去重)按渠道文档做,这里不展开:它保证的是报文是真渠道发来的,不保证这笔钱对得上你的账。商户号和证书怎么轮换也属于渠道侧的事,跟这笔钱最终分给谁有关,那是账务结构那一层的题目。

对账脚本解析出零行,本身就该报警

对账是这条链路上最后一道独立校验,也是少数不指望上游报错的一道。完整的对账系统怎么搭、几张表之间怎么勾稽、日切口径怎么定,08 那篇整篇在讲;这里只说绕不开它的那一处:对账脚本最常见的失败形态是空跑完还返回成功。

差异数为 0 是好事,说明账平了;解析行数为 0 才是故障,说明账单根本没进来。两个零长得一样,含义相反。常见的一种是账单文件解析出来 0 行,然后脚本很老实地跑完、返回成功、第二天没人再看。

触发条件是渠道改了表头或文件编码,或者下载链接过期拿回一个 HTML 错误页。防御办法在行数本身:汇总行显示有笔数、解析出来却是 0 行,就阻断;汇总行本身也是 0 笔的,是这天真没交易,不算故障。比昨天少九成也阻断,这个阈值是按日账单量波动不到一成定的,你的业务要先量一遍。同时拿账单汇总行的总笔数、总金额和解析明细对一遍,两个数对不上也阻断。

日切口径是另一处麻烦。渠道按支付成功时间归集,你按订单创建时间切,中间隔着一次跨零点:23:59:58 创建、00:00:02 支付成功的单,会落在第二天的账单里。服务器用 UTC、账单按北京时间,也会差一天。对账窗口要留缓冲,一般 T+1 对账、T+3 再跑一次覆盖前两天的差异。

退款和手续费要另外取。账单是按类型分开给的,只取支付成功那一份的话,退款流水不在你手上;结算金额里扣了渠道服务费,你的记账里如果没这一项,差额就一直对不平,而它对不平时的表现就像一笔掉单。

拿到三份流水之后,对不上的单子摆出来就那么几类,处置方式各不相同:

差异形态 常见原因 处置
我有渠道无 回调没来、查单窗口过了、订单号串到别的商户 挂账,主动查一次;查不到就让人工判断是否真收到钱
渠道有我无 重复支付、回调处理失败但钱到账、测试单 先补录挂账;确认商户号和订单、过了 T+1 窗口仍无对应单,再走原路退款
金额不符 单位错(分与元)、优惠或尾差、把渠道实收当订单应付 冻结该单状态推进,人工核
状态不符 渠道已退款我们记着已支付、退款受理当成成功 以渠道为准,但走 RECONCILE_FIX(对账修正)事件进 05 那张迁移表,operator 记 system:reconcile、biz_key 填对账批次;账务红冲保留痕迹,不删记录

这几类都要落到 08 篇那张对账差异表(recon_diff),带 handle_status 和处理人。没有这张表,对账结果就只是一行日志,第二天没人记得昨天那批差异处理到哪一条了。账务上删记录这条路不走,红冲保留痕迹。

明天早上先跑这一个数

明天早上可以先做一件事:把 pay_notify_raw 里 handled = 0 且落库超过 10 分钟的记录数跑出来。不为零说明有回调进来了没处理完;它一直是零也不能证明没丢单——回调根本没到达时,这张表里连一行都没有,那种只能拿渠道账单反查“渠道有我无”。同一个查询顺手把 handled = 2 也数出来;2 不是积压,超过 24 小时没人动才是。


系列:上一篇 订单状态机:别用 status=1,2,3 糊弄 | 下一篇 分账的账要能倒推回去 | 相关 账不平时要能说出错在哪一层