# 多主体统一支付接入

账号服务提供微信普通商户模式的统一支付中心。产品后端调用中心 API，浏览器进入收银台；微信将资金结算给支付单对应的商户，不经账号中心代收再转账。会员商品、价格、期限、权益交付与退款政策由产品管理。首版为逐次购买／续费，不包含签约代扣、自动续费、分账、小程序或 App 支付。

公共算力固定使用中央收款商户及指定充值场景；每个应用配置一个会员收款场景，可供多个应用共用。不同主体可使用不同支付域名，同一份 Web 代码可多域名或多实例部署。

## 接入产品选择金额后直接付款

接入时先区分产品商品支付与公共算力充值。二者使用同一个收银台，订单归属、收款场景和付款后的处理不同。

| 业务场景 | 当前支持情况 | 接入方式 |
| --- | --- | --- |
| 购买产品会员、课程等商品 | 已实现 | 产品后端创建业务订单，调用 `POST /api/payments/v1/product/orders`，浏览器跳转返回的 `checkoutUrl`；金额已确定，收银台无需再次选金额 |
| 在账号中心充值公共算力 | 已实现 | 引导至账号域名 `/checkout?recharge=1`，用户选择预设或自定义金额后付款；需要账号登录会话 |
| 在接入产品内选好公共算力充值金额，再直接付款 | 待实现 | 需要新增产品后端的公共算力充值下单接口；目前没有可供产品 Basic 认证调用的充值下单接口或对应 SDK 方法 |

### 已支持：产品商品支付

1. 用户在产品内选择商品或规格，产品后端从已校验的 OIDC 会话取得中央账号 `sub`，在服务端确定价格。
2. 产品先持久化本地订单、金额、用户归属和稳定幂等键，再使用应用 `client_id / client_secret` 调用下文的产品下单 API 或 SDK `createOrder()`。
3. 产品保存中心订单 `order.id`，将完整 `checkoutUrl` 返回给前端并跳转。链接包含该订单的访问凭证，不能丢弃 fragment，也不能在日志中记录完整链接。
4. 用户在收银台完成电脑扫码或微信内网页付款。产品以后端验签、查单及本地事务确认交付；具体通知处理见下文。

示例：用户在产品内选择 99 元月度会员，后端提交 `amountFen: "9900"`，收到 `checkoutUrl` 后直接跳转付款。不能通过浏览器传入的价格决定实际订单金额。

### 待实现：产品内预选公共算力充值金额

目标体验为：**产品内选择充值金额 → 产品后端创建公共算力充值单 → 浏览器跳转统一收银台直接付款 → 中央钱包到账 → 产品读取最新算力余额**。

这项能力尚未实现，接入方目前应使用 `/checkout?recharge=1`。不要将 `/api/payments/v1/product/orders` 的会员支付单当作算力充值单，也不要把账号中心用户接口 `POST /api/credits/v1/payments/orders` 当作产品后端接口：该用户接口依赖账号会话及匹配的 Origin，不接受产品 Basic 认证替代用户会话。

新增充值接口时需要同时确定并实现以下契约，完成联调后再补充正式路径与 SDK 示例：

- **身份和权限**：应用 Basic 认证、独立充值接入权限，以及来自产品受保护 OIDC 会话的中央用户 `sub`。
- **金额和幂等**：后端校验正整数分金额、限额与稳定业务订单号／幂等键；超时使用原单重试。不能仅靠 URL 查询参数确定付款金额。
- **收款和到账**：固定使用中央公共算力商户及其充值场景；按中央汇率入账，不能向应用会员商户充值。支付确认和钱包入账保持同一事务、重复通知不重复增加算力。
- **返回与结果**：返回中心单号及 `checkoutUrl`；返回地址精确匹配应用白名单。定义充值查单接口及需要的产品通知，产品以后端结果确认到账。
- **退款**：单独定义充值退款权限和入口，继续遵循原单比例、余额预留及处理中退款恢复规则，不能直接复用商品权益退款的处理方式。

`/checkout?recharge=1` 目前不支持使用 `amount`、`amountFen` 等 URL 参数预填金额或自动下单。商品支付已有完整示例见 `examples/payments-client`；该示例展示商品权益交付，不代表产品后端的公共算力充值能力已经可用。

