# 统一支付与收银台接入

图奕统一认证只处理渠道接入、支付状态、退款、回调和对账。产品仍是商品、价格、业务订单和权益的唯一所有者；`amount_fen` 必须由产品服务端根据自己的业务订单生成，不能接受浏览器传入金额后原样转发。

## 管理员先验证支付账户

在「支付中心 → 支付账户」保存证书后，点击对应账户右侧的「0.01 元测试」。无需先绑定产品，也不需要先打开正式收款。

1. 完成最近十分钟管理员二次验证，输入原因、账户代码，并勾选真实商户测试确认。
2. 平台只创建固定 **1 分 CNY** 的测试订单。微信展示 Native 二维码；支付宝提供电脑网站支付链接。必须由管理员手动核对商户与金额后付款。
3. 详情页分别显示支付状态、成功回调验签与金额核对、退款状态。同步回跳和主动查询不会将“回调验收”标为通过。
4. 付款后点击「原路退款 0.01 元」，再次确认测试 ID 和原因；未知结果只查询，禁止重复退款。渠道明确拒绝后可排除故障并手动重试，使用原退款编号。
5. 查看有效支付回调与退款成功结果，再单独验证产品下单、Webhook 和权益交付。测试不会自动开启任何账户或事故开关。

此入口在 `disabled` 模式下仍允许**管理员显式确认的真实测试**，且 staging 下支付宝也使用正式商户网关，不是沙箱。它不开放给产品服务端或普通用户，不产生产品业务订单、业务权益或产品 Webhook。单账户同时只允许一笔未完成测试（已付款须退款成功），每分钟最多一次、滚动 24 小时最多二十次。表单重复提交返回原记录；请求超时、连接中断或结果保存失败后，必须通过「查看最近一次测试」核对，不会再次调用下单。

测试记录在 `payment_account_tests`，金额由数据库约束为 1 分；管理员操作与审计同事务提交。测试支付与退款使用独立回调 `/callbacks/payments/{channel}/{account_code}/account-test`，不带查询参数，并核验已存在的测试订单、账户、签名、金额与币种。普通产品订单仍使用原回调地址，不会走测试结算路径。历史版本带 `account_test=1` 的通知继续接收以处理在途订单，但新订单不再生成这种地址。原始回调继续加密保存并遵循原有留存策略。

