diff --git a/docs/pay-orchestration-design.html b/docs/pay-orchestration-design.html index 1ee6cc4..1ac93d2 100644 --- a/docs/pay-orchestration-design.html +++ b/docs/pay-orchestration-design.html @@ -21,6 +21,9 @@ p{margin:10px 0} code{font-family:var(--mono);font-size:.86em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4} pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:#cdd3df} + pre.code .k{color:#ff7b72}pre.code .ty{color:#79c0ff}pre.code .fn{color:#d2a8ff}pre.code .st{color:#a5d6ff}pre.code .nu{color:#79c0ff}pre.code .co{color:#8b949e;font-style:italic} + table.fields td:first-child{font-family:var(--mono);font-size:12px;color:#cdd3df;white-space:nowrap;width:1%} + table.fields td code{background:transparent;padding:0} .tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle} .tag.ok{background:rgba(94,194,122,.16);color:var(--ok)} .tag.warn{background:rgba(224,184,79,.16);color:var(--warn)} @@ -183,30 +186,40 @@

控制面新增两张表(mysql + sqlite 双套 migration,金额一律 int64 最小单位,禁 float)。

4.1 pay_orders — 业务订单(购买/权益账本)

-
order_no      TEXT UNIQUE     -- 幂等主键, 形如 PAY2026...
-user_id       INTEGER         -- FK users.id(内部关联订阅)
-user_uuid     TEXT            -- 对外/跨系统标识(= pay 侧 user_ref)
-sku           TEXT            -- pro-month / pro-quarter / pro-year
-plan_code     TEXT            -- pro(建单时锁定,回调改不了)
-duration_days INTEGER         -- 30 / 90 / 365(建单时锁定)
-status        TEXT            -- created|pending|paid|expired|canceled
-subscription_id INTEGER NULL  -- 开通后回填(审计)
-created_at / paid_at / expires_at DATETIME
+ + + + + + + + + + + + + +
字段说明
order_no幂等主键(UNIQUE,形如 PAY2026…)
user_idFK users.id(内部关联订阅)
user_uuid对外/跨系统标识(= pay 侧 user_ref)
skupro-month / pro-quarter / pro-year
plan_codepro(建单时锁定,回调改不了)
duration_days30 / 90 / 365(建单时锁定)
statuscreated | pending | paid | expired | canceled
subscription_id开通后回填(审计,可空)
created_at / paid_at / expires_at时间戳

建单时就把 order_no → user / plan / 天数 落库。回调只能推进已存在的订单、开通其中预先锁定的套餐——回调无法指定"给谁开多久"。这是防回调伪造的纵深。

4.2 pay_attempts — 支付尝试(收款账本)

-
id            INTEGER PK
-order_no      TEXT            -- FK pay_orders
-method        TEXT            -- usdt_trc20 / alipay_nazha
-provider      TEXT            -- crypto / nazha
-provider_ref  TEXT            -- pangolin-pay 单号 / 哪吒 trade_no
-render_type   TEXT            -- redirect / display_details
-amount_minor  INTEGER         -- 该尝试的应收(按币种最小单位)
-currency      TEXT            -- USDT / CNY
-status        TEXT            -- created|pending|paid|failed|expired
-expires_at    DATETIME        -- 本次尝试的超时(哪吒 payurl / crypto 15min)
-created_at / paid_at DATETIME
-UNIQUE(provider, provider_ref)   -- 回调幂等的命门
+ + + + + + + + + + + + + + + + +
字段说明
id主键
order_noFK pay_orders
methodusdt_trc20 / alipay_nazha
providercrypto / nazha
provider_refpangolin-pay 单号 / 哪吒 trade_no
render_typeredirect / display_details
amount_minor该尝试应收(按币种最小单位)
currencyUSDT / CNY
statuscreated | pending | paid | failed | expired
expires_at本次尝试超时(哪吒 payurl / crypto 15min)
created_at / paid_at时间戳
UNIQUE(provider, provider_ref)回调幂等的命门

⚠️ 超时挂在 attempt 上:某 attempt 超时只把它自己置 expired,orderpending → 用户可再建 attempt 换渠道。order.expires_at整体购买窗口(较长,如 1h),到点才整单 expired。一笔 order 最多一个 attempt 能成功 —— MarkOrderPaid 只在 order=pending 时原子成立,paid 后续 attempt 全部失效。

4.3 两表分工与创建时机(一对多)

@@ -237,21 +250,21 @@ UNIQUE(provider, provider_ref) -- 回调幂等的命门

5. Provider 适配器接口

每个支付平台实现同一个接口,控制面只依赖接口。三个动作是最小完备集。