## 商户与支付场景配置

账号后台「支付接入」为独立一级菜单，下设「收款主体」「微信商户」「支付场景」「应用接入」四个二级页面，分别位于 `/supermanage/payments/subjects`、`merchants`、`scenes`、`products`；旧入口 `/supermanage/payments` 跳转到收款主体。

1. 在「支付接入 → 收款主体」创建收款主体（稳定标识、主体全称）。
2. 在身份服务运行环境使用专用工具导入商户配置；凭证文件放仓库及构建目录之外，权限 600。Web 环境和后台表单均不接收商户密钥。
3. 后台创建场景：商户、AppID、HTTPS Origin、支付方式。`native` 为电脑扫码、`jsapi` 为微信内网页、`h5` 为微信外手机浏览器。新场景默认停用，参数不可变；修改 AppID、域名或方式时创建新场景。
4. 为该商户开通所需微信产品权限，完成 AppID 与商户号关联、公众号网页授权域名、JSAPI 支付授权目录、H5 支付域名及备案主体要求。将支付 Origin 同时加入身份服务和 Web 的 `PAYMENTS_CHECKOUT_ORIGINS`，配置可信入口、TLS 和代理。
5. 在隔离受控的真实商户联调流程完成各方式的小额支付及退款，再由运营者显式登记验证结果；该登记不是自动验证或权限申请。默认未验证场景不能向用户发起支付，真实联调前可通过商户自己的受控联调工具验证接口与平台配置，不能通过修改数据库伪造已验证状态。
6. 后台启用商户、已验证场景；中央商户另选“公共算力充值收款”和明确的“算力充值场景”，系统只允许一个中央商户。会员应用配置独立场景、通知地址、精确返回地址白名单及支付／退款权限。

凭证 JSON 格式（仅占位示意，不能提交真实配置）：

```json
{
  "merchantId": "school-wechat",
  "subjectId": "school-company",
  "mchid": "1900000001",
  "name": "学堂收款商户",
  "credential": {
    "privateKey": "商户RSA私钥PEM全文",
    "serial": "商户证书序列号",
    "apiV3Key": "32字节APIv3密钥",
    "publicKeys": { "PUB_KEY_ID_替换为微信公钥ID": "微信支付公钥PEM或平台证书PEM" },
    "appSecrets": { "wx替换为公众号AppID": "该公众号Secret，仅JSAPI网页授权需要" }
  }
}
```

```sh
# 仅在身份服务环境执行，使用身份服务配置和专用数据库配置身份。
node --env-file=/private/identity-config.env --import tsx scripts/payment-config.ts import /private/school-merchant.json

# 已完成真实验证后的人工登记；保持渠道关闭直至实际验收。
PAYMENTS_REAL_VERIFICATION_CONFIRMED=yes node --env-file=/private/identity-config.env --import tsx scripts/payment-config.ts attest-scene school-web

node --env-file=/private/identity-config.env --import tsx scripts/payment-config.ts reconcile school-wechat 2026-10-25
```

重复导入会追加不可变凭证版本，不能变更已有 merchantId 的商户号或主体。订单保存创建时的版本及商户／AppID／域名快照；签名使用同一商户当前有效凭证，保留历史验签公钥、API v3 密钥及公众号配置，以支持轮换后的查单、退款和延迟通知。不能删除仍有历史交易的商户或凭证版本。微信支付公钥／平台证书需由运营者通过可信渠道更新；遇到未知公钥 ID 拒绝验签，不自动信任通知携带的信息。

### 微信内网页付款与场景升级

微信内网页使用 `jsapi`，电脑扫码使用 `native`；两种方式可在同一场景中启用。支付所需公众号 AppSecret 保存在商户凭证的 `appSecrets[appid]` 中，经配置工具加密入库，不需要新增 `WECHAT_PAY_*` 环境变量。账号登录的 `WECHAT_APP_ID / WECHAT_APP_SECRET` 与支付配置独立；支付配置必须匹配场景 AppID。

使用账号域名收银台时，微信支付平台的 JSAPI 支付授权目录为 `https://login.lingtuzaowu.com/`，对应公众号的网页授权域名为 `login.lingtuzaowu.com`。实际付款入口为 `/checkout`，支付网页授权回调为 `/api/payments/v1/checkout/oauth/callback`。两项平台配置需要分别完成，已有其他网站的配置不会自动覆盖账号域名。

