# 接入指南

> docId：`XD-OP-02` ｜ version：`1.0.0` ｜ status：`PUBLISHED`

本篇讲「怎么接得对」：请求怎么签、幂等怎么做、状态怎么判、收银与交付的边界在哪。字段清单见 [API 参考](./03-api-reference.md)。

## 1. 域名与 Base URL

- 商家 API 的 Base URL **只在登录后的门户「应用凭证」页下发**（同一页还有 `appId`、`kid` 与指纹）；文档与示例一律写作 `<BASE_URL>`，**官方资料里不印 host**，也不要在公开渠道互相转抄。取不到时请联系对接人。
- **不要在服务端代码里硬编码 host/IP/端口**，也不要在前端拼接——把 Base URL 当配置项管理，环境切换时只改配置。
- 平台收银页地址**永远使用下发响应里的 `payUrl` 原值**，商家不得自行拼接收银地址。

## 2. 域名归属验证（可选，建议完成）

**平台不强制核验域名归属**：申报域名由你自己填写，不做归属验证也能开通应用并收款。**建议顺手完成一次核验**——核验通过后该域名在平台侧标记为「已验证」，域名被他人冒用申报时归属更明确，处理争议时更有依据；核验也是免费的，两个形态**二选一**，整条流程在门户完成。

| 形态 | 你需要做什么 | 适用情形 |
| --- | --- | --- |
| DNS TXT 记录（推荐） | 在域名的解析服务商处添加一条 TXT 记录 | 你能改域名解析（绝大多数情况） |
| 网站回源文件 | 在网站根目录放一个验证文件 | 改不了解析、但能改站点内容 |

### 2.1 DNS TXT 记录形态

门户签发的指引里会给出两个值：**记录名**与**记录值**。

- **记录名**（完整）：`_xiaodou-open-api.<你申报的域名>`，例如申报 `pay.example.com` 时是 `_xiaodou-open-api.pay.example.com`。
- **记录值**：门户上那一串验证码（`xdch_` 开头），逐字符复制。

> **注意解析商的「主机记录」栏**：国内多数解析服务商（DNSPod 等）要求这一栏**只填主域名之前的部分**，不是完整记录名。同一例子（申报 `pay.example.com`）在这些服务商里应填的是 `_xiaodou-open-api`；若你把完整记录名填进去，实际生成的记录会变成 `_xiaodou-open-api.pay.example.com.pay.example.com`，验证永远不通过。门户指引里会按你申报的域名**直接给出这一栏该填的值**，照抄即可。

### 2.2 网站回源文件形态

- 在站点根目录放置验证文件，使门户给出的地址（形如 `https://<你申报的域名>/.well-known/xiaodou-open-api-challenge`）返回的内容**包含验证码**即可通过（判据是「页面内容含该验证码」；**原样只返回验证码**是最稳妥的做法，可避免框架附加内容引入变量）。
- 该地址必须满足三条：可**公网**访问、走 **HTTPS**、**不跳转**（不能 302 到别处，也不能先跳登录页）。

### 2.3 生效时间与常见失败

| 现象 | 含义 | 你要做的 |
| --- | --- | --- |
| 提示「暂未读取到验证记录」（**唯一可重试的失败**） | 平台按当前 DNS/站点状态还没读到你的配置 | 解析/部署生效通常需要几分钟，稍后**重新点一次验证**即可（同一条验证最多 5 次，每次只统计「没读到」） |
| 提示「已读取到配置内容，但与本页验证码不一致」 | 记录填了，但值不对（常见：复制时带了空格、换行，或填了上一次的验证码） | **该条验证就此作废（终态）**：重新签发 → 拿到**新**验证码 → 覆盖记录值 → 再验证。直接点「验证」只会拿到 `409` |
| 提示「目标域名解析到非公网地址」 | 域名指向了内网/保留地址 | **同属终态**：先修好解析，再**重新签发**验证（平台不会去访问非公网目标） |
| 提示「多次验证未通过，该验证已关闭」 | 尝试次数用尽（安全约束） | 在门户**重新签发**一条新的验证 |
| 提示「该验证已过期」 | 超出门户显示的有效期（24 小时） | 重新签发 |

