AI 通用对接文档

这是一份可以直接复制给 AI 编程助手的通用对接文档,覆盖商户 OpenAPI 鉴权、签名、回调验签、状态处理和当前公开接口清单。

使用时只需要把 baseUrl、Merchant UID、API Key 和业务回调地址替换成商户自己的值,再让 AI 按目标语言生成 SDK 或接入代码。

使用 Merchant UID + mch_ 开头 API Key 完成商户鉴权。
覆盖收款、独享钱包、单笔代付和回调处理。
内置签名规范、接口清单、字段约束、错误码和实现检查清单。

完整 Markdown 文档

# UUGate OpenAPI AI 通用对接文档

把这份文档直接交给 AI 编程助手,让它按你的技术栈实现 UUGate 商户 OpenAPI 对接。实现时以本文档为规格来源。

## 1. 接入目标

实现一个可复用的 UUGate 商户接入层,至少包含:

1. 统一配置:baseUrl、merchantUid、apiKey。
2. HMAC-SHA256 请求签名和带签名请求头的 HTTP Client。
3. 收款订单:创建、查询。
4. 独享钱包:创建、查询详情。
5. 代付订单:创建、查询、提交。
6. 回调接收接口:使用 apiKey 验签,按 status 幂等更新本地业务订单。
7. 错误码处理、幂等处理、金额字符串处理和日志追踪。

## 2. 基础变量

请把下面变量替换成真实值:

~~~env
UUGATE_BASE_URL=https://api.uugate.com
UUGATE_MERCHANT_UID=880001
UUGATE_API_KEY=mch_xxxxxxxxxxxxxxxxxxxx
UUGATE_PAYIN_NOTIFY_URL=https://merchant.example.com/api/uugate/payin-notify
UUGATE_PAYOUT_NOTIFY_URL=https://merchant.example.com/api/uugate/payout-notify
~~~

说明:

- baseUrl 是 API 域名,不要把接口 path 拼进 baseUrl。
- Merchant UID 是商户唯一身份。
- API Key 是商户后台 API 接入里的 mch_ 开头字符串,同时也是开放接口签名密钥。
- API Key 同时用于开放接口请求签名与回调验签;回调必须使用创建该订单时的密钥。
- notifyUrl 必须是商户自己可公网访问的回调地址;live 环境建议使用 HTTPS。

## 3. 支持币种和链路

当前业务只支持以下链路和币种组合:

| chainCode | tokenSymbol | 业务币种 |
| --- | --- | --- |
| TRON | USDT | TRC20-USDT |
| BSC | USDT | BEP20-USDT |
| BSC | USDC | BEP20-USDC |
| SOL | USDC | SPL-USDC |

金额一律使用字符串传输,例如 100.00 或 35.500000。不要用浮点数参与金额计算;内部建议使用 Decimal / BigNumber。

## 4. 请求鉴权和签名

所有 /openapi/ 接口都需要以下请求头:

| Header | 必填 | 说明 |
| --- | --- | --- |
| x-api-key | 是 | 商户 API Key,mch_ 开头 |
| x-merchant-uid | 是 | Merchant UID |
| x-timestamp | 是 | 13 位毫秒时间戳,默认允许和服务器时间相差 5 分钟 |
| x-nonce | 是 | 每次请求唯一随机串,最长 128 位,不能重复 |
| x-signature | 是 | HMAC-SHA256 十六进制签名 |
| content-type | POST 必填 | application/json |

签名原文固定为 7 行,用换行符 \n 拼接:

~~~text
merchantUid
timestamp
nonce
method
path
canonicalQuery
canonicalBody
~~~

字段规则:

- merchantUid、timestamp、nonce 都先 trim。
- method 使用大写,例如 GET、POST。
- path 只使用接口路径,例如 /openapi/payin/orders,不带协议、域名和 query。
- canonicalQuery:把 query 对象按 key 升序;空字符串、null、undefined 跳过;数组逐项展开;最后按 key、value 排序并 URL encode,格式为 a=1&b=2。
- canonicalBody:POST JSON body 按 key 升序递归规范化后 JSON.stringify;数组顺序保持不变;undefined 字段删除;Date 转 ISO 字符串;GET 无 body 时为空字符串。
- 签名算法:HMAC-SHA256(secret=apiKey, message=签名原文),输出 lowercase hex。
- 发送请求时的 JSON body 必须和参与签名的 body 保持一致。

Node.js 签名参考实现:

~~~js
import crypto from 'node:crypto';

function normalizePath(path) {
  const value = String(path || '').trim();
  return value.startsWith('/') ? value : '/' + value;
}

