﻿# 开放接口附录 - 回调验签与成功响应最小实现示例

**适用场景**：代收回调 / 代付回调  
**版本日期**：2026-04-18

---

## 1. 适用范围

本文档用于说明下游商户收到平台回调后，服务端最小需要做什么：

- 先验签
- 再判断业务状态
- 做幂等处理
- 最后返回平台认可的成功响应

适用回调：

- 代收下游回调通知
- 代付下游回调通知

---

## 2. 成功响应判定

平台把回调视为成功的条件是：

- HTTP `200`
- 且响应体为纯文本 `success`

或者：

- HTTP `200`
- 且响应 JSON 中 `code = 10000`

最稳妥的写法：

```text
success
```

---

## 3. 回调处理最小流程

收到回调后，按这个顺序处理：

1. 读取原始 JSON
2. 提取 `sign`
3. 用商户 `SignKey` 重算签名
4. 验签失败直接返回非成功响应
5. 验签通过后，按 `platformOrderNo` 或 `merchantOrderNo` 做幂等
6. 根据 `status` 更新本地订单
7. 成功落库后返回 `success`

不要这样做：

- 先改订单，再验签
- 验签失败也返回 `success`
- 每次回调都重复扣款/重复发货

---

## 4. 验签规则

回调验签规则与开放接口请求签名一致：

- 排除 `sign`
- 忽略 `null`
- 键名转为全小写路径
- 按 ASCII 升序排序
- 拼成 `key=value&key=value`
- `value` 做 URL 编码
- 最后追加 `&key=YOUR_SIGN_KEY`
- 做 MD5

---

## 5. 代收回调最小示例

## 5.1 平台回调示例

```json
{
  "appId": "your_app_id",
  "rspCode": "PAY_000",
  "rspMsg": "操作成功",
  "platformOrderNo": "PLT20260315000001",
  "merchantOrderNo": "ORD202603150001",
  "amount": 10000,
  "paidAmount": 10000,
  "currency": "CNY",
  "actualAmount": 10000,
  "status": "SUCCESS",
  "statusText": "支付成功",
  "payMethodCode": "qr",
  "subject": "购买VIP会员",
  "channelOrderNo": "UP202603150001",
  "paymentUrl": "https://cashier.example.com/pay/abc123",
  "createdTime": "2026-03-15T09:00:00",
  "updatedTime": "2026-03-15T09:02:00",
  "timestamp": "1773542405",
  "nonce": "30f1cbd18021409d",
  "apiVersion": "v1.0",
  "sign": "c310d80b7a1897724206cfb74e8de5e2"
}
```

## 5.2 C# 最小实现示例