- 上表的提示文案是**大意**——按失败原因分类，措辞以门户当场显示的为准，不要按字面去比对。
- **只有「暂未读取到验证记录」能原地重试**；其余失败都是终态，必须重新签发（且新验证码要重新覆盖到 DNS 记录或回源文件）。每次失败门户都会在该条记录旁边给出**具体原因**，按上表处理。
- **申请时可以先不填域名**：开通后在卖家门户的「应用配置变更」里补上你自己的域名（那里至少要填一条，但同样**不需要**核验）；也可以申请时就填，是否核验都不影响开通与生效（域名归属不是判据）。
- **单次申报最多 10 个域名**（超出会与「格式非法」共用同一句 `400` 文案，请先自查数量）。
- 域名在本平台**不是运行时访问控制**：平台不会因为某个域名未验证而拒绝你发起的支付或回调。

## 3. 鉴权总览

| 方向 | 机制 | 关键材料 |
| --- | --- | --- |
| 商家 → 平台 | `XD-Signature-v1` 六头签名（RSA-SHA256） | 你的 RSA2 私钥、`appId`、`kid` |
| 平台 → 商家（响应） | `XD-Response-v1` 响应头签名 | 平台公钥（固定文档页与门户双渠道发布） |
| 平台 → 商家（事件） | `XD-Webhook-v1` 投递头签名 | 平台公钥 |

- 六头成组提交，缺一个即 `400`；验签失败统一返回中性 `401`（**code** 一致，但 `message` 会点出时间窗越界这类原因，排障时先看它）；**nonce 复用是 `409`**，不是 401。
- 时间窗与 nonce 防重放是**强校验**：服务器时间必须与标准时间对齐（建议常开 NTP）。
- 响应签名与事件签名的规范串**与请求签名不同**，不要复用同一段拼串逻辑 —— 见 [安全红线](./09-security-redlines.md) 与 [webhook 验签](./05-webhook-verification.md)。

### 3.1 平台公钥与指纹（公示）

平台用**同一把 RSA2 密钥**对两类出站物签名：同步响应（`XD-Response-v1`）与事件投递（`XD-Webhook-v1`）。**公钥 PEM 正文在本页公示**；门户「应用凭证」页公示同一把钥的 **`kid` 与指纹**（与下方 `kid`/指纹逐字符一致，两处同源、可相互核对）。

| 项 | 值 |
| --- | --- |
| 密钥标识（`kid`） | `xdpk-2026-09` |
| 指纹（规范化 PEM 字节的 SHA-256，十六进制小写） | `c828f7e47c2eef5a958aa32009f5151bda43b585c979b40bf7c795b0b48d35f4` |

```text
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw9Z3B6q8jsaNu3KmswGF
Kl/8Rc3dWV2yFCq61VIbuPVEQPI6S1S2gbFeWpXz6KzrdAJxTDyMqfSDnSStoesn
t6FCMjgc0SBflQOjxsM2ahNDJO4JmJGj7Mca/nFCMrvVTy4tLxFkxMUxOcXhn4LU
QPCvpoIc7Y10xHBrqVizrHwMWNPxteWsOG4ZQUbXDrYddsceTcZ3a46cBhBUyqas
lNkWwzjaw9KmrdcXWOjDT3YCyMAXkcjLBh/7gr+sb11P4PUCjeQl2F6dDASIjZmO
EXFRSQJtXSRlna+WHWgVSFtrWBdyq+Gf+5pwjXMk1PrSWIG56QKhCXKCylNz8Kwn
TQIDAQAB
-----END PUBLIC KEY-----
```

用法要点：