function normalizeJson(value) {
  if (value === undefined) return undefined;
  if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
  if (Array.isArray(value)) return value.map((item) => {
    const normalized = normalizeJson(item);
    return normalized === undefined ? null : normalized;
  });
  if (value instanceof Date) return value.toISOString();
  if (typeof value === 'object') {
    const output = {};
    for (const key of Object.keys(value).sort()) {
      const normalized = normalizeJson(value[key]);
      if (normalized !== undefined) output[key] = normalized;
    }
    return output;
  }
  return String(value);
}

function canonicalBody(body) {
  const normalized = normalizeJson(body);
  return normalized === undefined ? '' : JSON.stringify(normalized);
}

function canonicalQuery(query) {
  if (!query || typeof query !== 'object' || Array.isArray(query)) return '';
  const entries = [];
  for (const key of Object.keys(query).sort()) {
    const raw = query[key];
    if (raw === undefined || raw === null || raw === '') continue;
    const values = Array.isArray(raw) ? raw : [raw];
    for (const item of values) {
      if (item === undefined || item === null) continue;
      const value = typeof item === 'object' ? canonicalBody(item) : String(item);
      entries.push([key, value]);
    }
  }
  return entries
    .sort((a, b) => a[0] === b[0] ? a[1].localeCompare(b[1]) : a[0].localeCompare(b[0]))
    .map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value))
    .join('&');
}

export function buildSignedHeaders({ merchantUid, apiKey, method, path, query, body }) {
  const timestamp = String(Date.now());
  const nonce = crypto.randomBytes(16).toString('hex');
  const payload = [
    String(merchantUid).trim(),
    timestamp,
    nonce,
    String(method).trim().toUpperCase(),
    normalizePath(path),
    canonicalQuery(query),
    canonicalBody(body),
  ].join('\n');

  const signature = crypto.createHmac('sha256', String(apiKey).trim()).update(payload).digest('hex');
  return {
    'x-api-key': String(apiKey).trim(),
    'x-merchant-uid': String(merchantUid).trim(),
    'x-timestamp': timestamp,
    'x-nonce': nonce,
    'x-signature': signature,
  };
}
~~~

## 5. 响应结构

成功响应:HTTP 2xx,body 是接口自己的 JSON 对象,不包一层 code。

失败响应通常为:

~~~json
{
  "code": 20011,
  "message": "Invalid API signature",
  "data": null,
  "requestId": "9e0cf688-11fd-4cd2-83aa-61df77123456"
}
~~~

客户端需要记录 requestId,方便排查。

## 6. OpenAPI 接口清单

### 6.1 创建收款订单

POST /openapi/payin/orders

请求 body:

~~~json
{
  "chainCode": "TRON",
  "tokenSymbol": "USDT",
  "merchantOrderNo": "M202604150001",
  "amount": "100.00",
  "notifyUrl": "https://merchant.example.com/api/uugate/payin-notify"
}
~~~

字段:

- chainCode: TRON / BSC / SOL。
- tokenSymbol: USDT / USDC,必须和 chainCode 匹配。
- merchantOrderNo: 商户订单号,最长 128。
- amount: 收款金额字符串。
- notifyUrl: 本订单回调地址,必填。

响应重点字段:

- orderNo: 平台收款订单号。
- merchantOrderNo: 商户订单号。
- amount: 订单金额。
- paidAmount: 实收金额,未支付时为 null。
- address: 实际收款地址。
- cashierUrl: 收银台完整地址,商户前端可以直接跳转。
- paymentUri: 钱包拉起 URI。
- expireAt / createdAt / detectedAt / confirmedAt / completedAt。
- status: waiting_payment / detected / confirming / completed / risk_settled / expired / frozen_abnormal / cancelled。

### 6.2 查询收款订单

GET /openapi/payin/orders/{orderNo}

path 参数:

- orderNo: 创建收款订单返回的平台订单号。

响应字段和创建收款订单一致。业务最终成功建议以 status=completed 为准。

### 6.3 创建独享钱包

POST /openapi/payin/exclusive-bindings

用于给会员、站点、业务账号等 bindKey 创建长期专属收款地址。

请求 body:

~~~json
{
  "chainCode": "TRON",
  "tokenSymbol": "USDT",
  "bindKey": "USER_90001",
  "label": "VIP User 90001",
  "notifyUrl": "https://merchant.example.com/api/uugate/member-topup-notify"
}
~~~

字段:

- bindKey: 商户侧唯一绑定键,最长 128。
- label: 备注,选填。
- notifyUrl: 专属地址后续自动生成充值订单时优先使用的回调地址,选填。

响应重点字段:

- bindingId、chainCode、tokenSymbol、bindKey、addressId、address、addressType=exclusive、notifyUrl、status。
- 同一 bindKey 重复创建时应按 get-or-create 思路处理,优先复用平台返回的已有地址。