若页面显示「下单失败」，说明未取得可展示的二维码；先排除页面给出的错误，再点击「修复后新建测试」。失败记录保留供审计，不会自动重新下单。「结果未知」则不同：必须先查已有订单，不能新建或重复付款。微信要求 `notify_url` 为不带参数的完整 HTTPS 回调地址，详见[官方填写规则](https://pay.wechatpay.cn/doc/v3/merchant/4012075420)。

后台每分钟查询未知结果并尝试关闭过期的未付订单，不自动付款、退款或重新下单。退款失败或长期未知时保留记录，需核对渠道商户后台。测试过程中不要轮换账户证书；若确需轮换，先处理全部在途订单与回调。

账户测试记录不混入产品交易统计；本次只验证账户付款、成功回调和退款，不代表产品 Webhook、账单文件对账或正式上线门禁通过。真实测试产生的流水仍需在商户账单中核对。

## 创建支付会话

以下所有接口调用方都是产品服务端，使用[服务 Token](PLATFORM_API.md)。创建/关闭需要 `payments:create`，支付和退款查询需要 `payments:read`，退款申请需要 `refunds:create`。不能把用户 OIDC Token 当服务 Token，也不能让浏览器直接持有 Service Secret。

前置准备：产品已启用，登记精确返回地址，绑定通过验收的渠道账户，启用相应支付能力；产品自己先建立待付款业务订单。

```http
POST /api/v1/payment-sessions
Authorization: Bearer SERVICE_TOKEN
Idempotency-Key: wyc-order-20260902-10086
Content-Type: application/json

{
  "external_order_id": "WYC-20260902-10086",
  "amount_fen": 9900,
  "subject": "网云穿专业版一年",
  "return_url": "https://www.example.com/pay/result",
  "user_sub": "0199..."
}
```

`user_sub` 和 `external_user_id` 必须二选一。迁移期使用 `external_user_id` 时，该 ID 必须已有当前产品映射。`return_url` 必须与产品后台登记值完全一致，不支持通配符。

| 字段 | 类型 | 限制 |
| --- | --- | --- |
| external_order_id | string | 非空、最多 64 字节；同产品订单唯一 |
| amount_fen | int64 | 1–10,000,000,000 整数分；不能传小数或金额字符串 |
| subject | string | 非空，最多 128 字符；不放手机号等敏感内容 |
| return_url | string | 精确登记的 HTTPS 地址；不能传任意客户端 URL |
| user_sub | string | 已认证平台用户 UUID，与 external_user_id 二选一 |
| external_user_id | string | 本产品已映射的旧用户 ID，保留前导零 |

不接收自定义 `currency` 字段，当前固定 CNY。产品仍须核对请求用户拥有这笔业务订单，不能只因为传来的 sub 语法正确就付款给其他账号。

创建返回 HTTP 201，例如：

```json
{
  "data": {
    "payment_id": "00000000-0000-4000-8000-000000000020",
    "external_order_id": "WYC-20260902-10086",
    "amount_fen": 9900,
    "currency": "CNY",
    "subject": "网云穿专业版一年",
    "status": "pending",
    "paid_amount_fen": 0,
    "expires_at": "2026-09-03T00:15:00Z",
    "checkout_url": "https://platform.too1.net/pay/ONE_TIME_PUBLIC_TOKEN"
  }
}
```

示例省略部分时间/用户字段；必须保存 `payment_id` 与业务订单的关系。收银台 URL 是持有即访问的敏感链接，不记到普通访问日志、统计平台或第三方 Referer。当前没有按 external_order_id 的公共查询接口；丢失 payment_id 且原幂等回执不可领取时，应由运维核查，不能新建相同业务订单。

成功返回 `payment_id`、状态和一个 256 位随机收银台 URL。URL 默认 15 分钟有效且无需再次登录。桌面端提供微信 Native 二维码和支付宝电脑网站支付；移动端使用微信 H5 或支付宝手机网站支付。

同步回跳只用于用户体验，绝不作为支付成功依据。产品必须以签名 Webhook `payment.succeeded` 为主要依据，并可用查询接口补偿：

```http
GET /api/v1/payments/{payment_id}
Authorization: Bearer SERVICE_TOKEN
```

支付状态固定为 `pending`、`paid`、`closed`、`expired`、`error`。

查询响应仍是 `{"data":{...支付对象...}}`，通常不再返回 checkout_url；退款不会把支付对象的 paid 改成未支付，退款状态单独查询。浏览器倒计时结束仅是展示过期，不是资金事实，以平台服务端确认的状态为准。用户返回后由产品服务端查平台状态，前端 `?success=1` 等字段完全不能作为交付依据。

### 幂等及异常恢复

每次业务操作使用独立 Key，例如 `pay:WYC-001`、`close:PAYMENT_UUID`、`refund:WYC-R-001`；同一 Client 的 Key 跨路由共享。原始 JSON 序列化字节需保持一致，详见[服务 API 幂等规则](PLATFORM_API.md)。

`duplicate_external_order` 表示业务单已存在，`idempotency_in_progress` 表示请求未完成，均不能理解为可以重新下单。发生网络超时或 `idempotency_storage_error` 时，订单可能已经创建/到达渠道，先查询已保存 ID 或领取原回执；无法判断则人工核查，不自动换新 Key。

## 关闭订单

```http
POST /api/v1/payments/{payment_id}/close
Authorization: Bearer SERVICE_TOKEN
Idempotency-Key: close-WYC-20260902-10086
Content-Type: application/json

{}
```

平台只有在渠道明确确认关闭后才将本地订单改为 `closed`。渠道超时、网络中断或错误时保持原状态，并返回安全错误；查询 Worker 会继续核对未知状态。

关闭成功返回 HTTP 200 与 `data` 支付对象；渠道未知可能返回 HTTP 202 **但正文仍是 error 对象**，错误码 `channel_result_unknown`。必须解析正文与状态，不要仅以“HTTP 2xx”标记关闭成功。

## 退款

```http
POST /api/v1/payments/{payment_id}/refunds
Authorization: Bearer SERVICE_TOKEN
Idempotency-Key: refund-WYC-20260902-1
Content-Type: application/json

{
  "external_refund_id": "WYC-R-20260902-1",
  "amount_fen": 500,
  "reason": "用户重复购买退款"
}
```

- 允许部分退款，累计申请金额不得超过实付金额。
- 默认退款窗口为支付成功后 90 天。
- 单笔不超过 100 元且产品当天自动退款累计不超过 500 元时，在事故开关允许的前提下自动发起。
- 超过任一阈值时创建 `pending_approval`，必须由最近 10 分钟完成短信二次验证的管理员输入退款 ID 和原因后批准或拒绝。
- 渠道结果未知时保持 `processing` 并由 Worker 查询，禁止盲目重复申请。

external_refund_id 最多 64 字节，reason 为 4–200 字符，amount_fen 为正整数。审批中/处理中/成功的退款都占用可退款额度。产品应先保存自己的业务退款单、生成唯一号，再发请求；审批未通过不能向用户展示“已到账”。申请响应 `data` 为退款对象，包含 refund_id、payment_id、external_refund_id、amount_fen、status 以及可选渠道信息；以该 status 继续查询。

查询退款：

```http
GET /api/v1/refunds/{refund_id}
Authorization: Bearer SERVICE_TOKEN
```

退款状态固定为 `pending_approval`、`processing`、`succeeded`、`failed`。

平台事件的[验签与可靠消费](WEBHOOKS.md)是必做项。产品退款成功后是否撤销套餐、缩短有效期或保留已用权益，由产品自己的业务规则决定，平台不会替产品改权益。

## 渠道回调

- 微信：`https://platform.too1.net/callbacks/payments/wechat/{account_code}`
- 支付宝：`https://platform.too1.net/callbacks/payments/alipay/{account_code}`

平台使用官方微信支付 Go SDK完成签名验证和 AES-GCM 解密；支付宝适配器执行 RSA2 验签。验签后还会核对 AppID、商户号、平台订单号、渠道订单号、金额和币种。通知 ID 唯一约束、数据库行锁、状态更新和 Outbox 事件保证重复通知安全。

若同一平台订单已支付后又收到不同渠道订单号的有效付款通知，平台不会把第二笔资金静默当成普通重试：它会创建 `duplicate_payment_suspected` 资金异常、在后台置顶，并投递 `payment.duplicate_suspected` 事件，等待人工核对和退款处置。

已验签的原始通知以 AES-GCM 加密保存 180 天。支付凭据采用证书模式并由超管后台上传：微信包含商户 API 证书及配对私钥、微信支付平台证书和 APIv3 Key；支付宝包含应用公钥证书及配对应用私钥、支付宝根证书和支付宝公钥证书。平台保存前校验证书格式、有效期和公私钥配对。支付数据密钥通过 HKDF-SHA256 从现有 `SSO_TOKEN_HASH_SECRET` 进行用途隔离派生，不需要额外文件；后台、日志和审计均不回显凭据原文。

证书轮换时使用同一账户代码重新上传完整材料。账户的 AppID、商户号和渠道创建后不可原地修改，更换商户主体必须创建新的账户代码，避免历史订单与延迟回调被错误路由。新证书版本与账户元数据、审计在同一事务中生效，旧版本标记为 retired；运行中的请求可完成，后续请求自动使用新版本，无需重启平台。`SSO_TOKEN_HASH_SECRET` 已是平台必须长期保留的核心 Secret；上传支付证书后不可直接更换，如确需轮换必须先执行支付密文迁移。

## 对账与异常

后台 Worker 会主动查询超时未知订单、查询处理中的退款、关闭过期订单。当前版本的每日自动对账采用渠道鉴权的逐单查询，差异只进入后台，不自动改写资金状态。

微信/支付宝账单文件下载与逐笔文件对账需要真实商户凭据、账单权限和实际账单样本完成联调验收；在完成该验收前，后台会明确按“逐单渠道核对”展示，不能将其视为账单文件已对账。生产上线仍必须在渠道商户后台核对首批交易和退款。

## 事故开关

暂停新支付、微信支付、支付宝支付或自动退款不会关闭以下能力：

- 已有支付/退款回调接收；
- 订单与退款查询 Worker；
- 过期订单关闭与对账；
- 管理员登录和事故处置。

一旦平台产生真实交易，回滚只能暂停新支付并保留当前版本继续处理回调与查询，不能直接换回不认识新交易表的旧二进制。

## 运维验收命令

```bash
# 纯内存模拟，不写数据库、不调用真实渠道
./ssoctl platform payment simulate --channel wechat
./ssoctl platform payment simulate --channel alipay

# 检查配置、事故开关、账户和产品绑定
./ssoctl platform payment check

# 执行昨日逐单渠道核对；真实渠道必须显式双确认
./ssoctl platform payment reconcile --live --confirm RUN-RECONCILIATION
```

模拟命令覆盖渠道创建、查询、关闭和退款接口，但不等同于 HTTP API/数据库闭环。完整闭环由集成测试和 staging 收银台验收承担。真实扣款必须通过平台中已持久化的支付会话进行，CLI 不提供绕过平台订单直接向渠道扣款的命令。
