PAYATHON 2026

退款已完成时 REST API 应返回哪个 HTTP response code?

支付老李

结论

如果重复请求对应同一次退款,而且交易已经退款成功,建议返回 200 OK,并在响应中明确说明退款已完成。

如果接口处理成功后不需要返回响应体,也可以使用 204 No Content。

不必因为”退款之前已经完成”而返回错误码。客户端希望交易进入”已退款”状态,而服务器当前已经处于该状态,所以可以把重复请求按成功处理。这正是幂等接口应有的行为。

为什么更适合返回 200

客户端可能因为网络超时而无法确认第一次请求的处理结果。例如:

  1. 客户端发起退款。
  2. 服务端完成退款。
  3. 响应在传输过程中丢失。
  4. 客户端重新发送相同请求。

如果第二次请求返回错误,客户端很难判断退款是真的失败了,还是已经成功。返回 200 OK 和现有退款结果,可以让客户端放心重试,同时保证资金不会重复退回。

需要注意的是,幂等并不要求每次响应完全一致,只要求重复执行不会产生额外副作用。 收到第二次请求时,服务端可以直接返回当前退款记录,不必再次调用支付渠道。

建议的处理流程

服务端收到请求后,可以按以下顺序处理:

  1. 根据 transactionId 查询原交易。
  2. 交易不存在时,返回 404 Not Found。
  3. 已有对应的成功退款时,返回 200 OK 和现有退款记录。
  4. 交易当前不能退款时,例如已经撤销、超过退款期限或状态不合法,根据业务规则返回 409 Conflict 或 422 Unprocessable Content。
  5. 交易尚未退款时,执行退款并保存结果。
  6. 通过数据库唯一约束、事务或原子状态更新,防止并发请求导致重复退款。

示例响应:

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 返回现有退款结果,通常是最清楚也最安全的处理方式。

备注:内容仅供参考。