chore(server): pay v2 接入终验 + OpenAPI 登记 + 交付说明
全量矩阵本地全绿(build/vet/test/run_sqlite_test.sh + 新增 SQLite 文件库带数据 升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM;client analyze/test 功能全绿, 27 个既有 golden 像素噪声与本次接入无关)。server/api/openapi.yaml 登记 6 个新 Pay 端点(catalog/orders/orderNo/retry/cancel/webhook)+ schema,通过结构校验。 顺手修复 run_mysql_test.sh 缺 multiStatements=true 的既有 bug(与 pay-v2 无关)。 新增 pay_migration_rehearsal_test.go 验证 000021 升级不丢行/AUTOINCREMENT 续序, 并确认一处安全特性:已有 source='pay' 行后 down 会被 CHECK 约束正确拒绝。 交付说明 docs/pay-v2-integration-delivery.html(含测试矩阵/联调 checklist/部署 附录)已登记 docs/index.html,记录一处既有但未修的部署前置阻断项(go.mod 的 wangjia/codes 本地路径 replace 会阻断异机构建,超出本任务授权范围)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -330,6 +330,323 @@ paths:
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
|
||||
# ── Pay(pay v2 统一支付网关代理;PAY_BASE_URL 未配置时整组不挂载)───
|
||||
|
||||
/pay/catalog:
|
||||
get:
|
||||
operationId: payCatalog
|
||||
summary: 可购档位目录
|
||||
description: |
|
||||
三档可购套餐(月/季/年),仅展示用(`price_minor` 为 CNY 展示价,单位分)。
|
||||
实际扣款金额以 pay 侧 `product_prices` / `products.price` 为准,两处人工对齐
|
||||
(联调 checklist 有价格一致性核对项)。
|
||||
tags: [Pay]
|
||||
responses:
|
||||
"200":
|
||||
description: 目录列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PayCatalogItem"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
|
||||
/pay/orders:
|
||||
post:
|
||||
operationId: payCreateOrder
|
||||
summary: 下单(代理 pay v2 create)
|
||||
description: |
|
||||
创建支付订单。**客户端只传 sku + method + 端型 metadata,永远不传金额**——
|
||||
金额由服务端按 sku 查 pay 侧目录后代下单,`biz_ref` 由服务端从当前 JWT 用户
|
||||
映射(`users.uuid`),客户端无感知。
|
||||
|
||||
下单成功后台账(`pay_purchases`)写入失败不影响本次下单结果(webhook 按
|
||||
`biz_ref` 兜底补建),仅记服务端日志。
|
||||
tags: [Pay]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sku, method]
|
||||
properties:
|
||||
sku:
|
||||
type: string
|
||||
description: 档位代码,取自 `/pay/catalog`(如 `pro_month`)
|
||||
example: pro_month
|
||||
method:
|
||||
type: string
|
||||
description: 支付方式,取值以 pay 侧支持渠道为准(如 `crypto` / `alipay`)
|
||||
example: crypto
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: |
|
||||
透传给 pay 的端型元数据,白名单仅 `is_mobile` / `render`(其余字段
|
||||
服务端静默丢弃,不透传)。
|
||||
example:
|
||||
is_mobile: "true"
|
||||
responses:
|
||||
"200":
|
||||
description: 下单成功,返回渲染会话
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PayOrderSessionResult"
|
||||
"400":
|
||||
description: 参数非法(未知 sku / method 为空 / 请求体解析失败)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
description: 当前用户不存在或账户非 active(NOT_FOUND)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
$ref: "#/components/responses/PayConflict"
|
||||
"429":
|
||||
$ref: "#/components/responses/TooManyRequests"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/components/responses/PayUpstream"
|
||||
|
||||
/pay/orders/{orderNo}:
|
||||
get:
|
||||
operationId: payGetOrder
|
||||
summary: 查单(代理 pay v2 查单 + 本地台账开通状态)
|
||||
description: |
|
||||
每次调用都会回源 pay 查一次单(无本地缓存;量大后可加 server 短缓存,见交付
|
||||
说明「取舍」条目)。`activated` 是客户端轮询判定成功开通的唯一依据——本地台账
|
||||
`pay_purchases.status = 'paid'`,与 `pay_status`(pay 侧状态词汇原样透传,
|
||||
仅展示、不做分支判断)是两条独立信息。
|
||||
tags: [Pay]
|
||||
parameters:
|
||||
- name: orderNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: pay 侧订单号(下单响应 `order_no`)
|
||||
example: "PAY202607100001"
|
||||
responses:
|
||||
"200":
|
||||
description: 订单状态
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PayOrderStatus"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
description: 订单不属于当前用户,或订单不存在(NOT_FOUND)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/components/responses/PayUpstream"
|
||||
|
||||
/pay/orders/{orderNo}/retry:
|
||||
post:
|
||||
operationId: payRetryOrder
|
||||
summary: 换渠道重试(同一订单换 method)
|
||||
description: |
|
||||
同一订单更换支付方式(如 crypto → alipay)。若目标渠道结算币种与订单原币种
|
||||
不符,pay 侧拒绝并返回 409 `CURRENCY_MISMATCH`——客户端应据此自动取消旧单、
|
||||
用新 method 重新走 `/pay/orders` 下单(见联调 checklist「换渠道」条)。
|
||||
tags: [Pay]
|
||||
parameters:
|
||||
- name: orderNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: pay 侧订单号(下单响应 `order_no`)
|
||||
example: "PAY202607100001"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [method]
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
example: alipay
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: 同 `/pay/orders`,仅 `is_mobile` / `render` 白名单透传
|
||||
responses:
|
||||
"200":
|
||||
description: 重试成功,返回新的渲染会话
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PayOrderSessionResult"
|
||||
"400":
|
||||
description: method 为空或请求体解析失败(BAD_REQUEST)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
description: 订单不属于当前用户,或订单不存在(NOT_FOUND)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
$ref: "#/components/responses/PayConflict"
|
||||
"429":
|
||||
$ref: "#/components/responses/TooManyRequests"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/components/responses/PayUpstream"
|
||||
|
||||
/pay/orders/{orderNo}/cancel:
|
||||
post:
|
||||
operationId: payCancelOrder
|
||||
summary: 取消订单
|
||||
description: |
|
||||
取消一个仍处于待支付状态的订单。仅当用户显式点击「取消订单」时调用——
|
||||
等待支付页面被系统返回键/手势关闭**不**触发取消(订单仍在 pay 侧 pending,
|
||||
可从头再进),这是桌面壳的既定行为,见风险清单第 6 条。
|
||||
tags: [Pay]
|
||||
parameters:
|
||||
- name: orderNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: pay 侧订单号(下单响应 `order_no`)
|
||||
example: "PAY202607100001"
|
||||
responses:
|
||||
"200":
|
||||
description: 取消结果(`canceled=false` 表示订单已不在待支付状态,未被取消)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [canceled]
|
||||
properties:
|
||||
canceled:
|
||||
type: boolean
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
description: 订单不属于当前用户,或订单不存在(NOT_FOUND)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/components/responses/PayUpstream"
|
||||
|
||||
/webhook/pay:
|
||||
post:
|
||||
operationId: payWebhook
|
||||
summary: pay v2 出站 webhook 接收(payment.succeeded)
|
||||
description: |
|
||||
pay 侧支付成功后的服务端到服务端回调,**不经过 JWT**(本接口不在
|
||||
`bearerAuth` 保护范围内),改用 HMAC 请求头验签:与 pay
|
||||
`util/sign.go` 逐字节同构,`parts = [X-Pay-System, X-Pay-Timestamp,
|
||||
X-Pay-Nonce, rawBody]`,`\n` join 后 HMAC-SHA256 + 标准 base64,
|
||||
时间窗容忍 ±5 分钟。
|
||||
|
||||
幂等三层:`X-Pay-Nonce` SETNX(传输层重放,Redis 不可用时跳过此层)→
|
||||
`out_trade_no` 行锁 CAS(业务幂等,重投唯一可靠键,已 `paid` 直接
|
||||
返回 SUCCESS)→ `biz_ref` 兜底(台账缺行按用户 uuid 自修复)。
|
||||
|
||||
**注意**:本接口的成功/失败响应体是**纯文本**,不是 `Error` JSON
|
||||
schema——ACK 判据是 HTTP 200 且 body 字面包含大写 `SUCCESS`;验签/
|
||||
载荷失败时用 `http.Error`(纯文本)返回 400/401;开通失败返回 500
|
||||
纯文本(pay 侧据此退避重投,事务已回滚、不产生半态)。
|
||||
tags: [Pay]
|
||||
security: []
|
||||
parameters:
|
||||
- name: X-Pay-System
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: 业务系统标识,须等于服务端配置的 `PAY_BIZ_SYSTEM`(默认 `pangolin`)
|
||||
- name: X-Pay-Timestamp
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Unix 秒级时间戳,±300s 容忍窗口
|
||||
- name: X-Pay-Nonce
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: 一次性随机串,用于传输层防重放(SETNX + TTL)
|
||||
- name: X-Pay-Sign
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: HMAC-SHA256(secret, join("\n", system, ts, nonce, rawBody)) 的标准 base64
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PayWebhookEvent"
|
||||
responses:
|
||||
"200":
|
||||
description: 已确认处理(幂等;重复投递也回 200 SUCCESS)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
example: SUCCESS
|
||||
"400":
|
||||
description: 请求体读取失败或载荷非法 JSON(纯文本响应,非 Error schema)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"401":
|
||||
description: 验签失败(system 不符 / 缺头 / 时间窗超限 / 签名不匹配,纯文本响应)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"500":
|
||||
description: 开通事务失败,pay 将按退避策略重投(纯文本响应,事务已回滚不产生半态)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/plans:
|
||||
get:
|
||||
operationId: listPlans
|
||||
@@ -619,6 +936,25 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
PayConflict:
|
||||
description: |
|
||||
订单状态冲突。`code` 可能为 `CURRENCY_MISMATCH`(换渠道结算币种与订单不符,
|
||||
客户端应取消旧单重新下单)或 `ORDER_NOT_PENDING`(订单状态已变化,需刷新)。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
PayUpstream:
|
||||
description: |
|
||||
pay 网关暂不可用(`code = PAY_UPSTREAM`)——上游 pay-server 不可达、超时,
|
||||
或返回了未分类的业务错误(`no_account` / `no_settle_currency` /
|
||||
`create_failed` / `upstream_error` 等),客户端应提示稍后重试。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
# ── Schema ────────────────────────────────────────────────
|
||||
|
||||
schemas:
|
||||
@@ -986,6 +1322,139 @@ components:
|
||||
format: date-time
|
||||
description: 发布时间(UTC ISO-8601)
|
||||
|
||||
# ── Pay(pay v2 统一支付网关代理)──────────────────────
|
||||
|
||||
PayCatalogItem:
|
||||
type: object
|
||||
description: 可购档位(三档单源,见 `internal/pay/catalog.go`)
|
||||
required: [sku, plan, days, price_minor, currency]
|
||||
properties:
|
||||
sku:
|
||||
type: string
|
||||
description: 档位代码,与 pay 侧 `products.biz_code` 一一对应
|
||||
example: pro_month
|
||||
plan:
|
||||
type: string
|
||||
enum: [free, pro, team]
|
||||
description: 兑换后生效的套餐代码
|
||||
example: pro
|
||||
days:
|
||||
type: integer
|
||||
description: 时长天数(宽松口径:31/92/366,覆盖大月与最长季)
|
||||
example: 31
|
||||
price_minor:
|
||||
type: integer
|
||||
format: int64
|
||||
description: |
|
||||
展示价,CNY 分。**仅展示**,实际扣款以 pay 侧
|
||||
`product_prices`(USDT 微单位)/ `products.price`(CNY 元)为准,
|
||||
两处人工对齐(见交付说明「取舍」条目 4)。
|
||||
example: 2999
|
||||
currency:
|
||||
type: string
|
||||
example: CNY
|
||||
|
||||
PaySession:
|
||||
type: object
|
||||
description: |
|
||||
pay 下单/retry 返回的渲染会话。`render_type` 多态,`payload` 结构随之变化,
|
||||
原样透传给客户端:
|
||||
- `crypto_address`:`{address, amount, amount_minor, currency, network}`(如 USDT/TRC20)
|
||||
- `redirect`:`{url}`(如支付宝拉起链接)
|
||||
- `qr`:`{data}`(当前仅复制兜底,未渲染二维码——见交付说明「取舍」条目 3)
|
||||
required: [render_type, payload]
|
||||
properties:
|
||||
render_type:
|
||||
type: string
|
||||
enum: [crypto_address, redirect, qr]
|
||||
payload:
|
||||
type: object
|
||||
description: 结构随 render_type 变化,原样透传,服务端不解析
|
||||
additionalProperties: true
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 会话/收款地址有效期(UTC ISO-8601),无限期时为 null
|
||||
|
||||
PayOrderSessionResult:
|
||||
type: object
|
||||
description: "`/pay/orders`(下单)与 `/pay/orders/{orderNo}/retry`(换渠道)的共同响应体"
|
||||
required: [order_no, session]
|
||||
properties:
|
||||
order_no:
|
||||
type: string
|
||||
description: pay 侧订单号
|
||||
example: "PAY202607100001"
|
||||
session:
|
||||
$ref: "#/components/schemas/PaySession"
|
||||
|
||||
PayOrderStatus:
|
||||
type: object
|
||||
required: [order_no, pay_status, activated]
|
||||
properties:
|
||||
order_no:
|
||||
type: string
|
||||
pay_status:
|
||||
type: string
|
||||
description: |
|
||||
pay 侧状态词汇原样透传(`pending` / `succeeded` / `canceled` 等,具体取值
|
||||
以 pay 实现为准)。**仅展示,不做分支判断**——本地成功判据是 `activated`。
|
||||
example: succeeded
|
||||
activated:
|
||||
type: boolean
|
||||
description: |
|
||||
本地台账(`pay_purchases.status = 'paid'`)是否已消费权益开通。
|
||||
客户端轮询以此字段为唯一成功判据。
|
||||
example: true
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 开通/延长后的订阅到期时间(UTC ISO-8601);未开通或查询失败时缺省
|
||||
|
||||
PayWebhookEvent:
|
||||
type: object
|
||||
description: |
|
||||
pay `settle.go::enqueuePaymentSucceeded` 出站 payload(payment.succeeded 事件,
|
||||
无 `refund_id` 字段)。
|
||||
required: [event_type, out_trade_no, biz_system, biz_ref, product_biz_code,
|
||||
amount_minor, currency, channel, paid_at]
|
||||
properties:
|
||||
event_type:
|
||||
type: string
|
||||
enum: [payment.succeeded]
|
||||
description: 事件类型;白名单外的事件本接口直接确认(200 SUCCESS)不处理
|
||||
out_trade_no:
|
||||
type: string
|
||||
description: pay 订单号,业务幂等唯一键
|
||||
biz_system:
|
||||
type: string
|
||||
example: pangolin
|
||||
biz_ref:
|
||||
type: string
|
||||
format: uuid
|
||||
description: 下单时传入的业务方用户标识(`users.uuid`)
|
||||
product_biz_code:
|
||||
type: string
|
||||
description: 对应 `/pay/catalog` 的 `sku`
|
||||
example: pro_month
|
||||
amount_minor:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 实付金额(最小单位)
|
||||
currency:
|
||||
type: string
|
||||
example: USDT
|
||||
channel:
|
||||
type: string
|
||||
description: 实际支付渠道
|
||||
example: crypto
|
||||
paid_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 支付完成时间(RFC3339);解析失败时服务端回落为处理时刻
|
||||
|
||||
tags:
|
||||
- name: Auth
|
||||
description: 认证相关(发验证码、注册、登录、刷新 Token)——无需 JWT
|
||||
@@ -993,6 +1462,10 @@ tags:
|
||||
description: 账户与设备管理
|
||||
- name: Commerce
|
||||
description: 商业闭环(激活码兑换、广告解锁、套餐目录)
|
||||
- name: Pay
|
||||
description: |
|
||||
pay v2 统一支付网关代理(下单/查单/换渠道/取消 + webhook 接收)。
|
||||
`PAY_BASE_URL` 未配置时整组端点不挂载(服务端启动日志告警,无 404 以外的降级)。
|
||||
- name: Nodes
|
||||
description: 节点目录与连接凭证下发(数据面入口)
|
||||
- name: Usage
|
||||
|
||||
Reference in New Issue
Block a user