### 6.4 查询独享钱包详情

GET /openapi/payin/exclusive-bindings/{bindKey}?chainCode=TRON&tokenSymbol=USDT&recentLimit=10

query 参数:

- chainCode: 必填,TRON / BSC / SOL。
- tokenSymbol: 必填,USDT / USDC。
- recentLimit: 选填,1 到 20,默认 10。

响应重点字段:

- bindingId、chainCode、tokenSymbol、bindKey、label、addressId、address、addressType、notifyUrl、status。
- stats: totalOrders、completedOrders、totalPaidAmount、completedPaidAmount、lastOrderAt、lastCompletedAt。
- recentOrders: 最近自动充值订单列表。
- recentTransactions: 最近链上入账交易列表。

### 6.5 创建单笔代付订单

公开 API 只提供单笔付款;批量付款仅在商户后台使用。

POST /openapi/payout/orders

请求 body:

~~~json
{
  "chainCode": "TRON",
  "tokenSymbol": "USDT",
  "fromAddress": "TNjBrNq2a9FK2vwu4W9C1QdLKSZ42Yx5YY",
  "toAddress": "TS7b7iD8G2PaPqK1TqSmLJ9nrrYH4oKX1S",
  "amount": "35.50",
  "deleteSourceAfterSuccess": false,
  "merchantOrderNo": "PO202604150001",
  "notifyUrl": "https://merchant.example.com/api/uugate/payout-notify"
}
~~~

字段:

- fromAddress: 指定付款地址,选填;不传时系统自动选择满足余额条件的可用地址。
- toAddress: 收款地址,必须与 chainCode 地址格式匹配,不能是商户自己的钱包地址。
- amount: 代付金额字符串。来源可用余额足够付款金额即可,不强制预留余额,付款金额不会自动扣减。
- deleteSourceAfterSuccess: 可选 JSON boolean,默认 false;不接受字符串。传 true 时在链上确认成功后软删除来源地址;失败不删除。历史订单、账目及密钥引用保留。有其他未完成转账时不能选择删除,同一商户订单号重试必须保持该参数一致。
- merchantOrderNo: 商户侧订单号,最长 128。
- notifyUrl: 本订单回调地址,必填。

响应重点字段:

- orderNo、merchantOrderNo、fromAddress、toAddress、amount、txHash、status、submittedAt、broadcastedAt、confirmedAt、failedAt、createdAt。
- 创建成功后系统会尝试自动提交链上;status 可能是 submitted、broadcasted、confirmed 或 failed。
- 创建成功不等于链上成功,业务终态以 confirmed / failed 或查询结果为准。

### 6.6 查询单笔代付订单

GET /openapi/payout/orders/{orderNo}

响应字段同创建单笔代付订单。业务最终成功以 status=confirmed 为准,失败以 status=failed 为准。

### 6.7 提交单笔代付订单

POST /openapi/payout/orders/{orderNo}/submit

请求 body:

~~~json
{
  "remark": "manual submit to signer gateway"
}
~~~

仅当订单仍为 submitted 时可提交。创建接口已经会自动尝试提交,所以通常只在自动提交失败后用于重试。

## 7. 回调验签

平台向 notifyUrl 或商户默认 callbackUrl 发起 POST application/json 回调。

回调请求头:

| Header | 说明 |
| --- | --- |
| x-callback-timestamp | 毫秒时间戳 |
| x-callback-nonce | 回调随机串 |
| x-callback-signature | HMAC-SHA256(apiKey, timestamp + "." + nonce + "." + rawBody) |
| x-callback-signature-algorithm | HMAC-SHA256 |
| x-callback-signature-version | v1 |

验签规则:

1. 必须使用 HTTP 原始请求体 rawBody,不要用重新序列化后的 JSON 字符串代替。
2. 用 apiKey 做 HMAC-SHA256。
3. digest 输出 lowercase hex 后和 x-callback-signature 做常量时间比较。
4. 验签通过后再更新业务订单。
5. 返回任意 HTTP 2xx 表示成功并立即停止;非 2xx 或超时会在 1 分钟后进行第 2 次投递,再失败则在 5 分钟后进行第 3 次投递,总计最多 3 次。

Node.js 回调验签参考:

~~~js
import crypto from 'node:crypto';

export function verifyUUGateCallback({ apiKey, timestamp, nonce, signature, rawBody }) {
  const expected = crypto
    .createHmac('sha256', String(apiKey).trim())
    .update(String(timestamp).trim() + '.' + String(nonce).trim() + '.' + rawBody)
    .digest('hex');

  const actual = String(signature || '').trim().toLowerCase();
  if (!actual || actual.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(actual, 'utf8'), Buffer.from(expected, 'utf8'));
}
~~~