新增支付方式时创建新的场景 ID，再切换商户的 `rechargeSceneId` 或应用的 `sceneId`。同一商户和域名允许保留多个不可变场景；旧场景和旧订单快照保持原定义。数据库迁移 `20261026014000_payment_scene_versions` 将商户和域名唯一索引改为普通索引，不改写已有订单或余额。

## 产品后端认证与接口

服务地址：账号 Origin，路径前缀 `/api/payments/v1/product`。复用应用现有 `client_id / client_secret`，HTTP Basic 格式与 OIDC／算力接口相同；由后端调用，不使用 Cookie 或 OIDC Access Token 代替产品认证。用户 `sub` 必须是已授权本产品的中央账号十进制字符串。

| 方法 | 路径（相对前缀） | 用途 |
| --- | --- | --- |
| POST | `/orders` | 创建会员支付单，必须携带 `Idempotency-Key` |
| GET | `/orders/:id` | 读取已持久化支付状态和退款列表 |
| POST | `/orders/:id/query` | 主动查询微信并核验支付结果 |
| POST | `/orders/:id/close` | 查询并关闭未付款单；若已付款，返回已付款结果 |
| POST | `/orders/:id/refunds` | 申请退款；应用需退款权限 |
| GET | `/refunds/:id` | 查询已持久化退款状态 |
| POST | `/refunds/:id/query` | 主动核验退款状态 |

创建请求：

```json
{
  "businessOrderNo": "membership-20261025-001",
  "sub": "1",
  "amountFen": "19900",
  "description": "产品年度会员",
  "returnUrl": "https://product.example/account/orders"
}
```

响应为 `{ order, checkoutUrl }`。`order` 包含中央单号 `id`、业务单号、金额、状态、收款主体名称、支付方式、返回地址、过期时间、付款时间和产品处理状态。读取接口另带 `refunds`，每项包含中央退款单号、产品退款单号、金额及状态。退款请求为 `{ "businessRefundNo":"refund-001", "amountFen":"19900" }`。

- 金额使用正整数分字符串，币种固定 CNY，单笔上限 100,000,000 分。商品描述最长 127 UTF-8 字节。所有业务订单号／退款单号采用字母、数字、下划线或连字符，最长 100 字符；幂等键最长 128 字符，同样字符集。
- 同应用业务订单号和幂等键分别唯一；重复请求返回原单和相同收银台链接。改变金额、用户、商品描述、返回地址或业务单号时返回 `IDEMPOTENCY_CONFLICT`，不能改键绕过原单恢复。
- 请求超时不能判定未创建。创建支付时用原业务订单号和原幂等键重试；退款用原业务退款单号重试，不生成新单号。
- `returnUrl` 精确匹配预登记白名单。通知地址使用支付单创建时的应用配置快照，不接受浏览器或逐单任意地址。
- 支付单 30 分钟过期，收银台访问凭证最多可用至下单后 90 分钟。地址采用 fragment 携带随机凭证，页面读取后移除 fragment、保存在当前标签页会话中，API 使用 Bearer 传递；不能记录完整链接、认证头、微信授权 code 或 Cookie。
- 同一支付单只保留一个有效支付尝试；改变场景或支付 OpenID 前先核验并关闭旧尝试。结果未知保留旧尝试，防止重复收款。
- 支付状态为 `pending / paid / closed`；退款为 `processing / abnormal / refunded / closed`。`abnormal` 需要人工关注，仍占用可退额度；算力退款继续冻结预留，不释放。
- `delivery=pending` 表示产品尚未确认处理；`delivered` 表示产品接收接口已成功应答。中心不能替产品证明权益实际生效，因此产品只能在本地事务提交后成功应答。
- 产品暂停、取消或关闭新支付权限后，停止新下单及新调起；历史读取、关闭、通知和退款仍可使用现有有效凭证与退款权限恢复。应用密钥轮换后调用及通知验签都需使用新密钥。
- 余额充值不是会员购买：产品引导用户到账号中心充值；不要通过会员支付接口伪造算力商品，也不能将算力当作跨主体结算资金。

