同一个 out_trade_no 第二次下单:支付宝四个错误码怎么分诊、怎么收尾
适用边界先讲清楚:下面全部基于支付宝国内版 openapi.alipay.com 的单笔支付与收银台场景(alipay.trade.page.pay / alipay.trade.wap.pay / alipay.trade.app.pay / alipay.trade.pay),不涉及间连模式和国际版。文中错误码、trade_status 语义取自开放平台文档与技术支持 FAQ,部分调用细节未在真实商户号上逐条复现,接入前建议用沙箱或小额真实交易验证一遍。
现象:同一笔单号第二次下单,报错取决于支付宝侧当前状态
用户支付未完成就返回、前端重复点击、后端重试时没换 out_trade_no,第二次调同一个下单接口会拿到不同的错误码——不是接口不稳定,而是这笔 out_trade_no 在支付宝侧当前的 trade_status 不一样。所以排障的第一步不是看错误码文本,而是先查这笔单在支付宝侧处于哪一态。
按 trade_status 分诊
统一收单交易查询 alipay.trade.query 返回四个业务状态:
| trade_status | 含义 |
|---|---|
WAIT_BUYER_PAY | 交易创建,等待买家付款 |
TRADE_CLOSED | 未付款交易超时关闭,或支付完成后全额退款 |
TRADE_SUCCESS | 交易支付成功 |
TRADE_FINISHED | 交易结束,不可退款 |
异步通知默认只开 TRADE_SUCCESS(tradeStatus.TRADE_SUCCESS 默认 1),TRADE_CLOSED / TRADE_FINISHED / WAIT_BUYER_PAY 默认关闭(0)。只靠通知判断状态会漏。
同一个 out_trade_no 复用时的行为:
WAIT_BUYER_PAY:订单已创建未完成,商户用相同订单号且其他业务参数不变,重新请求可以正常唤起支付页面;但必须是之前创建该订单的用户账户才能付款,不支持更换付款账户。参数改了就报ACQ.CONTEXT_INCONSISTENT/ALIN42282「商家订单参数异常,请尝试返回后重新付款或联系商家确认」。TRADE_SUCCESS/TRADE_FINISHED:报ALIN42279「订单已付款,请勿重复提交」,或ACQ.TRADE_HAS_SUCCESS「交易已被支付」。TRADE_CLOSED:报ALIN42280「抱歉,订单已关闭,你可以重新购买并付款」,或ACQ.TRADE_HAS_CLOSE「交易已经关闭」。
alipay.trade.pay 的同步业务错误码原文:
ACQ.TRADE_HAS_SUCCESS400 交易已被支付:确认该订单号是否重复支付,如果是新订单,请更换商户订单号后重新提交支付。ACQ.TRADE_HAS_CLOSE400 交易已经关闭:商户订单号对应的交易已经关闭(超时未支付或者已全额退款),请更换商户订单号后重新发起支付请求。ACQ.CONTEXT_INCONSISTENT400 订单信息不一致:商户订单号已经创建交易成功,且本次请求的交易关键信息(如订单金额、订单标题等)与已存交易的信息不一致;如原有交易未支付成功,请更换订单号重新发起支付请求。ACQ.TRADE_BUYER_NOT_MATCH400 交易买家不匹配:确认该订单号是否重复支付,如果是新订单,请更换商户订单号后重新提交支付。ACQ.TRADE_NOT_EXIST(查询接口):查询的交易不存在,检查传入的交易号是否正确。ACQ.INVALID_PARAMETER参数无效。ACQ.SYSTEM_ERROR400 系统异常:该笔交易可能成功也可能失败,请调用查询订单 API 查询当前订单状态,并根据订单状态决定下一步操作;多次重试仍报此错误联系支付宝客服。
对应 FAQ:ACQ.TRADE_HAS_SUCCESS 说明、订单已付款,请勿重复提交(ALIN42279)。
关键一点:TRADE_CLOSED 既可能是「从来没付过、超时关掉」,也可能是「付过、后来全额退款」。同一个状态两种来源,本地不能只看支付宝状态就决定该不该退款。
正确收尾:查询优先,撤销和退款不要混用
没拿到交易结果时不要直接要求用户再付一次——可能是用户已付款成功、只是商户侧网络问题没拿到结果。确认已支付就先退款,再让用户重付。
异常处理口径(蚂蚁开放平台接口调用规范,接口调用规范/单边账):
- 支付接口
alipay.trade.pay异常:立即调查询;查询返回ACQ.TRADE_NOT_EXIST时用相同参数重调支付;网络超时或未知异常继续查询一分钟,仍超时/未知则记录异常交易走人工,不能推断为付款失败。 - 查询
alipay.trade.query、撤销alipay.trade.cancel异常:立即重试一分钟,仍失败记录异常交易走人工。 - 预下单
alipay.trade.precreate异常:换新的out_trade_no重新预下单。 - 退款
alipay.trade.refund异常:相同参数重试一分钟,仍失败记录走人工,不能推断退款成功或失败。 - 唤起收银台的交易(返回状态 10003)建议轮询:总时长 30 秒,平均间隔 3 秒。让用户再次支付前必须先查询确认当前订单状态。
撤销只处理未支付订单,超过 24 小时无法撤销;退款走 alipay.trade.refund。两者混用会让订单状态和业务状态对不上,监控上也看不出异常。
out_trade_no 怎么生成
String(64),商家自定义,仅支持字母、数字、下划线,且必须保证商户端不重复。新建订单都要换号,重试也走跳号处理,不要图省事复用上一笔的单号。参考如何设置 out_trade_no。
另外单品总金额要与订单金额一致,避免实收与应收不匹配。
超时撤销
未支付订单及时调 alipay.trade.cancel,超过 24 小时就撤不掉了。撤销的目的不是清账,而是把「用户不会再付」的单收口掉,避免后面同一个单号被再次提交时落进 WAIT_BUYER_PAY 分支唤起一个早已过期的收银台。
退款:out_request_no 必须换
同一笔交易多次部分退款,每次都要更换退款请求号 out_request_no,否则请求号冲突导致部分退款失败。把它当成幂等键用:同一个 out_request_no 重试是安全的,想再退一次就必须换号。
SYSTEM_ERROR 与 isp.unknow-error
接口返回 code=20000 且 sub_code=isp.unknow-error 或 ACQ.SYSTEM_ERROR 时,业务处理结果未知——注意是未知,不是失败。这两种情况都只能落到「查询 → 再决策」这条路径上,不要在 catch 分支里直接把订单置为失败或让用户重付。
代码收口
Go 版本,三个动作:查询优先、条件更新、幂等键。支付宝调用封装成接口,具体 SDK 调用按各自接入方式实现。
type TradeStatus string
const (
WaitBuyerPay TradeStatus = "WAIT_BUYER_PAY"
TradeClosed TradeStatus = "TRADE_CLOSED"
TradeSuccess TradeStatus = "TRADE_SUCCESS"
TradeFinished TradeStatus = "TRADE_FINISHED"
)
// QueryResult 对应 alipay.trade.query 的业务响应参数
type QueryResult struct {
Found bool // false 即 ACQ.TRADE_NOT_EXIST
TradeStatus TradeStatus
TotalAmount string // 元,字符串,别用浮点
}
var ErrUnknown = errors.New("trade result unknown") // 超时 / ACQ.SYSTEM_ERROR / isp.unknow-error
func (s *Service) Pay(ctx context.Context, order Order, req PayRequest) (Decision, error) {
// 幂等:request_id 唯一索引 + 状态条件更新,抢不到说明已处理过,直接回放结果
acquired, err := s.repo.TryLockAttempt(ctx, order.ID, req.RequestID)
if err != nil {
return Decision{}, err
}
if !acquired {
return s.repo.LoadDecision(ctx, req.RequestID)
}
// 查询优先,异常按规范重试一分钟
q, err := s.alipay.QueryWithRetry(ctx, order.OutTradeNo, time.Minute)
if err != nil {
return Decision{Action: ActionManual, Reason: "query unknown"}, nil // 记异常交易走人工
}
if !q.Found {
// ACQ.TRADE_NOT_EXIST:支付宝侧没这笔,按原单号创建
return s.createAndPay(ctx, order, req)
}
switch q.TradeStatus {
case WaitBuyerPay:
// 复用原单号的前提:金额、标题等关键参数与已存交易完全一致,且付款账户不变
if !sameBizParams(order, req) {
return Decision{Action: ActionNewOutTradeNo, Reason: "ACQ.CONTEXT_INCONSISTENT"}, nil
}
return Decision{Action: ActionReopenCashier}, nil
case TradeSuccess, TradeFinished:
// ALIN42279 / ACQ.TRADE_HAS_SUCCESS:本地补记成功,不再重发支付
return Decision{Action: ActionMarkPaid}, nil
case TradeClosed:
// ALIN42280 / ACQ.TRADE_HAS_CLOSE:换号重建;是否要先退款看本地是否有成功支付记录
if s.repo.HasPaidRecord(ctx, order.ID) {
return Decision{Action: ActionRefundFirst, Reason: "closed after refund"}, nil
}
return Decision{Action: ActionNewOutTradeNo}, nil
}
return Decision{Action: ActionManual, Reason: "unexpected trade_status"}, nil
}
条件更新那一步(示例 SQL,靠影响行数判断是否抢到):
UPDATE orders
SET status = 'paying', last_request_id = ?, updated_at = NOW()
WHERE id = ?
AND status IN ('created', 'wait_pay')
影响行数为 0 就是并发重复提交,回放上一次结果即可,不要第二次调用支付接口。