- **只从本页或门户取公钥**；不要在未验签的响应或事件里取公钥（那等于让攻击者自己递钥匙）。
- 验签时按**响应/事件头里的 `key-id`** 选中对应公钥；`kid` 与上面不一致时先到本页确认是否有新版本。
- 指纹用于**本地自检**：算之前先按下面的规则**规范化**，再取 SHA-256（十六进制小写），与上表逐字符比对，可确认没抄错、没被中间环节替换：
  1. 把 PEM 文本里的 CRLF（`\r\n`）与孤立 CR（`\r`）统一成 LF（`\n`）；
  2. **去掉首尾空白**（含末尾换行）；
  3. 对**这段 UTF-8 文本字节**求 SHA-256。
  照抄上面的 PEM 块时，**不要把文件末尾的换行一起算进去**——那会得到另一个值，让你误以为「被换了钥匙」。
- **轮换**：平台更换签名密钥时，会同时发布**新的 `kid` 与新公钥/指纹**；新旧并存期内，请同时保留两把公钥并按 `kid` 选择。参考实现见 [`examples/`](../examples/)（**在公开仓与「下载接入包」内；不在「下载文档包」内**）。

## 4. 幂等：三层，别混用

| 层 | 键 | 语义 |
| --- | --- | --- |
| 请求级 | `X-XD-Request-Id` | 同 `(appId, 方法, 路径, requestId)` 绑定一次请求体摘要；**重放原样返回首次响应**；同 `requestId` 换请求体 → `409` 冲突 |
| 业务级 | `outTradeNo` / `outRefundNo` | 同号同内容 → 幂等重放（`200` + `replayed=true`）；同号异内容 → `409` 冲突 |
| 状态机 | 订单/退款状态 | 非法迁移或终态冲突 → `409` |

实践建议：

- **重试必须复用同一个 `requestId`**（网络超时、5xx 重试场景）。换 `requestId` 重发等于新请求，可能造成重复下单。注意**请求体也要逐字节相同**：同一个 `requestId` 换了请求体（哪怕只是「不传 body」变成传 `{}`）会拿到 `409 OPEN_API_REQUEST_ID_CONFLICT`。
- 「同号同内容」里的**内容 = 业务摘要**：`channel`、`tradeScope`、`amountFen`、`currency`、`subject`、`description`、`returnUrl`、`attach`（完整清单见 [API 参考 §2.7](./03-api-reference.md)）——改动其中任何一个再重试，都是「异内容」→ `409`。`payerClientIp` 不在摘要内，重试时按实际情况传不会冲突。
- `outTradeNo` 由你的系统生成，**不要用「时间戳+随机数」当订单号后重试**——重试要用同一个号。
- 遇到 `409` 先查单，**不要盲目换号重试**。

## 5. 状态与终态可信边界

订单状态：`CREATED` → `PAYING` → `SUCCEEDED`，或收敛到 `CLOSED` / `FAILED`；另有 `UNKNOWN`（状态不确定，**不判死**）。退款状态：`REQUESTED` → `PENDING` → `SUCCEEDED` / `FAILED`，另有 `UNKNOWN`。

**`CLOSED` / `FAILED` 不是终点（最容易写错的一条）**：支付机构随后确认到账时，平台会把订单**提升为 `SUCCEEDED`** 并推送 `PAYMENT_SUCCEEDED`——关单与到账竞态一律以支付机构为准。所以这两个状态**不能建成吸收态**，收到 `PAYMENT_SUCCEEDED` 就按到账处理。

**可信边界（最容易踩的一条）**：

- ❌ 收银页被打开、收银页回跳 `returnUrl`、用户点了「完成」——**都不是**支付终态。
- ✅ 终态只认：查单接口返回的订单状态，或经验签的事件通知。

**`UNKNOWN` 的处理纪律**：`UNKNOWN` 表示「平台与支付机构之间的结果暂时不可知」，**不是失败**。遇到 `UNKNOWN`：不要重新下单、不要自行判失败、不要给买家提示「支付失败」；按查单/事件收敛即可（补偿策略见 [退款与对账](./08-refund-and-reconciliation.md)）。

