diff --git a/docs/pay-orchestration-design.html b/docs/pay-orchestration-design.html index 715012d..bda68fe 100644 --- a/docs/pay-orchestration-design.html +++ b/docs/pay-orchestration-design.html @@ -203,8 +203,10 @@ 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) -- 回调幂等的命门 +

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

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

@@ -214,10 +216,11 @@ UNIQUE(provider, provider_ref) -- 回调幂等的命门 - + +
何时创建用户选套餐点「购买」确认那刻 —— POST /v1/pay/orders,一次购买一行每次选定支付方式建 provider 会话 —— 同一 POST /v1/pay/orders(首选渠道),或 /retry(换渠道)
数量1 笔购买 = 1 行(order_no 唯一)1 笔购买 = N 行(每次换渠道重试各一行)
锁定建单即锁 user/plan/天数/金额 → 回调改不了(防伪造)绑 provider + provider_ref → UNIQUE 幂等
生命周期created→pending→paid/expired/canceled(长)created→pending→paid/failed/expired(短,可弃)
生命周期created→pending→paid / expired(整体窗口到点) / canceled(用户取消)(长)created→pending→paid / failed / expired(单次超时)(短,可弃)
超时整体购买窗口(如 1h)到点 → order expired各自超时(哪吒 payurl / crypto 15min);超时只弃本 attempt,order 不变
-

典型:用户买 pro-year 选哪吒 → 建 order + attempt#1(nazha);扫码超时 → attempt#1 expired;点「换 crypto」→ order 不变,新建 attempt#2(crypto);链上付款回调 → 按 provider_ref 定位 attempt#2 → 幂等 → order 与 attempt#2 一起置 paid、开通订阅。attempt#1 保留 expired 留痕。拆两层就是为了「换渠道重试不污染订单状态机」+「订单是开通谁的唯一真相」。

+

典型:用户买 pro-year 选哪吒 → 建 order + attempt#1(nazha);扫码超时 → attempt#1 expired,order 仍 pending;点「换 crypto」→ order 不变,新建 attempt#2(crypto);链上付款回调 → 按 provider_ref 定位 attempt#2 → 幂等 → order 与 attempt#2 一起置 paid、开通订阅。attempt#1 保留 expired 留痕。一笔 order 最多一个 attempt 成功(MarkOrderPaid 只在 order=pending 原子成立)。用户也可 POST /orders/{no}/cancel 主动取消(pending→canceled),取消单仍在订单列表可见。拆两层:换渠道重试不污染订单状态机 + 订单是开通谁的唯一真相。

4.4 SKU 目录 + 定价 已定

SKU → (plan, 天数, 各币种价格)。哪吒收人民币(支付宝)、crypto 收 USDT,故按币种各定一价(不做实时汇率换算,固定价是产品决策)。首版 SKU 目录作为控制面代码常量(后续可迁 DB)。