常见错误：`401 INVALID_CREDENTIAL`，`403 PAYMENT_FORBIDDEN / USER_NOT_BOUND / PAYMENT_DISABLED`，`404 NOT_FOUND`（含其他应用订单），`409 IDEMPOTENCY_CONFLICT / PAYMENT_BUSY / REFUND_LIMIT`，`503 PAYMENT_UNAVAILABLE`。遇到处理中或服务暂时不可用，保持原单并查询，不直接发货、不释放退款预留。

## Node.js SDK

SDK 在 `packages/payment-sdk`，Node.js 22+，无运行时第三方依赖。公开分发文件由 `npm run docs:sync` 从明确的文件白名单生成到 `apps/web/public/sdk/payment-sdk/`，正常构建也会自动同步。新分发文件需随 Web 发布后才可通过公网读取。

- [SDK 使用说明](/sdk/payment-sdk/README.md)
- [JavaScript 运行源码](/sdk/payment-sdk/index.mjs)
- [TypeScript 类型声明](/sdk/payment-sdk/index.d.mts)
- [可安装 SDK 压缩包](/sdk/payment-sdk/lingtu-payment-sdk-0.1.0.tgz)
- [SHA-256 校验清单](/sdk/payment-sdk/SHA256SUMS)

发布到账号站点后，接入方可以安装压缩包，不需要访问本项目源码仓库：

```sh
npm install https://login.lingtuzaowu.com/sdk/payment-sdk/lingtu-payment-sdk-0.1.0.tgz
```

上面是账号站点提供的包文件地址，不代表该包已发布到 npm 公共注册表。

```js
import { PaymentClient } from '@lingtu/payment-sdk';
const payments = new PaymentClient({
  origin: process.env.PAYMENTS_ORIGIN,
  clientId: process.env.OIDC_CLIENT_ID,
  clientSecret: process.env.OIDC_CLIENT_SECRET,
});
// 先在本产品数据库持久化业务订单、用户与服务端计算的价格。
const result = await payments.createOrder({
  businessOrderNo: localOrder.id,
  sub: localOrder.centralSub,
  amountFen: localOrder.amountFen,
  description: localOrder.description,
  returnUrl: 'https://product.example/account/orders',
}, localOrder.idempotencyKey);
// 持久化 result.order.id，再跳转 result.checkoutUrl。
```

微信／产品付款返回均只作为刷新界面的提示，后端以中心查单结果交付权益。

## 通知与产品交付

中心以 POST JSON 通知产品。事件包含 `eventId`、`type`、`orderId`、`businessOrderNo`；退款事件另带 `refundId`。类型包括 `payment.paid`、`refund.processing`、`refund.abnormal`、`refund.refunded`、`refund.closed`。

请求头为 `x-payments-timestamp` 和 `x-payments-signature`。签名算法：

```text
key = SHA256(client_secret) 的小写十六进制字符串
signature = HMAC-SHA256(key, timestamp + "." + 原始HTTP请求正文) 的小写十六进制字符串
```

SDK `verifyEvent(rawBody, headers)` 校验时间窗口（5 分钟）、签名及基本事件结构。原始正文必须保留，不能 JSON.parse 后重新序列化再验签。稳定事件 ID 配合产品 inbox 唯一约束防止重复交付；时间窗口本身不替代持久化幂等。

收到通知后：

1. 验签，再以本产品凭证 `getOrder(event.orderId)` 后端读取中心订单。
2. 对照本地订单核对中央单号、业务单号、金额、币种和用户归属；支付状态必须为 `paid`。
3. 在本地事务中原子写入 inbox 及会员权益变更；重复事件和重复主动补单都不得重复延长会员。
4. 提交成功才返回 HTTP 2xx。事务失败返回非 2xx，中心从 15 秒开始指数退避、最长每小时持续重试，事件 ID 不变。

退款状态也以后端查询为准；产品需要处理乱序通知。例如已退款时才收到旧付款事件，应按中心当前订单及退款总额计算权益，不能盲目开通。中心会保存逐次投递记录；后台资金页可查看通知、安排重试及核查账单异常。

## 算力兼容与后台