## 6. 通道与交易范围申报

- `channel` 是**你要走哪条收款通道**（枚举见 API 参考）；通道在**下单时冻结**，订单创建后不可改。
- `tradeScope` 是**交易形态申报**：双申报的应用**必填**，单申报应用可省略（省略即取该申报形态）。申报与实际不符会被拒（`422`）。
- 通道存在但链路未放行时返回 `422 OPEN_API_CHANNEL_UNAVAILABLE`——这是**可预期的正常拒绝**，不要按故障处理，也不要重试刷量。
- **放行粒度是「通道 × 交易形态」**：**微信 JSAPI 已开放**（仅微信内打开收银页可调起；微信外手机浏览器与商家 App 内没有直接拉起形态）；其他通道或交易形态可能尚未放行。接入前请与对接人确认**你要用的那个组合**是否已放行；平台侧**没有自助查询面**，也不要靠试探下单去确认。

## 7. 收银与交付的边界

- 平台负责：**收款、支付事实、状态与事件**。
- 商家负责：**交付本身**（卡密发放、服务开通、发货、开票、售后）。
- 收银页在买家点「**返回商家**」时把浏览器 `303` 跳回你下单时冻结的 `returnUrl`——**它不是自动跳转**：买家不点、刷新过页面时都不会回跳；按钮由收银页本次会话**首次观察到支付成功**时提供，**扫码支付（PC 出码 → 手机付款）不受此限**（只要收银页保持打开并轮询到支付成功，按钮照常提供并可回跳）。而且**这是一次导航，不是终态通知**，你的落地页必须自己再查一次单或等事件。
- **微信内（JSAPI）支付完成后**微信会接管完成页：买家从**微信支付记录中的「商家小票」**点「返回商家」回到同一个 `returnUrl`（平台侧配置，无需商家开通；同样只是导航，不是终态）。
- 不要把交付内容（卡密、下载链接、账号密码）塞进 `returnUrl` 的 query 里：它会被浏览器历史、日志、Referer 一路带走。**交付只在你的服务端用订单状态驱动**。

## 8. 商家自有 App / 宿主内的硬约束

如果你的买家在**你自己的 App（WebView/内嵌浏览器）**里打开收银链：

- **必须**用系统浏览器打开（等价「跳转外部浏览器」语义），**禁止**在自有 App 的 WebView 内嵌打开收银页。
- 原因：内嵌容器的 UA 与浏览器特性会失真，平台侧按终端分派收款形态的判断会错，买家会落到一个拉不起来的页面。
- App 内的微信支付仍然走[推荐路径](./06-cashier-integration.md)（把链接送进微信内打开），平台侧没有「App 内直接拉起微信支付」的形态。

## 9. 时间与金额

- 金额：**整数分**，字段后缀 `Fen`；仅人民币。
- 时间：接口返回的时间字段是 ISO-8601 UTC；`payUrlExpireHint` / 事件 `occurredAt` 是 UTC epoch **秒**。
- 判断「是否过期」以服务端返回的 `expireAt` 为准，不要在客户端用本地时间自算窗口。

## 10. 联调支持边界（重要）

平台**暂不提供**沙箱、测试应用与回调模拟器。可用的替代路径：

1. **本地 demo**（[`demo/h5-cashier/`](../demo/h5-cashier/)，**只在公开仓提供；不在门户的两个下载包内**）：loopback + mock，全链跑通，不连生产支付机构。
2. **上线检查清单**（[`11-go-live-checklist.md`](./11-go-live-checklist.md)）：逐条自检，上线前必须全过。
3. **首次生产首单人工陪跑**：通过官网反馈渠道提交，平台侧协助（**不承诺 SLA**）。

> 本地 demo 与任何「mock 通过」都**不能**替代生产首单验证：mock 只证明你的签名与解析没问题，不证明资金链通。