```csharp
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Web;

app.MapPost("/merchant/pay-notify", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body, Encoding.UTF8);
    var rawBody = await reader.ReadToEndAsync();

    using var doc = JsonDocument.Parse(rawBody);
    var root = doc.RootElement;

    var sign = root.GetProperty("sign").GetString() ?? string.Empty;
    var signKey = "YOUR_SIGN_KEY";

    var calculated = CalcSign(root, signKey);
    if (!string.Equals(sign, calculated, StringComparison.OrdinalIgnoreCase))
        return Results.BadRequest("invalid sign");

    var platformOrderNo = root.GetProperty("platformOrderNo").GetString() ?? string.Empty;
    var status = root.GetProperty("status").GetString() ?? string.Empty;

    // 这里应先查本地订单是否已处理，避免重复发货
    if (AlreadyProcessed(platformOrderNo))
        return Results.Text("success");

    if (status == "SUCCESS")
    {
        MarkOrderPaid(platformOrderNo);
    }
    else if (status == "FAILED")
    {
        MarkOrderFailed(platformOrderNo);
    }

    return Results.Text("success");
});

static string CalcSign(JsonElement root, string signKey)
{
    var dict = new SortedDictionary<string, string>(StringComparer.Ordinal);
    Flatten(string.Empty, root, dict);
    dict.Remove("sign");

    var parts = dict
        .Where(x => !string.IsNullOrWhiteSpace(x.Value))
        .Select(x => $"{x.Key}={HttpUtility.UrlEncode(x.Value)}");

    var signStr = string.Join("&", parts) + "&key=" + signKey;
    using var md5 = MD5.Create();
    var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(signStr));
    return Convert.ToHexString(hash).ToLowerInvariant();
}

static void Flatten(string prefix, JsonElement element, IDictionary<string, string> dict)
{
    switch (element.ValueKind)
    {
        case JsonValueKind.Object:
            foreach (var p in element.EnumerateObject())
            {
                var next = string.IsNullOrEmpty(prefix)
                    ? p.Name.ToLowerInvariant()
                    : $"{prefix}.{p.Name.ToLowerInvariant()}";
                Flatten(next, p.Value, dict);
            }
            break;
        case JsonValueKind.Array:
            var i = 0;
            foreach (var item in element.EnumerateArray())
            {
                Flatten($"{prefix}[{i}]", item, dict);
                i++;
            }
            break;
        case JsonValueKind.Null:
        case JsonValueKind.Undefined:
            break;
        default:
            dict[prefix] = element.ToString();
            break;
    }
}

static bool AlreadyProcessed(string platformOrderNo) => false;
static void MarkOrderPaid(string platformOrderNo) { }
static void MarkOrderFailed(string platformOrderNo) { }
```

---

## 6. 代付回调最小示例

## 6.1 平台回调示例

```json
{
  "appId": "your_app_id",
  "rspCode": "PAY_000",
  "rspMsg": "成功",
  "platformOrderNo": "PLT_OUT_20260315000001",
  "merchantOrderNo": "WD202603150001",
  "applyAmount": 50000,
  "currency": "CNY",
  "feeAmount": 500,
  "actualAmount": 49500,
  "status": "SUCCESS",
  "statusText": "代付成功",
  "method": "bank_card_payout",
  "payeeType": "BANK",
  "payeeName": "张三",
  "payeeAccount": "6222021234567890123",
  "payeeBankName": "中国工商银行",
  "createdTime": "2026-03-15T10:00:00",
  "settledTime": "2026-03-15T10:05:00",
  "remark": "银行卡代付",
  "timestamp": "1773542406",
  "nonce": "3c8bb214123e4ed9",
  "apiVersion": "v1.0",
  "sign": "4CB9B989CB1C77A3F4BD9A4C9425A159"
}
```

## 6.2 处理要点

- 代付回调字段里的支付方式名是 `method`，不是 `payMethodCode`
- 终态只看：
  - `SUCCESS`
  - `FAILED`
- 验签通过前，不要改代付单状态
- 已经处理过的回调，直接回 `success`

---

## 7. 推荐的幂等策略

建议至少做到其中一项：

- 以 `platformOrderNo` 建唯一约束
- 回调处理前先查本地订单状态
- 只有“未终态”订单才允许推进到终态

推荐规则：

- 本地已是 `SUCCESS`，后续重复回调直接回 `success`
- 本地已是 `FAILED`，后续重复回调直接回 `success`
- 同一订单不要重复发货、重复入账、重复解冻

---

## 8. 排查建议

### 8.1 验签总失败

优先检查：

- 是否把 `sign` 自己也参与了签名
- 键名是否转成了全小写
- 是否做了 URL 编码
- 是否用了错误的 `SignKey`

### 8.2 平台一直重试回调

优先检查：

- 你是否返回了 HTTP 200
- 响应体是否真的是 `success`
- 是否异常中断，没走到最终响应

### 8.3 重复通知导致重复处理

优先检查：

- 是否按 `platformOrderNo` 做幂等
- 是否在发货/入账前先判断本地终态

---

## 9. 使用建议

- 联调第一步先用纯文本 `success`
- 回调日志至少记录：
  - 原始请求体
  - 验签结果
  - 本地订单号
  - 平台订单号
  - 最终响应内容
- 回调处理逻辑尽量短，复杂动作放异步任务