现有 `/api/credits/v1/payments/orders` 充值接口、充值记录、关联退款入口继续使用。新充值关联通用支付单；微信回调／查单核验后，通用支付状态、充值状态和算力分录在同一个事务提交。每 1 分换算 10 个内部算力最小单位（1 元 = 100 算力）。算力退款按原单比例预留，最后一笔吸收舍入尾差，余额不足拒绝申请；未知、处理中和异常退款均保留冻结。

旧线下支付和退款保留原记录与流程，历史未关联线上记录不自动绑定新商户。用户 `/account/recharges` 查看算力充值，`/account/payments` 查看本人统一支付及退款。`/supermanage/funds` 为「算力订单」，管理算力充值、关联退款和收退款确认；`/supermanage/payments/orders` 为「会员/产品订单」，按主体／应用／业务／支付及产品确认状态查询统一订单、关联退款、产品通知和商户对账。两个入口均位于后台「订单管理」分组。

## 运维、验收与限制

迁移为增量新增支付表及保护约束，不改写历史账本。按 `scripts/provision-database.sql` 授权：身份服务只读凭证表，配置工具使用独立数据库配置身份；账本与投递日志保持追加写。凭证版本不可覆盖或删除。迁移完成后同步发布 identity 和 Web；已有线上支付、处理中退款和未投递事件时，不能回退到不认识这些状态的旧服务。

后台任务每 5 秒扫描待核验订单和退款；单笔网络操作使用持久化租约，实例重启后可恢复。通知与恢复分别调度；每日北京时间 10 点后每小时尝试昨日交易账单，校验下载地址、下载签名和文件摘要，再核对支付／退款金额与遗漏，结果见后台。该对账为交易核对，不是银行结算余额核对。账单包含中心以外订单也会提示差异，建议专用商户或人工核查。

每分钟日志输出支付通知积压、待重试延迟、异常退款及账单差异数量；日志不含订单正文、密钥或访问链接。网络失败、未知微信公钥和账单未就绪保持可恢复状态，不能当作支付或退款失败终态。实时签名依赖准确服务器时间。

运行 `npm run test:payments` 验证新增核心，`npm test`、`npm run test:mysql`、`npm run build` 验证全量。隔离 MySQL 测试使用模拟微信 API 的真实RSA签名、AES-GCM通知和SDK请求，覆盖三种下单方式、错商户、错金额、租约、丢响应、重复通知、退款预留及会员示例持久化。模拟不能替代真机微信浏览器调起、商户权限审批和真实退款验收。真实渠道在完成对应验证前保持关闭。

微信官方配置参考：[普通商户与服务商模式](https://pay.wechatpay.cn/doc/v3/partner/4012081931)、[JSAPI接入](https://pay.wechatpay.cn/doc/v3/merchant/4012791870)、[H5支付域名要求](https://pay.wechatpay.cn/doc/v3/merchant/4013287193)。

## 灵途灵感会员接入（2026-10-26，本地实现）

灵途灵感产品已增加会员在线订单与签名通知处理，会员权益、价格、期限和额度由产品独立数据库管理。新售方案为 BASIC 19 元／月、Growth 49 元／月、PRO 99 元／月、Elite 199 元／月，同档续费延长一个自然月，不自动续费；旧年会员和体验卡停售，历史订单继续按原金额和期限恢复与交付。首版应用退款权限关闭。

本地登记使用灵感 `.env.development.local` 中的独立开发应用，不使用正式应用 ID。可在本账号项目执行 `node scripts/configure-idea-payments-local.mjs`：工具先验证账号数据库为本机非生产库，再保存 `http://localhost:3459/api/payments/notify` 和精确返回白名单 `http://localhost:3459/me/orders`，收款场景选择已有 `lingtu-account-web-jsapi`。该登记保持新支付与退款权限停用，不导入或读取商户凭证，不修改正式环境。

完整模拟购买在灵感项目通过 `npm run preview:payments` 启动独立临时 MySQL、签名模拟支付中心和实际构建页面；不会使用账号开发库中的真实商户渠道。中心通知必须经产品原始正文验签和后端查单，产品事务提交会员交付后才返回成功。

未来正式接入地址为 `https://idea.link2.cn/api/payments/notify` 和 `https://idea.link2.cn/me/orders`；需发布产品新迁移与代码并完成真实商户联调后单独启用。本次未发布、未修改正式支付配置、未发起真实付款。