@@ -270,8 +273,9 @@ type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } //
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} (历史订单,用户中心订单页用)
-POST /v1/pay/orders/{order_no}/retry {method} → 201 新 session(Order 不变)
+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)
@@ -327,8 +331,11 @@ GET /v1/webhooks/pay/nazha (哪吒, RSA 验签, GET)
  • webhook 可能丢:crypto 无 webhook(只链上侦测)、哪吒 GET 回调可能被 GFW/CF 吞 → Query 轮询兜底是刚需,不只依赖 webhook。
  • 重复回调:UNIQUE(provider, provider_ref) + order granted 标记,已开通再来直接 200。
  • 金额不符:crypto 付错金额进 pay 侧 orphan;哪吒金额核对不过则拒绝并告警,不开通。
  • -
  • 订单过期后到账:订单已 expired 则不自动开通,转人工对账(避免迟到付款误开/漏开)。
  • -
  • 换渠道重试:新建 Attempt,老 Attempt 置 expired/canceled,Order 仍 pending。
  • +
  • attempt 超时 ≠ order 关闭:单个 attempt 到自己的 expires_at 只置该 attempt expired,order 仍 pending,用户可继续换渠道;只有 order 整体窗口到点才整单 expired。
  • +
  • 只有一个 attempt 能成功:MarkOrderPaid 原子守卫 order=pending,paid 后所有 attempt/retry 失效。
  • +
  • 用户主动取消:POST /orders/{no}/cancel 仅在 pending 生效(→canceled);已 paid/已取消返 409。canceled 单仍在订单列表可见。取消后若链上迟到付款到账 → 进 orphan 人工处理。
  • +
  • 订单过期/取消后到账:order 非 pending 则不自动开通,转人工对账(避免迟到付款误开/漏开)。
  • +
  • 换渠道重试:新建 Attempt,老 Attempt 置 expired,Order 仍 pending。
  • 10. 测试策略

    diff --git a/docs/pay-orchestration-p1-plan.html b/docs/pay-orchestration-p1-plan.html index f145cb2..f492b06 100644 --- a/docs/pay-orchestration-p1-plan.html +++ b/docs/pay-orchestration-p1-plan.html @@ -84,7 +84,7 @@

    Task 4 · payorders 数据访问

    -

    新包 server/internal/payordersOrder/Attempt 结构 + CreateOrder/GetOrder/ListOrders/CreateAttempt/AttemptByProviderRef + 幂等 MarkOrderPaid(仅 status='pending' 才置 paid + 回填 subscription_id + attempt 置 paid;已 paid 返回 false)。SQLite 内存实库测「建单→查单→标付→再标付幂等」。

    +

    新包 server/internal/payordersOrder/Attempt 结构 + CreateOrder/GetOrder/ListOrders(全状态)/CreateAttempt/AttemptByProviderRef + 幂等 MarkOrderPaid(仅 status='pending' 才置 paid + 回填 subscription_id + attempt 置 paid;非 pending 返回 false —— 「一单只一个 attempt 成功」的守卫)+ CancelOrder(pending→canceled,canceled 单仍在列表可见)。attempt 带自己的 expires_at(单次超时不关 order)。SQLite 内存实库测「建单→查单→标付→再标付幂等」「取消→再取消 false→列表可见」。

    两张新表

    @@ -94,7 +94,8 @@ · created_at/paid_at/expires_at pay_attempts 支付尝试/收款账本:order_no · method · provider · provider_ref · render_type · amount_minor · currency · status - (created|pending|paid|failed|expired) · UNIQUE(provider,provider_ref) 幂等命门 + (created|pending|paid|failed|expired) · expires_at(单次超时,不关 order) + · UNIQUE(provider,provider_ref) 幂等命门

    相关:编排层设计 · 真相源 docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md

    diff --git a/docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md b/docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md index 35aef66..b498cff 100644 --- a/docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md +++ b/docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md @@ -68,6 +68,7 @@ CREATE TABLE pay_attempts ( amount_minor INTEGER NOT NULL, currency TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('created','pending','paid','failed','expired')), + expires_at DATETIME NOT NULL, -- 本次尝试超时(哪吒 payurl / crypto 15min);超时只弃本 attempt created_at DATETIME NOT NULL, paid_at DATETIME NULL, UNIQUE (provider, provider_ref), @@ -151,6 +152,7 @@ CREATE TABLE pay_attempts ( amount_minor BIGINT NOT NULL, currency VARCHAR(16) NOT NULL, status ENUM('created','pending','paid','failed','expired') NOT NULL, + expires_at DATETIME(6) NOT NULL, created_at DATETIME(6) NOT NULL, paid_at DATETIME(6) NULL, UNIQUE KEY uq_provider_ref (provider, provider_ref), @@ -476,11 +478,12 @@ git commit -m "refactor(pay): codes 开通委托 subscription.Grant(行为不变 - Consumes: 表 `pay_orders`/`pay_attempts`(Task 1)。 - Produces: - `type Order struct{ OrderNo string; UserID int64; UserUUID, SKU, PlanCode string; DurationDays int; AmountMinor int64; Currency, Status string; CreatedAt, ExpiresAt time.Time; PaidAt sql.NullTime; SubscriptionID sql.NullInt64 }` - - `type Attempt struct{ ID int64; OrderNo, Method, Provider, ProviderRef, RenderType string; AmountMinor int64; Currency, Status string; CreatedAt time.Time; PaidAt sql.NullTime }` + - `type Attempt struct{ ID int64; OrderNo, Method, Provider, ProviderRef, RenderType string; AmountMinor int64; Currency, Status string; ExpiresAt, CreatedAt time.Time; PaidAt sql.NullTime }` - `type Store struct{ db *sql.DB }` · `NewStore(db) *Store` · `func (s *Store) DB() *sql.DB` - - `CreateOrder(ctx, Order) error` · `GetOrder(ctx, orderNo) (*Order, error)`(`ErrNotFound`)· `ListOrders(ctx, userID int64, limit int, beforeID int64) ([]Order, error)` + - `CreateOrder(ctx, Order) error` · `GetOrder(ctx, orderNo) (*Order, error)`(`ErrNotFound`)· `ListOrders(ctx, userID int64, limit int, beforeID int64) ([]Order, error)` —— 返回**全部状态**(含 canceled),供订单历史页。 - `CreateAttempt(ctx, Attempt) (int64, error)` · `AttemptByProviderRef(ctx, provider, ref string) (*Attempt, error)` - - `MarkOrderPaid(ctx, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error)` —— 事务内幂等:仅当 order.status='pending' 时置 paid + 回填 subscription_id + 对应 attempt 置 paid;已 paid 返回 `false,nil`。 + - `MarkOrderPaid(ctx, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error)` —— 事务内幂等:仅当 order.status='pending' 时置 paid + 回填 subscription_id + 对应 attempt 置 paid;非 pending(已 paid/取消/过期)返回 `false,nil`。**这是「一单只一个 attempt 成功」的守卫**。 + - `CancelOrder(ctx, orderNo string) (bool, error)` —— 仅当 order.status='pending' 时置 canceled,返回 true;非 pending 返回 `false,nil`。 - `var ErrNotFound = errors.New("payorders: not found")` - [ ] **Step 1: 写失败测试(sqlite 实库,建最小 schema)** @@ -510,7 +513,7 @@ func openPay(t *testing.T) *sql.DB { status TEXT, subscription_id INTEGER NULL, created_at DATETIME, paid_at DATETIME NULL, expires_at DATETIME)`, `CREATE TABLE pay_attempts (id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT, method TEXT, provider TEXT, provider_ref TEXT, render_type TEXT, amount_minor INTEGER, currency TEXT, status TEXT, - created_at DATETIME, paid_at DATETIME NULL, UNIQUE(provider, provider_ref))`, + expires_at DATETIME, created_at DATETIME, paid_at DATETIME NULL, UNIQUE(provider, provider_ref))`, } for _, s := range ddl { if _, err := db.Exec(s); err != nil { t.Fatalf("ddl: %v", err) } @@ -539,7 +542,7 @@ func TestCreateGetMarkPaidIdempotent(t *testing.T) { if _, err := st.CreateAttempt(ctx, payorders.Attempt{ OrderNo: "PAY-1", Method: "usdt_trc20", Provider: "crypto", ProviderRef: "P-9", RenderType: "display_details", AmountMinor: 29990000, Currency: "USDT", - Status: "pending", CreatedAt: now, + Status: "pending", ExpiresAt: now.Add(15 * time.Minute), CreatedAt: now, }); err != nil { t.Fatalf("attempt: %v", err) } tx, _ := db.Begin() @@ -558,6 +561,38 @@ func TestCreateGetMarkPaidIdempotent(t *testing.T) { t.Fatalf("final: %+v", final) } } + +func TestCancelOrder(t *testing.T) { + db := openPay(t) + st := payorders.NewStore(db) + ctx := context.Background() + now := time.Now().UTC() + + must := payorders.Order{ + OrderNo: "PAY-C", UserID: 1, UserUUID: "u-1", SKU: "pro-month", PlanCode: "pro", + DurationDays: 30, AmountMinor: 3990000, Currency: "USDT", Status: "pending", + CreatedAt: now, ExpiresAt: now.Add(time.Hour), + } + if err := st.CreateOrder(ctx, must); err != nil { t.Fatalf("create: %v", err) } + + ok, err := st.CancelOrder(ctx, "PAY-C") + if err != nil || !ok { t.Fatalf("cancel#1 ok=%v err=%v", ok, err) } + + // 再取消 → false(非 pending)。 + ok2, _ := st.CancelOrder(ctx, "PAY-C") + if ok2 { t.Fatalf("第二次 CancelOrder 应 false") } + + got, _ := st.GetOrder(ctx, "PAY-C") + if got.Status != "canceled" { t.Fatalf("status = %s, 应 canceled", got.Status) } + + // canceled 单仍在 ListOrders 出现。 + list, _ := st.ListOrders(ctx, 1, 20, 0) + var seen bool + for _, o := range list { + if o.OrderNo == "PAY-C" { seen = true } + } + if !seen { t.Fatalf("canceled 单应出现在订单列表") } +} ``` - [ ] **Step 2: 跑测试确认失败** @@ -609,6 +644,7 @@ type Attempt struct { AmountMinor int64 Currency string Status string + ExpiresAt time.Time CreatedAt time.Time PaidAt sql.NullTime } @@ -687,10 +723,10 @@ func (s *Store) ListOrders(ctx context.Context, userID int64, limit int, beforeI func (s *Store) CreateAttempt(ctx context.Context, a Attempt) (int64, error) { res, err := s.db.ExecContext(ctx, `INSERT INTO pay_attempts (order_no, method, provider, provider_ref, render_type, - amount_minor, currency, status, created_at) - VALUES (?,?,?,?,?,?,?,?,?)`, + amount_minor, currency, status, expires_at, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, a.OrderNo, a.Method, a.Provider, a.ProviderRef, a.RenderType, - a.AmountMinor, a.Currency, a.Status, a.CreatedAt.UTC()) + a.AmountMinor, a.Currency, a.Status, a.ExpiresAt.UTC(), a.CreatedAt.UTC()) if err != nil { return 0, fmt.Errorf("payorders.CreateAttempt: %w", err) } @@ -702,10 +738,10 @@ func (s *Store) AttemptByProviderRef(ctx context.Context, provider, ref string) var a Attempt err := s.db.QueryRowContext(ctx, `SELECT id, order_no, method, provider, provider_ref, render_type, amount_minor, - currency, status, created_at, paid_at + currency, status, expires_at, created_at, paid_at FROM pay_attempts WHERE provider=? AND provider_ref=?`, provider, ref). Scan(&a.ID, &a.OrderNo, &a.Method, &a.Provider, &a.ProviderRef, &a.RenderType, - &a.AmountMinor, &a.Currency, &a.Status, &a.CreatedAt, &a.PaidAt) + &a.AmountMinor, &a.Currency, &a.Status, &a.ExpiresAt, &a.CreatedAt, &a.PaidAt) if err == sql.ErrNoRows { return nil, ErrNotFound } @@ -715,6 +751,18 @@ func (s *Store) AttemptByProviderRef(ctx context.Context, provider, ref string) return &a, nil } +// CancelOrder flips a pending order to canceled. Returns false if the order was +// not in 'pending' (already paid / canceled / expired). +func (s *Store) CancelOrder(ctx context.Context, orderNo string) (bool, error) { + res, err := s.db.ExecContext(ctx, + `UPDATE pay_orders SET status='canceled' WHERE order_no=? AND status='pending'`, orderNo) + if err != nil { + return false, fmt.Errorf("payorders.CancelOrder: %w", err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + // MarkOrderPaid flips a pending order to paid inside tx (idempotent). Returns // false if the order was not in 'pending' (already handled / expired). func (s *Store) MarkOrderPaid(ctx context.Context, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error) { @@ -737,10 +785,10 @@ func (s *Store) MarkOrderPaid(ctx context.Context, tx *sql.Tx, orderNo, provider } ``` -- [ ] **Step 4: 跑测试确认通过** +- [ ] **Step 4: 跑测试确认通过(含取消/列表)** -Run: `cd server && go test ./internal/payorders/ -run TestCreateGetMarkPaidIdempotent -v` -Expected: PASS。 +Run: `cd server && go test ./internal/payorders/ -v` +Expected: `TestCreateGetMarkPaidIdempotent` 与 `TestCancelOrder` 均 PASS。 - [ ] **Step 5: 全量编译 + 测试**