回调 body 会包含 merchantUid 和业务字段。常见收款回调字段:

~~~json
{
  "merchantUid": "880001",
  "orderNo": "PI1776193200123ABCD1234",
  "merchantOrderNo": "M202604150001",
  "mode": "shared",
  "businessType": "fixed",
  "bindKey": "USER_90001",
  "expectedAmount": "100.000000",
  "paidAmount": "100.000000",
  "chainCode": "TRON",
  "tokenSymbol": "USDT",
  "txHash": "f7f17891f52c35d0c93170f0d12bb347ac16ab34853b4bc8dcf7fd0a8c9aa321",
  "fromAddress": "TS7b7iD8G2PaPqK1TqSmLJ9nrrYH4oKX1S",
  "status": "completed"
}
~~~

收款状态处理:

- detected / confirming 是过程状态。
- completed 是最终成功。
- risk_settled 表示有风险结算,按商户风控策略处理。
- 回调可能重复投递,必须按 orderNo 或 merchantOrderNo 幂等更新。

常见代付回调字段:

~~~json
{
  "merchantUid": "880001",
  "orderNo": "PO1776193200A1B2C3D4",
  "merchantOrderNo": "PO202604150001",
  "chainCode": "TRON",
  "tokenSymbol": "USDT",
  "fromAddress": "TNjBrNq2a9FK2vwu4W9C1QdLKSZ42Yx5YY",
  "toAddress": "TS7b7iD8G2PaPqK1TqSmLJ9nrrYH4oKX1S",
  "txHash": "b3d4fb0cb11eb86bb906d1e0ee3b84e17f40d5441d1e4c651ea46920a48a9a01",
  "amount": "35.500000",
  "status": "confirmed"
}
~~~

代付状态处理:

- submitted / broadcasted 是过程状态。
- confirmed 是最终成功。
- failed 是最终失败,failedStage 可能说明失败阶段。

## 8. 常见错误码

| code | 说明 |
| --- | --- |
| 10001 | 请求参数不合法 |
| 20005 | API Key 无效 |
| 20011 | 请求签名无效 |
| 20012 | 签名时间戳超出允许窗口 |
| 20016 | nonce 已被使用,请勿重放请求 |
| 31001 | 链不支持 |
| 31002 | 代币不支持 |
| 31003 | 链和代币不匹配 |
| 40002 | 独享钱包绑定不存在 |
| 40105 | 共享收款金额已被占用 |
| 40108 | 收款订单不存在 |
| 51002 | 商户运营状态、操作开关、限额或费用余额导致操作受限 |
| 51004 | 费用余额不足 |
| 53001 | 来源地址不存在 |
| 53002 | 来源地址不允许用于当前操作 |
| 53003 | 币种可用余额不足 |
| 53005 | 来源地址对应链和币种余额不足 |
| 54001 | 代付订单不存在 |
| 54003 | 不能付款给商户自己的地址 |

## 9. 实现要求和检查清单

请 AI 生成代码时遵守:

1. 使用 Merchant UID + API Key 完成商户鉴权。
2. 所有金额都用字符串和 Decimal,不用 float。
3. 每个请求都生成新的 timestamp 和 nonce。
4. 签名 path 永远不带域名和 query。
5. GET 请求 body 参与签名时为空字符串。
6. POST 请求签名使用规范化 body,并发送同一个 body。
7. query 参数参与签名前要排序和 URL encode。
8. 回调必须先验签,再做业务处理。
9. 回调处理必须幂等:同一个 orderNo、txHash 或 status 重复到达不能重复加款或重复扣款。
10. 收款最终成功只认 completed;代付最终成功只认 confirmed。
11. 对 20011、20012、20016 不要盲目重试,应重新生成签名请求。
12. 对网络错误或 5xx 可以做有限重试,但每次重试必须重新签名。
13. 日志里不要打印完整 API Key 。

## 10. 推荐 SDK 方法命名

建议生成以下方法:

~~~text
createPayinOrder(payload)
getPayinOrder(orderNo)
createExclusiveBinding(payload)
getExclusiveBinding(bindKey, query)
createPayoutOrder(payload)
getPayoutOrder(orderNo)
submitPayoutOrder(orderNo, payload)
verifyCallback(headers, rawBody)
~~~

完成后至少写 3 个测试:

1. 签名 payload 构造测试:字段顺序、query 排序、body key 排序。
2. 回调验签测试:rawBody 不变时成功,body 被重新格式化时失败。
3. 业务幂等测试:同一个 completed 或 confirmed 回调重复投递不会重复更新余额或订单。