-
type Provider interface {
-    Method() MethodInfo   // 元信息:{ID, Name, IconURL, RenderType, Currency, Enabled, Min, Max}
-    Create(ctx, o Order) (Session, error)                       // ① 下单→返回渲染指令
-    HandleCallback(ctx, r *http.Request) (CallbackResult, error) // ② 验签+解析+归一化
-    Query(ctx, providerRef string) (PaymentStatus, *PaidEvent, error) // ③ 主动查单兜底
+
type Provider interface {
+    Method() MethodInfo   // 元信息:{ID, Name, IconURL, RenderType, Currency, Enabled, Min, Max}
+    Create(ctx, o Order) (Session, error)                       // ① 下单→返回渲染指令
+    HandleCallback(ctx, r *http.Request) (CallbackResult, error) // ② 验签+解析+归一化
+    Query(ctx, providerRef string) (PaymentStatus, *PaidEvent, error) // ③ 主动查单兜底
 }
 
-type Session struct {   // Create 的返回,客户端按 RenderType 分发
-    ProviderRef string
-    RenderType  string          // redirect / display_details / ...
-    Payload     json.RawMessage // 按 RenderType 定 shape
+type Session struct {   // Create 的返回,客户端按 RenderType 分发
+    ProviderRef string
+    RenderType  string          // redirect / display_details / ...
+    Payload     json.RawMessage // 按 RenderType 定 shape
     ExpiresAt   time.Time
 }
-type PaidEvent struct { OrderNo string; ProviderRef string; AmountMinor int64; Currency string; PaidAt time.Time; TxRef string }
-type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } // AckBody: 哪吒要回 "success"
+type PaidEvent struct { OrderNo string; ProviderRef string; AmountMinor int64; Currency string; PaidAt time.Time; TxRef string } +type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } // AckBody: 哪吒要回 "success"

5.1 crypto adapter(包裹已部署的 pangolin-pay)

pay-server 保持独立进程(持 xpub、看链,最小权限),控制面 crypto adapter 只是它的 HTTP 客户端:

@@ -271,15 +284,15 @@ type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } //

6. 客户端契约

6.1 REST 端点(控制面,全部 Bearer)

-
GET  /v1/pay/methods                 → 200 [{id,name,icon,render_type,currency,enabled,min,max}]
-POST /v1/pay/orders {sku, method}    → 201 {order_no, status, session:{render_type,payload,expires_at}}
-GET  /v1/pay/orders/{order_no}       → 200 {status, plan, expires_at, session?}
-GET  /v1/pay/orders?limit&cursor     → 200 {orders:[{order_no,sku,plan,amount,currency,status,created_at,paid_at}], next} (历史订单含 canceled,用户中心订单页用)
-POST /v1/pay/orders/{order_no}/retry {method} → 201 新 attempt/session(Order 不变)
-POST /v1/pay/orders/{order_no}/cancel → 200 {status:"canceled"}(仅 pending 可取消;paid/已取消返 409)
---- 平台→控制面(公开,不带 Bearer)---
-POST /v1/webhooks/pay/crypto         (pangolin-pay, HMAC 验签)
-GET  /v1/webhooks/pay/nazha          (哪吒, RSA 验签, GET)
+
GET  /v1/pay/methods                 → 200 [{id,name,icon,render_type,currency,enabled,min,max}]
+POST /v1/pay/orders {sku, method}    → 201 {order_no, status, session:{render_type,payload,expires_at}}
+GET  /v1/pay/orders/{order_no}       → 200 {status, plan, expires_at, session?}
+GET  /v1/pay/orders?limit&cursor     → 200 {orders:[{order_no,sku,plan,amount,currency,status,created_at,paid_at}], next} (历史订单含 canceled,用户中心订单页用)
+POST /v1/pay/orders/{order_no}/retry {method} → 201 新 attempt/session(Order 不变)
+POST /v1/pay/orders/{order_no}/cancel → 200 {status:"canceled"}(仅 pending 可取消;paid/已取消返 409)
+--- 平台→控制面(公开,不带 Bearer)---
+POST /v1/webhooks/pay/crypto         (pangolin-pay, HMAC 验签)
+GET  /v1/webhooks/pay/nazha          (哪吒, RSA 验签, GET)

6.2 render_type 联合体(写死在协议里)

@@ -306,11 +319,16 @@ GET /v1/webhooks/pay/nazha (哪吒, RSA 验签, GET)

7. 统一开通管线

webhook 与 Query 轮询最终都产出 PaidEvent,走同一条幂等开通逻辑:

-
① 验签           adapter.HandleCallback / Query  (各平台不同:HMAC / RSA / 链上确认)
-② 定位订单       provider_ref → pay_attempts → pay_orders
-③ 幂等           attempt 已 paid?  order 已 granted?  → 是则直接回 200,不重复开通
-④ 金额/币种核对   PaidEvent.amount == attempt.amount_minor && currency 一致
-⑤ 事务开通       { order→paid; attempt→paid; 开通订阅(source=pay); 回填 subscription_id; 审计 }
+
+ + + + + + + + +
步骤动作
① 验签adapter.HandleCallback / Query(各平台不同:HMAC / RSA / 链上确认)
② 定位订单provider_ref → pay_attempts → pay_orders
③ 幂等attempt 已 paid? order 已 granted? → 是则直接回 200,不重复开通
④ 金额/币种核对PaidEvent.amount == attempt.amount_minor && currency 一致
⑤ 事务开通{ order→paid; attempt→paid; 开通订阅(source=pay); 回填 subscription_id; 审计 }

7.1 复用并重构现有开通逻辑

开通能力现埋在 codes.Service.applySubscription(server/internal/codes/service.go:235),仅作为 Redeem 事务的一步、且 CreateSubscriptionsource 硬编码 'code'。改动: