退款已完成时 REST API 应返回哪个 HTTP response code?
结论
如果重复请求对应同一次退款,而且交易已经退款成功,建议返回 200 OK,并在响应中明确说明退款已完成。
如果接口处理成功后不需要返回响应体,也可以使用 204 No Content。
不必因为”退款之前已经完成”而返回错误码。客户端希望交易进入”已退款”状态,而服务器当前已经处于该状态,所以可以把重复请求按成功处理。这正是幂等接口应有的行为。
为什么更适合返回 200
客户端可能因为网络超时而无法确认第一次请求的处理结果。例如:
- 客户端发起退款。
- 服务端完成退款。
- 响应在传输过程中丢失。
- 客户端重新发送相同请求。
如果第二次请求返回错误,客户端很难判断退款是真的失败了,还是已经成功。返回 200 OK 和现有退款结果,可以让客户端放心重试,同时保证资金不会重复退回。
需要注意的是,幂等并不要求每次响应完全一致,只要求重复执行不会产生额外副作用。 收到第二次请求时,服务端可以直接返回当前退款记录,不必再次调用支付渠道。
建议的处理流程
服务端收到请求后,可以按以下顺序处理:
- 根据
transactionId查询原交易。 - 交易不存在时,返回
404 Not Found。 - 已有对应的成功退款时,返回
200 OK和现有退款记录。 - 交易当前不能退款时,例如已经撤销、超过退款期限或状态不合法,根据业务规则返回
409 Conflict或422 Unprocessable Content。 - 交易尚未退款时,执行退款并保存结果。
- 通过数据库唯一约束、事务或原子状态更新,防止并发请求导致重复退款。
示例响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
"transactionId": "txn_123",
"refundId": "ref_456",
"status": "refunded",
"alreadyRefunded": true
}
alreadyRefunded 是可选的业务字段。如果客户端不需要区分退款是本次完成还是此前已经完成,只需返回稳定的退款资源。
接口设计示例
如果每笔交易最多只能产生一笔退款,可以将退款建模为交易下的唯一资源,并使用 PUT:
PUT /transactions/txn_123/refund
PUT 适合这类”将唯一资源设置为指定状态”的幂等操作。第一次调用创建或完成退款,之后的调用返回已有结果。
下面是一个简化的 JavaScript 示例:
app.put("/transactions/:transactionId/refund", async (req, res) => {
const { transactionId } = req.params;
const transaction = await transactionRepository.findById(transactionId);
if (!transaction) {
return res.status(404).json({
code: "TRANSACTION_NOT_FOUND",
message: "Transaction not found"
});
}
const existingRefund =
await refundRepository.findSuccessfulByTransactionId(transactionId);
if (existingRefund) {
return res.status(200).json({
transactionId,
refundId: existingRefund.id,
status: "refunded",
alreadyRefunded: true
});
}
if (!canRefund(transaction)) {
return res.status(409).json({
code: "TRANSACTION_NOT_REFUNDABLE",
message: "Transaction cannot be refunded in its current state"
});
}
const refund = await refundService.processOnce(transactionId);
return res.status(200).json({
transactionId,
refundId: refund.id,
status: "refunded",
alreadyRefunded: false
});
});
processOnce 必须在持久化层保证退款只执行一次,不能只依赖前面的查询。否则,两个并发请求可能同时查到交易”尚未退款”,随后分别发起退款。
例如,可以为退款表添加唯一约束:
CREATE UNIQUE INDEX uq_refund_transaction
ON refunds (transaction_id);
如果系统允许部分退款,一笔交易可能合法地对应多条退款记录,就不能直接为 transaction_id 建立唯一索引。此时应改用退款请求标识,例如:
CREATE UNIQUE INDEX uq_refund_idempotency_key
ON refunds (idempotency_key);
什么时候可以返回 409
如果重复请求并非同一个操作,而是与现有退款发生冲突,可以返回 409 Conflict。例如:
- 第一次请求退款 100 元,第二次却使用同一个幂等键请求退款 200 元。
- 交易已经全额退款,客户端又尝试创建一笔新的退款资源。
- 请求参数与已有退款记录不一致,服务端无法将其认定为安全重试。
示例:
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"code": "IDEMPOTENCY_KEY_CONFLICT",
"message": "The idempotency key was already used with different refund parameters"
}
因此,409 Conflict 应用于”新请求与现有资源冲突”,不适合仅仅表示相同退款已经成功。
使用 POST 时如何处理
如果接口必须使用 POST,建议支持 Idempotency-Key:
POST /transactions/txn_123/refunds
Idempotency-Key: 8ecdd528-2649-4a09-a744-2f7f83ea7841
Content-Type: application/json
{
"amount": 10000,
"currency": "CNY"
}
服务端需要保存幂等键、请求参数和第一次处理结果。再次收到相同幂等键和相同参数时,直接返回保存的结果。如果相同幂等键携带了不同参数,则返回 409 Conflict。
首次创建退款资源时可以返回:
HTTP/1.1 201 Created
Location: /refunds/ref_456
相同请求重放时,可以再次返回保存的 201 响应,也可以返回 200 OK 和现有退款资源。这两种方式都可以,但接口契约必须统一,并在文档中写清楚。
注意事项
- 不要用
400 Bad Request表示重复退款,因为请求格式通常没有问题。 422 Unprocessable Content适合请求参数合法,但业务上无法处理的情况,不适用于重试相同的成功请求。- 退款仍在处理中时,可以返回
202 Accepted并提供状态查询地址,不要提前报告退款成功。 - 退款幂等需要覆盖数据库并发、进程重启、消息重复投递和支付渠道超时等情况。
- 支付渠道返回超时时,不要立即认定退款失败并再次发起退款。应先根据渠道订单号或幂等键查询最终状态。
- 响应中应提供稳定的
refundId和明确的退款状态,方便客户端核对和追踪。
对于”同一交易的同一次退款已经完成,服务端又收到完全相同的请求”这种情况,使用 200 OK 返回现有退款结果,通常是最清楚也最安全的处理方式。
备注:内容仅供参考。
