From 0912027c69e21cf2fe51f35a7628d6fa89b281f0 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 16:22:44 +0800 Subject: [PATCH] =?UTF-8?q?docs(v2):=20P4=20=E9=80=80=E6=AC=BE=20/=20P6=20?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6=E5=AE=88=E6=8A=A4=20/=20P8=20=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=8B=92=E4=BB=98=20=E4=B8=89=E8=AE=A1=E5=88=92(P6=20?= =?UTF-8?q?=E6=94=B6=E7=BC=96=E5=85=A8=E9=83=A8=E8=B7=AF=E7=94=B1=E6=AC=A0?= =?UTF-8?q?=E8=B4=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- .../plans/2026-07-10-pay-v2-p4-refunds.md | 1608 ++++++++++++++ .../plans/2026-07-10-pay-v2-p6-reconcile.md | 1855 +++++++++++++++++ .../plans/2026-07-10-pay-v2-p8-recurring.md | 1081 ++++++++++ 3 files changed, 4544 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-pay-v2-p4-refunds.md create mode 100644 docs/superpowers/plans/2026-07-10-pay-v2-p6-reconcile.md create mode 100644 docs/superpowers/plans/2026-07-10-pay-v2-p8-recurring.md diff --git a/docs/superpowers/plans/2026-07-10-pay-v2-p4-refunds.md b/docs/superpowers/plans/2026-07-10-pay-v2-p4-refunds.md new file mode 100644 index 0000000..106a360 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pay-v2-p4-refunds.md @@ -0,0 +1,1608 @@ +# pay v2 · P4 退款(refunds)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 每个 Task 自包含,给完整 Go 代码 + 测试 + 命令,可交给全新 implementer subagent。 + +> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html`(重点 **§6 退款设计**——退款三向:业务发起(卡/宝/微)/ crypto 人工 / 平台发起,统一收敛成 refund 事件;§4.1 `refund(provider_ref, amount, reason)`;§5 webhook v2 `refund.succeeded`/`refund.failed`;§3.4 Refund 子实体、§3 Order 退款态 `paid→refunding→refunded/partially_refunded`)。 +> **前置计划(全部已完成、绿):** +> - P1 `docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md`(`model.Refund`/`RefundStatus`/`OrderStatusV2` 退款态、`store.OrderStore`、`money`、`accounts`)。 +> - P2 `docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md`(`provider.Provider`/`RefundingProvider` 声明、`gateway.Gateway`、webhook outbox「先入队后翻转」不变量)。 +> - P3 `docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md`(crypto/alipay/stripe 三 adapter,离线 `httptest` 测法)。 +> - P5 多账户路由已就绪(`accounts.Router`);本计划**不**做退款的多账户路由(adapter 只持装配期注入的单一渠道客户端,退款走同一客户端——与回调验签同法)。 + +**Goal:** 在 P2 管线 + P3 三渠道 adapter 上,落地**退款机制**(退款政策归业务,机制归 pay)。业务方经 `POST /api/v2/refunds`(HMAC 验签)发起 → pay 校验(订单已付、金额 ≤ 可退余额、支持部分/多次累计)→ 建 `Refund` 单 → 渠道支持则调 `provider.RefundingProvider.Refund`(alipay `TradeRefund` / stripe `Refunds.New`,`refund_id` 作幂等键)→ 幂等落状态 → order 退款态机条件 UPDATE 翻转 → outbox `refund.succeeded`/`refund.failed`(**幂等键含 refund 单号**,同单多次部分退不撞唯一键)。crypto 自托管无退款 API → 建 `manual_pending` 单 + 运营人工 sweep 后回填完成的最小闭环。全程 `httptest` 假渠道 + 免 docker + 不打真网 + 无真实密钥。 + +**Architecture:** 退款编排落 `internal/gateway/refund.go`(与 `settle.go` 同包同纪律),`Gateway` 新增 `*store.RefundStore` 依赖。退款态的幂等/事务纪律**完全复刻 settle.go 的「先幂等入队 webhook、再条件 UPDATE 翻转」**:顺序不变量升级为「**refund 为 succeeded ⇒ outbox 行必已存在**」。渠道差异仍封死在各 adapter 的 `Refund()`;`gateway`/`provider` 核心保持中性。outbox 唯一键从 `(out_trade_no,event_type)` 扩为 `(out_trade_no,event_type,refund_id)`——payment 事件 `refund_id=""`(每单每事件仍唯一),refund 事件带 `refund_id`(同单多次部分退各自成行)。webhook 投递门禁从「订单 == paid」放宽为「订单已结算(paid/refunding/partially_refunded/refunded)」,否则退款态订单的 refund webhook 会被门禁永久卡住。 + +**Tech Stack:** Go 1.26.1 · `github.com/wangjia/pay` · Gin · GORM v1.31 · glebarez/sqlite · `github.com/smartwalle/alipay/v3 v3.2.29`(`Client.TradeRefund` 同步退款)· `github.com/stripe/stripe-go/v79 v79.12.0`(`client.API.Refunds.New` + `CheckoutSessions.Get` 取 payment_intent)· crypto 无退款 API(人工向)。 + +## Global Constraints(继承 P2/P3) + +- **复用 P1/P2/P3,不重造**:模型 `model.Refund`(`RefundID`/`OutTradeNo`/`AttemptProviderRef`/`AmountMinor`/`Currency`/`Reason`/`Status`/`ProviderRefundRef`/`InitiatedBy`/`CompletedAt`)+ `RefundStatus`(`requested`/`processing`/`succeeded`/`failed`,本计划新增 `manual_pending`)+ `OrderStatusV2`(`refunding`/`partially_refunded`/`refunded` 已在 P1);数据访问 `store.OrderStore`;金额 `int64` 最小单位 + 币种码(`money.Format/Parse`,CNY/USD=2 位、USDT=6 位);Provider 抽象 `provider.RefundingProvider`。 +- **金额一律 int64 最小单位 + 币种码,禁 float**。退款额由业务方传(它才知道退多少),但 pay **强校验** `已退(非失败)+ 本次 ≤ 订单已付额`,拒绝超退。 +- **幂等 = 条件 UPDATE + RowsAffected**(SQLite 单写者,无 `FOR UPDATE`),复刻 `store.MarkAttemptPaid`/`applyPaid` 惯例。退款态翻转、order 态机推进均条件 UPDATE。 +- **先入队后翻转(资金命脉)**:refund 成功事件**先幂等入队 outbox、再翻转 refund 单 + order 态机**。两步间崩溃 → outbox 躺一条 refund.succeeded,投递门禁放行(order 已结算),渠道/重试幂等自愈;绝不「已退款但业务方永不知情」。 +- **Provider 中性**:adapter 只依赖 `internal/provider`(+ 自己的渠道 SDK + `money`);`internal/gateway` 绝不 import 任何 adapter。退款只用**装配期注入的单一渠道客户端**(不做退款多账户路由,记 Self-Review)。 +- **凭证 env only**:渠道密钥经 P3 装配注入;人工闭环 admin 端点用 env `PAY_ADMIN_TOKEN`(`os.Getenv`,严禁写 config.yaml/落库/提交 git)。 +- **GORM AutoMigrate 只加不删**:`WebhookDelivery` 加 `RefundID` 列并改 `uq_delivery` 复合唯一键——本分支 v2 为**新建库**(P1-P5 未上生产),`AutoMigrate` 重建索引安全;存量库迁移记 Self-Review。 +- 每步 `go build ./...` 通过;测试 `go test ./...`(全 `:memory:`/临时 sqlite + `httptest`,**免 docker、不打真网、无真实密钥**)。每任务严格 bite-sized TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。禁占位。 + +--- + +### Task 1: outbox 退款感知 —— `RefundID` 唯一键 + 投递门禁放行退款态 + 依赖签名贯通 + +**为什么先做:** 退款事件与 payment 事件同表同惯例,但**同单多次部分退**同 `event_type="refund.succeeded"` 会撞现有 `unique(out_trade_no,event_type)` 唯一键(第二次被 `DoNothing` 丢弃)。且 `event_type` 列 `size:32`,塞不下 `refund.succeeded:`(refund_id ≈ 26 字符)。故正解 = **给 outbox 加 `refund_id` 列进复合唯一键**;payment 事件 `refund_id=""` 语义不变。同时 webhook 投递门禁 `orderPaid` 现只认 `== paid`,退款后订单转 `refunding/partially_refunded/refunded` → refund webhook 会被门禁永久卡住,须放宽为「已结算」。 + +**Files:** +- Modify: `internal/model/webhook_delivery.go`(`RefundID` 列进 `uq_delivery`) +- Modify: `internal/model/v2.go`(`OrderStatusV2.Settled()` helper + `RefundManualPending` 状态) +- Modify: `internal/store/webhook.go`(`EnqueueDelivery` 加 `refundID` 参、`OnConflict` 加列) +- Modify: `internal/webhook/notifier.go`(`Enqueue` 加 `refundID` 参 → 透传 store) +- Modify: `internal/gateway/gateway.go`(`WebhookEnqueuer` 接口加 `refundID` 参) +- Modify: `internal/gateway/settle.go`(payment 入队传 `refundID=""`) +- Modify: `main.go`(投递门禁闭包用 `Settled()`) +- Test: `internal/store/webhook_test.go`(refund_id 复合唯一键:同单同事件不同 refund_id 各成行;同 refund_id 幂等)、`internal/model/v2_test.go`(`Settled()`) + +**Interfaces(改后签名,全仓贯通):** +- `store.EnqueueDelivery(outTradeNo, bizSystem, eventType, refundID, payload string) error` +- `webhook.Notifier.Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error`(实现 `gateway.WebhookEnqueuer`) +- `gateway.WebhookEnqueuer.Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error` + +- [ ] **Step 1: 写失败测试** + +`internal/store/webhook_test.go`(新增用例;若文件不存在则新建 `package store` 内部测试,复用 `model.OpenTestDB`): +```go +package store + +import ( + "testing" + + "github.com/wangjia/pay/internal/model" +) + +func TestEnqueueDeliveryRefundIDUnique(t *testing.T) { + ws := NewWebhookStore(model.OpenTestDB(t)) + // payment:refund_id="" —— 同单同事件第二次幂等丢弃 + must := func(err error) { if err != nil { t.Fatalf("enqueue: %v", err) } } + must(ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", "", `{"a":1}`)) + must(ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", "", `{"a":2}`)) // 幂等 no-op + // refund:同单同事件、不同 refund_id —— 各自成行(部分退多次) + must(ws.EnqueueDelivery("PAY-1", "pangolin", "refund.succeeded", "rf-A", `{"r":"A"}`)) + must(ws.EnqueueDelivery("PAY-1", "pangolin", "refund.succeeded", "rf-B", `{"r":"B"}`)) + must(ws.EnqueueDelivery("PAY-1", "pangolin", "refund.succeeded", "rf-A", `{"r":"A2"}`)) // 同 refund_id 幂等 + + rows, err := ws.ListUndelivered(50) + if err != nil { + t.Fatal(err) + } + // 期望 3 行:1 payment + 2 refund(rf-A / rf-B) + if len(rows) != 3 { + t.Fatalf("undelivered rows = %d want 3: %+v", len(rows), rows) + } +} +``` + +`internal/model/v2_test.go`(新增): +```go +package model + +import "testing" + +func TestOrderStatusSettled(t *testing.T) { + settled := []OrderStatusV2{OrderPaidV2, OrderRefundingV2, OrderPartRefundedV2, OrderRefundedV2} + for _, s := range settled { + if !s.Settled() { + t.Fatalf("%s should be settled", s) + } + } + for _, s := range []OrderStatusV2{OrderCreatedV2, OrderPendingV2, OrderCanceledV2, OrderExpiredV2} { + if s.Settled() { + t.Fatalf("%s should NOT be settled", s) + } + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/model/ -run 'RefundIDUnique|Settled' -v` +Expected: 编译失败(`EnqueueDelivery` 参数不符 / `Settled` 未定义 / `RefundManualPending` 未定义)。 + +- [ ] **Step 3: 写实现** + +`internal/model/webhook_delivery.go` —— 加 `RefundID` 进 `uq_delivery`: +```go +type WebhookDelivery struct { + Base + OutTradeNo string `gorm:"size:64;not null;uniqueIndex:uq_delivery" json:"out_trade_no"` + EventType string `gorm:"size:32;not null;uniqueIndex:uq_delivery" json:"event_type"` + RefundID string `gorm:"size:64;uniqueIndex:uq_delivery" json:"refund_id,omitempty"` // 退款事件的幂等维度;payment 事件为空 + BizSystem string `gorm:"index;size:32" json:"biz_system"` + Payload string `gorm:"type:text" json:"payload"` + Delivered bool `gorm:"index;default:false" json:"delivered"` + Attempts int `json:"attempts"` + LastError string `gorm:"size:255" json:"last_error,omitempty"` +} +``` + +`internal/model/v2.go` —— `RefundStatus` 常量块加一枚,并加 `Settled()`: +```go +const ( + RefundRequested RefundStatus = "requested" + RefundProcessing RefundStatus = "processing" + RefundSucceeded RefundStatus = "succeeded" + RefundFailed RefundStatus = "failed" + RefundManualPending RefundStatus = "manual_pending" // crypto 自托管:待运营人工 sweep 退款 +) + +// Settled 报告订单是否「已真正收到过钱」(paid 及其后的退款态)。webhook 投递门禁用: +// 只有已结算订单的 outbox 才放行——payment 事件挡住崩溃窗口里的未付单,refund 事件 +// 则因订单已 paid 过而正常放行(不会因订单转入退款态被卡)。 +func (s OrderStatusV2) Settled() bool { + switch s { + case OrderPaidV2, OrderRefundingV2, OrderPartRefundedV2, OrderRefundedV2: + return true + } + return false +} +``` + +`internal/store/webhook.go` —— `EnqueueDelivery` 加 `refundID`: +```go +func (s *WebhookStore) EnqueueDelivery(outTradeNo, bizSystem, eventType, refundID, payload string) error { + row := model.WebhookDelivery{ + OutTradeNo: outTradeNo, BizSystem: bizSystem, EventType: eventType, + RefundID: refundID, Payload: payload, + } + err := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "out_trade_no"}, {Name: "event_type"}, {Name: "refund_id"}}, + DoNothing: true, + }).Create(&row).Error + if err != nil { + return fmt.Errorf("store.EnqueueDelivery: %w", err) + } + return nil +} +``` + +`internal/webhook/notifier.go` —— `Enqueue` 加 `refundID` 透传: +```go +func (n *Notifier) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error { + body, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("webhook.Enqueue marshal: %w", err) + } + return n.deliveries.EnqueueDelivery(outTradeNo, bizSystem, eventType, refundID, string(body)) +} +``` + +`internal/gateway/gateway.go` —— `WebhookEnqueuer` 接口加参: +```go +// WebhookEnqueuer receives a domain payload to deliver to the business system. +// refundID 为退款事件的幂等维度(payment 事件传 "")。 +type WebhookEnqueuer interface { + Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error +} +``` + +`internal/gateway/settle.go` —— payment 入队传空 refundID(`enqueuePaymentSucceeded` 末行): +```go + return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, "payment.succeeded", "", data) +``` + +`main.go` —— 投递门禁闭包放宽为已结算: +```go + notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) { + o, err := orderStore.GetOrder(no) // 投递门禁:订单已结算(paid 及退款态)才放行 + if err != nil { + return false, err + } + return o.Status.Settled(), nil + }) +``` + +> **同步跟签名的既有测试替身**(否则 `go build`/既有测试编译失败): +> - `internal/gateway/gateway_test.go` 的 `spyEnqueuer.Enqueue` → `func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error`(签名加 `refundID`,函数体不变)。 +> - `internal/handler/gateway_test.go` 的 `nopEnqueuer.Enqueue` → `func (nopEnqueuer) Enqueue(string, string, string, string, map[string]any) error { return nil }`(多一个 `string`)。 +> - 其它实现 `WebhookEnqueuer` 的替身一并跟签名(全仓 grep `func.*Enqueue(` 核对)。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ ./internal/model/ ./internal/webhook/ ./internal/gateway/ -v` +Expected: 全 PASS(含既有 P2/P3 用例;`RefundIDUnique` 得 3 行、`Settled` 通过)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/model/webhook_delivery.go internal/model/v2.go internal/model/v2_test.go internal/store/webhook.go internal/store/webhook_test.go internal/webhook/notifier.go internal/gateway/gateway.go internal/gateway/settle.go internal/gateway/gateway_test.go internal/handler/gateway_test.go main.go +git commit -m "feat(v2): outbox 退款感知(refund_id 进唯一键)+ 投递门禁放行退款态 + Enqueue 贯通 refundID" +``` + +--- + +### Task 2: `RefundStore` + Order 退款态机(store 层纯 TDD) + +**Files:** +- Create: `internal/store/refund.go`(`RefundStore`:建单/查单/求和/条件翻转/人工待办列表) +- Modify: `internal/store/order_query.go`(`PaidAttempt` + `ApplyRefundToOrder`) +- Test: `internal/store/refund_test.go` + +**决策:** +1. **可退余额守卫**:超退检查按「**非失败**退款额之和」(`requested`/`processing`/`manual_pending`/`succeeded` 全计入)——在途退款也占额度,防并发/重复请求把一单退穿。 +2. **order 态机由「已成功退款额」驱动**:`ApplyRefundToOrder(fully)` 条件 UPDATE,`fully = succeeded 之和 ≥ 订单已付额` → `refunded`,否则 `partially_refunded`。守卫 `status IN (paid, partially_refunded, refunding)`(已 `refunded` 或未付一律 no-op,幂等)。 +3. **`PaidAttempt`**:退款针对「哪笔已付」(原路退)——取订单 `status=paid` 的 attempt,拿它的 `channel`(选渠道)+ `provider_ref`(渠道退款定位原交易)。一单只一笔 paid attempt(P2「一单 N 渠道只一个成功」)。 +4. **`MarkRefundStatus(refundID, from, to, ref, at)`**:条件 UPDATE(`WHERE refund_id=? AND status=?`),返回 `RowsAffected>0`;`to∈{succeeded,failed}` 顺带写 `completed_at`,非空 `ref` 写 `provider_refund_ref`。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/refund_test.go`: +```go +package store + +import ( + "testing" + "time" + + "github.com/wangjia/pay/internal/model" +) + +func seedPaid(t *testing.T, os *OrderStore, outNo string, amount int64) { + t.Helper() + if err := os.CreateOrder(&model.OrderV2{ + OutTradeNo: outNo, BizSystem: "pangolin", BizCode: "pro_year", + AmountMinor: amount, Currency: "CNY", Status: model.OrderPaidV2, + }); err != nil { + t.Fatal(err) + } + if err := os.CreateAttempt(&model.Attempt{ + OutTradeNo: outNo, Channel: "alipay", ProviderRef: outNo, + AmountMinor: amount, Currency: "CNY", Status: model.AttemptPaid, + }); err != nil { + t.Fatal(err) + } +} + +func TestRefundStoreLifecycle(t *testing.T) { + db := model.OpenTestDB(t) + os := NewOrderStore(db) + rs := NewRefundStore(db) + seedPaid(t, os, "PAY-1", 19900) + + // PaidAttempt 定位原交易 + att, err := os.PaidAttempt("PAY-1") + if err != nil || att.Channel != "alipay" || att.ProviderRef != "PAY-1" { + t.Fatalf("PaidAttempt = %+v, %v", att, err) + } + + // 建两笔部分退款(processing) + for _, id := range []string{"rf-1", "rf-2"} { + if err := rs.CreateRefund(&model.Refund{ + RefundID: id, OutTradeNo: "PAY-1", AttemptProviderRef: "PAY-1", + AmountMinor: 5000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business", + }); err != nil { + t.Fatal(err) + } + } + // 非失败之和 = 10000(占额度) + if sum, _ := rs.RefundSum("PAY-1", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded); sum != 10000 { + t.Fatalf("reserved sum = %d want 10000", sum) + } + + // rf-1 翻 succeeded(条件正确) + ok, err := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "ali-refund-1", time.Now()) + if err != nil || !ok { + t.Fatalf("mark rf-1 = %v, %v", ok, err) + } + // 再翻一次(from 已不符)→ no-op + if ok, _ := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "x", time.Now()); ok { + t.Fatal("重复翻转应 no-op") + } + r1, _ := rs.GetRefund("rf-1") + if r1.Status != model.RefundSucceeded || r1.ProviderRefundRef != "ali-refund-1" || r1.CompletedAt == nil { + t.Fatalf("rf-1 = %+v", r1) + } + + // 部分退:succeeded 之和 5000 < 19900 → partially_refunded + succ, _ := rs.RefundSum("PAY-1", model.RefundSucceeded) + if flipped, _ := os.ApplyRefundToOrder("PAY-1", succ >= 19900); !flipped { + t.Fatal("ApplyRefundToOrder 应翻转") + } + if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 { + t.Fatalf("order status = %s want partially_refunded", o.Status) + } + + // rf-2 也成功 → 总退 10000 仍 < 19900,保持 partially_refunded(非 fully) + _, _ = rs.MarkRefundStatus("rf-2", model.RefundProcessing, model.RefundSucceeded, "ali-refund-2", time.Now()) + succ, _ = rs.RefundSum("PAY-1", model.RefundSucceeded) + _, _ = os.ApplyRefundToOrder("PAY-1", succ >= 19900) + if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 { + t.Fatalf("order status = %s want partially_refunded", o.Status) + } +} + +func TestApplyRefundToOrderFully(t *testing.T) { + db := model.OpenTestDB(t) + os := NewOrderStore(db) + seedPaid(t, os, "PAY-2", 10000) + if _, err := os.ApplyRefundToOrder("PAY-2", true); err != nil { + t.Fatal(err) + } + if o, _ := os.GetOrder("PAY-2"); o.Status != model.OrderRefundedV2 { + t.Fatalf("status = %s want refunded", o.Status) + } +} + +func TestListManualPending(t *testing.T) { + db := model.OpenTestDB(t) + rs := NewRefundStore(db) + _ = rs.CreateRefund(&model.Refund{RefundID: "rf-m1", OutTradeNo: "C-1", AmountMinor: 1, Currency: "USDT", Status: model.RefundManualPending}) + _ = rs.CreateRefund(&model.Refund{RefundID: "rf-p1", OutTradeNo: "C-2", AmountMinor: 1, Currency: "USDT", Status: model.RefundProcessing}) + list, err := rs.ListManualPending(50) + if err != nil || len(list) != 1 || list[0].RefundID != "rf-m1" { + t.Fatalf("manual pending = %+v, %v", list, err) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ -run 'RefundStore|ApplyRefund|ManualPending' -v` +Expected: 编译失败(`RefundStore`/`PaidAttempt`/`ApplyRefundToOrder` 未定义)。 + +- [ ] **Step 3: 写实现** + +`internal/store/refund.go`: +```go +package store + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/model" +) + +var ErrRefundNotFound = errors.New("store: refund not found") + +type RefundStore struct{ db *gorm.DB } + +func NewRefundStore(db *gorm.DB) *RefundStore { return &RefundStore{db: db} } + +func (s *RefundStore) CreateRefund(r *model.Refund) error { + if r.RefundID == "" { + return fmt.Errorf("store.CreateRefund: empty RefundID") + } + if err := s.db.Create(r).Error; err != nil { + return fmt.Errorf("store.CreateRefund: %w", err) + } + return nil +} + +func (s *RefundStore) GetRefund(refundID string) (*model.Refund, error) { + var r model.Refund + if err := s.db.Where("refund_id = ?", refundID).First(&r).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrRefundNotFound + } + return nil, fmt.Errorf("store.GetRefund: %w", err) + } + return &r, nil +} + +// RefundSum sums amount_minor of an order's refunds in the given statuses +// (no statuses = all). 用两种口径:守卫查非失败之和(占额度),态机查 succeeded 之和。 +func (s *RefundStore) RefundSum(outTradeNo string, statuses ...model.RefundStatus) (int64, error) { + var total int64 + q := s.db.Model(&model.Refund{}).Where("out_trade_no = ?", outTradeNo) + if len(statuses) > 0 { + q = q.Where("status IN ?", statuses) + } + if err := q.Select("COALESCE(SUM(amount_minor),0)").Scan(&total).Error; err != nil { + return 0, fmt.Errorf("store.RefundSum: %w", err) + } + return total, nil +} + +// MarkRefundStatus flips a refund from an expected status to a new one +// (conditional UPDATE + RowsAffected). Returns false if not in the from-status. +func (s *RefundStore) MarkRefundStatus(refundID string, from, to model.RefundStatus, providerRefundRef string, at time.Time) (bool, error) { + updates := map[string]any{"status": to} + if providerRefundRef != "" { + updates["provider_refund_ref"] = providerRefundRef + } + if to == model.RefundSucceeded || to == model.RefundFailed { + updates["completed_at"] = at + } + res := s.db.Model(&model.Refund{}). + Where("refund_id = ? AND status = ?", refundID, from). + Updates(updates) + if res.Error != nil { + return false, fmt.Errorf("store.MarkRefundStatus: %w", res.Error) + } + return res.RowsAffected > 0, nil +} + +// ListManualPending lists refunds awaiting manual (crypto) settlement. +func (s *RefundStore) ListManualPending(limit int) ([]model.Refund, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + var out []model.Refund + if err := s.db.Where("status = ?", model.RefundManualPending). + Order("id ASC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListManualPending: %w", err) + } + return out, nil +} +``` + +`internal/store/order_query.go` —— 追加两个方法(文件已 import `errors`/`gorm`/`model`,已有 `ErrAttemptNotFound`): +```go +// PaidAttempt returns the settled (paid) attempt of an order — the payment a +// refund reverses (channel + provider_ref for the original transaction). +func (s *OrderStore) PaidAttempt(outTradeNo string) (*model.Attempt, error) { + var a model.Attempt + err := s.db.Where("out_trade_no = ? AND status = ?", outTradeNo, model.AttemptPaid).First(&a).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAttemptNotFound + } + return nil, fmt.Errorf("store.PaidAttempt: %w", err) + } + return &a, nil +} + +// ApplyRefundToOrder advances a settled order's status per cumulative refunds: +// fully refunded → refunded, else → partially_refunded. Guard: only from a +// post-paid, non-fully-refunded state (paid / partially_refunded / refunding). +func (s *OrderStore) ApplyRefundToOrder(outTradeNo string, fully bool) (bool, error) { + next := model.OrderPartRefundedV2 + if fully { + next = model.OrderRefundedV2 + } + res := s.db.Model(&model.OrderV2{}). + Where("out_trade_no = ? AND status IN ?", outTradeNo, + []model.OrderStatusV2{model.OrderPaidV2, model.OrderPartRefundedV2, model.OrderRefundingV2}). + Update("status", next) + if res.Error != nil { + return false, fmt.Errorf("store.ApplyRefundToOrder: %w", res.Error) + } + return res.RowsAffected > 0, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ -v` +Expected: 全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/store/refund.go internal/store/refund_test.go internal/store/order_query.go +git commit -m "feat(v2): RefundStore(建单/求和/条件翻转/人工待办)+ Order 退款态机(PaidAttempt/ApplyRefundToOrder)" +``` + +--- + +### Task 3: `RefundingProvider` 签名扩展 + `gateway.Refund` 编排(含 crypto 人工向 + 人工闭环) + +**Files:** +- Modify: `internal/provider/provider.go`(`RefundingProvider.Refund` 加 `refundID` 幂等键参) +- Modify: `internal/provider/fake/fake.go`(加退款测试缝:`EnableRefund` + `Refund` 实现) +- Modify: `internal/gateway/gateway.go`(`Gateway` 加 `*store.RefundStore` 依赖;`New` 加参) +- Create: `internal/gateway/refund.go`(`Refund` / `CompleteManualRefund` / `GetRefund` / `ListManualPendingRefunds` 编排) +- Modify: `main.go`(装配 `refundStore` 传入 `gateway.New`) +- Test: `internal/gateway/refund_test.go` + +**决策(核心):** +1. **`RefundingProvider.Refund` 加 `refundID` 参**(P2 只声明未落地,无实现者,现扩展零破坏): + ```go + Refund(ctx, providerRef, refundID string, amountMinor int64, reason string) (refundRef string, status PaidStatus, err error) + ``` + `refundID` = pay 侧 refund 单号,作**渠道幂等键**:alipay `OutRequestNo`(部分退款必传且须唯一稳定,重试不重复退)、stripe `Idempotency-Key`。没有它就无法给「同单多次部分退」做幂等——这是设计文档 §6「统一收敛成 refund 事件」落地的必需项。 +2. **退款事件幂等键 = refund 单号**(承 Task 1):`enqueueRefundEvent` 走 `Enqueue(..., eventType, r.RefundID, data)`,payload 含 `refund_id`/`provider_refund_ref`/`amount_minor`/`reason`。同单多次部分退各自成 outbox 行、各自投递。 +3. **crypto 人工向最小闭环**:crypto 不实现 `RefundingProvider`(且 `SupportsRefund=false`)→ `Refund` 建 `manual_pending` 单**即返回**(不动 order 态、不发事件——钱还没退)。运营链上转账后调 `CompleteManualRefund(refundID, tx)` 回填 → 走与渠道成功**同一条** `settleRefundSucceeded`(enqueue-before-flip + 态机推进 + `refund.succeeded` 事件)。`ListManualPendingRefunds` 供运营捞待办。 +4. **order 态机翻转**:仅在退款**成功落地**时(`settleRefundSucceeded`)按「succeeded 之和」推进 `partially_refunded`/`refunded`。发起时不预翻 `refunding`(避免异步 pending 卡在 refunding 需回滚);态机只在有确定资金结果时前进。 +5. **同步/异步结果归一**:`Refund()` 返回 `PaidSucceeded`→落地成功;`PaidFailed`→建过的 processing 单翻 failed + 发 `refund.failed`;`PaidPending`(stripe `requires_action` 等异步)→保持 `processing`,交 `CompleteManualRefund` 或 P6 退款查单收敛(记 Self-Review,P4 不做异步退款查单 job)。 +6. **归属校验**:`RefundInput.BizSystem` 非空时须等于 `order.BizSystem`,防业务 A 退业务 B 的单。 + +- [ ] **Step 1: 写失败测试** + +`internal/gateway/refund_test.go`: +```go +package gateway_test + +import ( + "context" + "errors" + "testing" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" +) + +// 把 fake 订单推到 paid(复用 P2/P3 的 SyncPendingAttempts + SetQueryResult 路径)。 +func createAndPay(t *testing.T, g *gateway.Gateway, fp interface { + SetQueryResult(string, provider.PaidEvent) +}, orders interface { + ListAttemptsByStatus(model.AttemptStatus, int) ([]model.Attempt, error) +}) string { + t.Helper() + res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{ + SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ + ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, + PaidAmountMinor: 29990000, PaidCurrency: "USDT", + }) + if _, err := g.SyncPendingAttempts(context.Background(), 10); err != nil { + t.Fatalf("sync: %v", err) + } + return res.OrderNo +} + +func TestRefundPartialThenFull(t *testing.T) { + g, fp, spy, orders := newGateway(t) + fp.EnableRefund("fake-refund-ref", provider.PaidSucceeded, nil) // fake 变可退渠道,同步成功 + no := createAndPay(t, g, fp, orders) + + // 部分退 1/3(总价 29990000) + r1, err := g.Refund(context.Background(), gateway.RefundInput{ + OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin", + }) + if err != nil || r1.Status != string(model.RefundSucceeded) { + t.Fatalf("refund1 = %+v, %v", r1, err) + } + if o, _ := orders.GetOrder(no); o.Status != model.OrderPartRefundedV2 { + t.Fatalf("after partial: order = %s want partially_refunded", o.Status) + } + // 退款事件已入队(refund.succeeded,带 refund_id) + last := spy.calls[len(spy.calls)-1] + if last["event_type"] != "refund.succeeded" || last["refund_id"] != r1.RefundID { + t.Fatalf("refund webhook = %+v", last) + } + + // 超退守卫:再退 25000000 > 剩余 20000000 → 拒 + if _, err := g.Refund(context.Background(), gateway.RefundInput{ + OutTradeNo: no, AmountMinor: 25000000, BizSystem: "pangolin", + }); !errors.Is(err, gateway.ErrRefundAmountInvalid) { + t.Fatalf("over-refund err = %v want ErrRefundAmountInvalid", err) + } + + // 退剩余 → refunded + r2, err := g.Refund(context.Background(), gateway.RefundInput{ + OutTradeNo: no, AmountMinor: 20000000, BizSystem: "pangolin", + }) + if err != nil || r2.Status != string(model.RefundSucceeded) { + t.Fatalf("refund2 = %+v, %v", r2, err) + } + if o, _ := orders.GetOrder(no); o.Status != model.OrderRefundedV2 { + t.Fatalf("after full: order = %s want refunded", o.Status) + } +} + +func TestRefundNotRefundableWhenPending(t *testing.T) { + g, _, _, _ := newGateway(t) + res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake"}) + if _, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 1}); !errors.Is(err, gateway.ErrOrderNotRefundable) { + t.Fatalf("err = %v want ErrOrderNotRefundable", err) + } +} + +func TestRefundManualForCryptoLikeChannel(t *testing.T) { + g, fp, spy, orders := newGateway(t) + // fp 不 EnableRefund → SupportsRefund=false → 建 manual_pending,不动 order、不发事件 + no := createAndPay(t, g, fp, orders) + nCalls := len(spy.calls) + r, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: no, AmountMinor: 9990000, BizSystem: "pangolin"}) + if err != nil || r.Status != string(model.RefundManualPending) { + t.Fatalf("manual refund = %+v, %v", r, err) + } + if o, _ := orders.GetOrder(no); o.Status != model.OrderPaidV2 { + t.Fatalf("manual pending 不应动 order,得 %s", o.Status) + } + if len(spy.calls) != nCalls { + t.Fatal("manual pending 不应发退款事件") + } + // 待办可捞 + list, _ := g.ListManualPendingRefunds(10) + if len(list) != 1 || list[0].RefundID != r.RefundID { + t.Fatalf("manual list = %+v", list) + } + // 运营回填完成 → 成功落地 + 事件 + 态机 + done, err := g.CompleteManualRefund(context.Background(), r.RefundID, "tron-tx-hash") + if err != nil || done.Status != string(model.RefundSucceeded) { + t.Fatalf("complete = %+v, %v", done, err) + } + if o, _ := orders.GetOrder(no); o.Status != model.OrderPartRefundedV2 { + t.Fatalf("after complete: order = %s want partially_refunded", o.Status) + } + last := spy.calls[len(spy.calls)-1] + if last["event_type"] != "refund.succeeded" || last["provider_refund_ref"] != "tron-tx-hash" { + t.Fatalf("complete webhook = %+v", last) + } +} +``` + +> `newGateway` 现返回 `(*gateway.Gateway, *fake.Provider, *spyEnqueuer, *store.OrderStore)`——本 Task 给 `fake.Provider` 加 `EnableRefund`,并在 `newGateway` 里补 `refundStore` 装配(见实现)。`spyEnqueuer.Enqueue` 已在 Task 1 跟到含 `refundID` 的签名;其 `data` 里含 `refund_id` 键即断言依据。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -run 'Refund' -v` +Expected: 编译失败(`Gateway.Refund`/`ErrRefundAmountInvalid`/`fake.EnableRefund`/`gateway.New` 参数不符)。 + +- [ ] **Step 3: 写实现** + +`internal/provider/provider.go` —— `RefundingProvider` 加 `refundID`: +```go +// RefundingProvider — 可选:支持渠道退款的 Provider 额外实现(P4)。refundID 为 pay 侧 +// 退款单号,作渠道幂等键(alipay out_request_no / stripe Idempotency-Key)——同单多次 +// 部分退款靠它去重,重试不重复退。不支持退款的渠道不实现本接口(capabilities=false)。 +type RefundingProvider interface { + Provider + Refund(ctx context.Context, providerRef, refundID string, amountMinor int64, reason string) (refundRef string, status PaidStatus, err error) +} +``` + +`internal/provider/fake/fake.go` —— 加退款缝(`Provider` 结构体加字段、`Capabilities` 用字段、加 `EnableRefund`+`Refund`): +```go +type Provider struct { + mu sync.Mutex + queryResults map[string]provider.PaidEvent + + supportsRefund bool + refundRef string + refundStatus provider.PaidStatus + refundErr error +} +``` +`New` 不变(零值 `supportsRefund=false`)。`Capabilities` 的 `SupportsRefund` 改用字段: +```go +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderCryptoAddress}, + SupportsRefund: p.supportsRefund, + SettleCurrencies: []string{"USDT"}, + Regions: []string{"global"}, + } +} +``` +文件末尾追加: +```go +// EnableRefund 令 fake 表现为可退渠道并预置一次退款结果(测试缝)。 +func (p *Provider) EnableRefund(refundRef string, status provider.PaidStatus, err error) { + p.mu.Lock() + defer p.mu.Unlock() + p.supportsRefund = true + p.refundRef = refundRef + p.refundStatus = status + p.refundErr = err +} + +func (p *Provider) Refund(_ context.Context, _ /*providerRef*/, _ /*refundID*/ string, _ int64, _ string) (string, provider.PaidStatus, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.refundErr != nil { + return "", provider.PaidFailed, p.refundErr + } + return p.refundRef, p.refundStatus, nil +} +``` + +`internal/gateway/gateway.go` —— `Gateway` 加 `refunds` 字段;`New` 加参: +```go +type Gateway struct { + orders *store.OrderStore + refunds *store.RefundStore + providers *provider.Registry + picker accounts.Picker + products ProductResolver + webhook WebhookEnqueuer + region string +} + +func New(orders *store.OrderStore, refunds *store.RefundStore, providers *provider.Registry, picker accounts.Picker, + products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway { + return &Gateway{orders: orders, refunds: refunds, providers: providers, picker: picker, + products: products, webhook: webhook, region: region} +} +``` + +`internal/gateway/refund.go`(新建): +```go +package gateway + +import ( + "context" + "errors" + "time" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/util" +) + +var ( + ErrOrderNotRefundable = errors.New("gateway: order not refundable (not settled or fully refunded)") + ErrRefundAmountInvalid = errors.New("gateway: refund amount invalid or exceeds refundable balance") + ErrRefundNotManual = errors.New("gateway: refund is not awaiting manual settlement") +) + +// reservedStatuses 计入可退余额守卫(非失败:在途 + 已成功都占额度)。 +var reservedStatuses = []model.RefundStatus{ + model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded, +} + +type RefundInput struct { + OutTradeNo string + AmountMinor int64 + Reason string + BizSystem string // 非空则须等于 order.BizSystem(归属校验);平台发起可空 + InitiatedBy string // business/platform;空默认 business +} + +type RefundResult struct { + RefundID string `json:"refund_id"` + Status string `json:"status"` +} + +// Refund 业务发起退款:校验 → 建单 → 渠道退款(或 crypto 人工向)→ 幂等落状态 + 态机 + 事件。 +func (g *Gateway) Refund(ctx context.Context, in RefundInput) (*RefundResult, error) { + o, err := g.orders.GetOrder(in.OutTradeNo) + if err != nil { + return nil, err // ErrOrderNotFound + } + if in.BizSystem != "" && o.BizSystem != in.BizSystem { + return nil, ErrOrderNotRefundable // 非本业务的单 + } + if !o.Status.Settled() || o.Status == model.OrderRefundedV2 { + return nil, ErrOrderNotRefundable + } + if in.AmountMinor <= 0 { + return nil, ErrRefundAmountInvalid + } + reserved, err := g.refunds.RefundSum(in.OutTradeNo, reservedStatuses...) + if err != nil { + return nil, err + } + if reserved+in.AmountMinor > o.AmountMinor { + return nil, ErrRefundAmountInvalid + } + att, err := g.orders.PaidAttempt(in.OutTradeNo) + if err != nil { + return nil, err // ErrAttemptNotFound + } + if in.InitiatedBy == "" { + in.InitiatedBy = "business" + } + refundID := util.NewOutTradeNo("rf") + prov, err := g.providers.Get(att.Channel) + if err != nil { + return nil, err + } + + rp, canRefund := prov.(provider.RefundingProvider) + if !canRefund || !prov.Capabilities().SupportsRefund { + // crypto 自托管:无退款 API → 建"待人工"单即返回(钱未退,不动 order/不发事件)。 + r := &model.Refund{ + RefundID: refundID, OutTradeNo: in.OutTradeNo, AttemptProviderRef: att.ProviderRef, + AmountMinor: in.AmountMinor, Currency: o.Currency, Reason: in.Reason, + Status: model.RefundManualPending, InitiatedBy: in.InitiatedBy, + } + if err := g.refunds.CreateRefund(r); err != nil { + return nil, err + } + return &RefundResult{RefundID: refundID, Status: string(model.RefundManualPending)}, nil + } + + // 渠道退款:先建 processing 单(refundID 作渠道幂等键),再调渠道 API。 + r := &model.Refund{ + RefundID: refundID, OutTradeNo: in.OutTradeNo, AttemptProviderRef: att.ProviderRef, + AmountMinor: in.AmountMinor, Currency: o.Currency, Reason: in.Reason, + Status: model.RefundProcessing, InitiatedBy: in.InitiatedBy, + } + if err := g.refunds.CreateRefund(r); err != nil { + return nil, err + } + refundRef, status, rerr := rp.Refund(ctx, att.ProviderRef, refundID, in.AmountMinor, in.Reason) + if rerr != nil { + _, _ = g.refunds.MarkRefundStatus(refundID, model.RefundProcessing, model.RefundFailed, "", time.Now()) + _ = g.enqueueRefundEvent(o, att, r, "refund.failed", "") + return &RefundResult{RefundID: refundID, Status: string(model.RefundFailed)}, rerr + } + switch status { + case provider.PaidSucceeded: + if err := g.settleRefundSucceeded(o, att, r, refundRef); err != nil { + return nil, err + } + return &RefundResult{RefundID: refundID, Status: string(model.RefundSucceeded)}, nil + case provider.PaidFailed: + _, _ = g.refunds.MarkRefundStatus(refundID, model.RefundProcessing, model.RefundFailed, refundRef, time.Now()) + _ = g.enqueueRefundEvent(o, att, r, "refund.failed", refundRef) + return &RefundResult{RefundID: refundID, Status: string(model.RefundFailed)}, nil + default: // PaidPending:stripe requires_action 等异步 → 保持 processing,交人工/P6 收敛 + return &RefundResult{RefundID: refundID, Status: string(model.RefundProcessing)}, nil + } +} + +// settleRefundSucceeded 复刻 settle 的「先入队后翻转」:先幂等入队 refund.succeeded +// (dedupe=refund_id,同单多次部分退不撞键),再翻转 refund 单 + 推进 order 退款态机。 +// 顺序不变量:refund 为 succeeded ⇒ outbox 行必已存在。 +func (g *Gateway) settleRefundSucceeded(o *model.OrderV2, att *model.Attempt, r *model.Refund, refundRef string) error { + if err := g.enqueueRefundEvent(o, att, r, "refund.succeeded", refundRef); err != nil { + return err + } + if _, err := g.refunds.MarkRefundStatus(r.RefundID, r.Status, model.RefundSucceeded, refundRef, time.Now()); err != nil { + return err + } + succeeded, err := g.refunds.RefundSum(o.OutTradeNo, model.RefundSucceeded) + if err != nil { + return err + } + _, err = g.orders.ApplyRefundToOrder(o.OutTradeNo, succeeded >= o.AmountMinor) + return err +} + +func (g *Gateway) enqueueRefundEvent(o *model.OrderV2, att *model.Attempt, r *model.Refund, eventType, refundRef string) error { + if o.BizSystem == "" { + return nil // 独立收款无业务方回调 + } + data := map[string]any{ + "event_type": eventType, + "out_trade_no": o.OutTradeNo, + "biz_system": o.BizSystem, + "biz_ref": o.BizRef, + "product_biz_code": o.BizCode, + "refund_id": r.RefundID, + "provider_refund_ref": refundRef, + "amount_minor": r.AmountMinor, + "currency": r.Currency, + "channel": att.Channel, + "reason": r.Reason, + } + return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, eventType, r.RefundID, data) +} + +// CompleteManualRefund 收口 crypto 人工退款:运营链上转账后回填 tx,走与渠道成功同一落地路径。 +func (g *Gateway) CompleteManualRefund(_ context.Context, refundID, providerRefundRef string) (*RefundResult, error) { + r, err := g.refunds.GetRefund(refundID) + if err != nil { + return nil, err // ErrRefundNotFound + } + if r.Status != model.RefundManualPending { + return nil, ErrRefundNotManual + } + o, err := g.orders.GetOrder(r.OutTradeNo) + if err != nil { + return nil, err + } + att, err := g.orders.PaidAttempt(r.OutTradeNo) + if err != nil { + return nil, err + } + if err := g.settleRefundSucceeded(o, att, r, providerRefundRef); err != nil { + return nil, err + } + return &RefundResult{RefundID: refundID, Status: string(model.RefundSucceeded)}, nil +} + +func (g *Gateway) ListManualPendingRefunds(limit int) ([]model.Refund, error) { + return g.refunds.ListManualPending(limit) +} + +// RefundStatusView / GetRefund 供 HTTP 查询退款状态(Task 6)。 +type RefundStatusView struct { + RefundID string `json:"refund_id"` + OutTradeNo string `json:"out_trade_no"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + Status string `json:"status"` + ProviderRefundRef string `json:"provider_refund_ref,omitempty"` +} + +func (g *Gateway) GetRefund(refundID string) (*RefundStatusView, error) { + r, err := g.refunds.GetRefund(refundID) + if err != nil { + return nil, err + } + return &RefundStatusView{ + RefundID: r.RefundID, OutTradeNo: r.OutTradeNo, AmountMinor: r.AmountMinor, + Currency: r.Currency, Status: string(r.Status), ProviderRefundRef: r.ProviderRefundRef, + }, nil +} +``` + +`internal/gateway/gateway_test.go` —— `newGateway` 装配 `refundStore` 并传入(改动最小): +```go +func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *store.OrderStore) { + t.Helper() + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + preg := provider.NewRegistry() + fp := fake.New() + preg.Register(fp) + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + {AccountID: "fake-a2", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + spy := &spyEnqueuer{} + g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global") + return g, fp, spy, orders +} +``` + +`main.go` —— 装配 refundStore 传入 `gateway.New`: +```go + orderStore := store.NewOrderStore(db) + refundStore := store.NewRefundStore(db) + webhookStore := store.NewWebhookStore(db) + // ...(notifier 等不变)... + gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn") +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/gateway/ ./internal/provider/... -v` +Expected: 全 PASS(含 P2/P3 既有;`TestRefundPartialThenFull`/`TestRefundManualForCryptoLikeChannel`/`TestRefundNotRefundableWhenPending`)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/provider.go internal/provider/fake/fake.go internal/gateway/gateway.go internal/gateway/gateway_test.go internal/gateway/refund.go internal/gateway/refund_test.go main.go +git commit -m "feat(v2): gateway.Refund 编排(部分/多次累计 + 超退守卫 + crypto 人工向闭环)+ RefundingProvider 加 refundID 幂等键" +``` + +--- + +### Task 4: 支付宝 adapter 落地 `RefundingProvider`(`TradeRefund` 同步退款) + +**Files:** +- Modify: `internal/provider/alipay/alipay.go`(`Capabilities().SupportsRefund=true` + `Refund` 方法) +- Test: `internal/provider/alipay/alipay_refund_test.go`(临时 RSA 密钥 + `httptest` 假支付宝网关,离线) + +**决策:** +- **`Client.TradeRefund(ctx, sw.TradeRefund{...})`**:`OutTradeNo=providerRef`(支付宝以商户单号归位,P3 里 alipay 的 `providerRef==OutTradeNo`)、`RefundAmount=money.Format(amount,"CNY")`(元 string)、`RefundReason=reason`、**`OutRequestNo=refundID`**(部分退款必传且须唯一稳定 → 用 pay 侧退款单号,天然幂等:同 `out_request_no` 重复请求支付宝按同一笔处理)。 +- **同步结果**:支付宝退款是**同步接口**——`rsp.IsFailure()==false` 即受理成功(`FundChange` 为 `Y`/`N` 都算成功:`N` 表示重复退款已幂等,不是失败)。返回 `(refundRef=refundID, PaidSucceeded, nil)`;`refundRef` 取 pay 侧退款单号(= 支付宝 `out_request_no`,退款在支付宝侧的定位键)。失败返回 `("", PaidFailed, err)`(`sw.Error` 自带 `Error()`)。 +- **离线测法**:P3 里 alipay client 用 `sw.New(...)` + `LoadAliPayPublicKey`;退款是**主动请求**(非验签回调),`TradeRefund` 会 POST 到支付宝网关。用 `sw.New(appID, appPriv, false, sw.WithGateway(ts.URL))` 把网关指向 `httptest`,返回一份**用"支付宝侧"私钥签名**的 `alipay_trade_refund_response`,client 内部会用装的支付宝公钥验签响应。`WithGateway` 见 smartwalle v3.2.29(client option)。签名响应体的构造在测试里给出 helper(与 P3 `alipay_test.go` 的 `signRSA2` 同款,只是签的是 response node)。 + +- [ ] **Step 1: 写失败测试** + +`internal/provider/alipay/alipay_refund_test.go`: +```go +package alipay_test + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + sw "github.com/smartwalle/alipay/v3" + + "github.com/wangjia/pay/internal/provider" + ali "github.com/wangjia/pay/internal/provider/alipay" +) + +// fakeAlipayRefund 返回一份用"支付宝侧"私钥签名的 alipay.trade.refund 响应。 +func fakeAlipayRefund(t *testing.T, aliPriv *rsa.PrivateKey, fundChange string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + node := map[string]any{ + "code": "10000", "msg": "Success", + "trade_no": "2021AAA", "out_trade_no": "PAY-1", + "refund_fee": "199.00", "fund_change": fundChange, + } + nodeJSON, _ := json.Marshal(node) + // 支付宝对 response node 的原文做 RSA2 签名(sign_type=RSA2)。 + h := sha256.Sum256(nodeJSON) + sig, _ := rsa.SignPKCS1v15(rand.Reader, aliPriv, crypto.SHA256, h[:]) + resp := map[string]any{ + "alipay_trade_refund_response": json.RawMessage(nodeJSON), + "sign": base64.StdEncoding.EncodeToString(sig), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) +} + +func buildRefundClient(t *testing.T, gateway string, aliPub *rsa.PublicKey) *sw.Client { + app, _ := rsa.GenerateKey(rand.Reader, 2048) + c, err := sw.New("2021000000000000", base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(app)), false, sw.WithGateway(gateway)) + if err != nil { + t.Fatalf("new client: %v", err) + } + if err := c.LoadAliPayPublicKey(base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PublicKey(aliPub))); err != nil { + t.Fatalf("load pub: %v", err) + } + return c +} + +func TestAlipayRefundSyncSuccess(t *testing.T) { + aliKey, _ := rsa.GenerateKey(rand.Reader, 2048) + ts := fakeAlipayRefund(t, aliKey, "Y") + defer ts.Close() + p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey)) + + if !p.Capabilities().SupportsRefund { + t.Fatal("alipay Capabilities.SupportsRefund 应为 true") + } + ref, status, err := p.Refund(context.Background(), "PAY-1", "rf-123", 19900, "用户申请") + if err != nil { + t.Fatalf("refund: %v", err) + } + if status != provider.PaidSucceeded || ref != "rf-123" { + t.Fatalf("refund result: ref=%s status=%s", ref, status) + } +} +``` + +> 若 `sw.WithGateway` 在 v3.2.29 的 option 名不同,查 `go doc github.com/smartwalle/alipay/v3` 定位设置网关的 client option(v3 提供 `WithGateway`/`Client.SetGateway` 之一),按实到名接;测试目的是**不打真网**。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/alipay/ -run 'Refund' -v` +Expected: 编译失败(`alipay.Provider` 无 `Refund` 方法 / `SupportsRefund=false`)。 + +- [ ] **Step 3: 写实现** + +`internal/provider/alipay/alipay.go` —— `Capabilities` 翻 `true`;文件末尾加 `Refund`: +```go +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderRedirect}, + SupportsRefund: true, // P4:alipay.trade.refund 同步退款 + SettleCurrencies: []string{"CNY"}, + Regions: []string{"cn"}, + } +} + +// Refund 调 alipay.trade.refund(同步接口)。providerRef=out_trade_no(alipay 归位键), +// refundID 作 out_request_no(部分退款必传且须唯一稳定 → 幂等)。IsFailure()==false 即成功 +// (FundChange=N 表重复退款已幂等,仍算成功)。 +func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amountMinor int64, reason string) (string, provider.PaidStatus, error) { + amt, err := money.Format(amountMinor, "CNY") + if err != nil { + return "", provider.PaidFailed, err + } + rsp, err := p.client.TradeRefund(ctx, sw.TradeRefund{ + OutTradeNo: providerRef, + RefundAmount: amt, + RefundReason: reason, + OutRequestNo: refundID, + }) + if err != nil { + return "", provider.PaidFailed, fmt.Errorf("alipay: 退款请求失败: %w", err) + } + if rsp.IsFailure() { + return "", provider.PaidFailed, fmt.Errorf("alipay: 退款被拒: %w", rsp.Error) + } + // 退款在支付宝侧以 out_request_no 定位;refundRef 回传我方退款单号(= out_request_no)。 + return refundID, provider.PaidSucceeded, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/alipay/ -v` +Expected: 全 PASS(含 P3 既有 Create/VerifyCallback 用例)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/alipay/ +git commit -m "feat(v2): 支付宝 adapter 落地 RefundingProvider(TradeRefund 同步退款,out_request_no=refund_id 幂等)" +``` + +--- + +### Task 5: Stripe adapter 落地 `RefundingProvider`(session→payment_intent→`Refunds.New`) + +**Files:** +- Modify: `internal/provider/stripe/stripe.go`(`Capabilities().SupportsRefund=true` + `Refund` + `mapRefundStatus`) +- Test: `internal/provider/stripe/stripe_refund_test.go`(`httptest` 假 Stripe:GET session 取 payment_intent + POST /v1/refunds) + +**决策:** +- **两跳**:P3 里 stripe `providerRef` = Checkout Session ID(`cs_...`),退款需 PaymentIntent。先 `sc.CheckoutSessions.Get(providerRef, nil)` 拿 `sess.PaymentIntent.ID`(未展开时 SDK 也把 id 反序列化进 `*PaymentIntent{ID}`),再 `sc.Refunds.New(&RefundParams{PaymentIntent, Amount})`。 +- **幂等键**:`params.SetIdempotencyKey(refundID)`(Stripe 幂等头)——重试不重复退。 +- **不传 `Reason`**:Stripe `RefundParams.Reason` 只收枚举(`duplicate`/`fraudulent`/`requested_by_customer`),业务自由文案会被拒;`reason` 只落 pay 本地 `Refund.Reason`,不传给 Stripe(决策写明)。 +- **状态归一** `mapRefundStatus`:`succeeded→PaidSucceeded`;`pending`/`requires_action→PaidPending`(异步,gateway 保持 processing);`failed`/`canceled→PaidFailed`。`refundRef = rf.ID`。 +- **离线测法**:沿用 P3 `stripe_test.go` 的 `newStripe`(`gostripe.Backends{API: GetBackendWithConfig(URL: ts.URL)}` 注入 httptest 后端),假服务器处理 `GET /v1/checkout/sessions/{id}`(回带 `payment_intent`)与 `POST /v1/refunds`。 + +- [ ] **Step 1: 写失败测试** + +`internal/provider/stripe/stripe_refund_test.go`: +```go +package stripe_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gostripe "github.com/stripe/stripe-go/v79" + "github.com/stripe/stripe-go/v79/client" + + "github.com/wangjia/pay/internal/provider" + st "github.com/wangjia/pay/internal/provider/stripe" +) + +func fakeStripeRefundAPI(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"): + // 未展开:payment_intent 是字符串 id,SDK 反序列化进 *PaymentIntent{ID} + fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","payment_intent":"pi_test_456","amount_total":2999,"currency":"usd","payment_status":"paid"}`) + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/refunds"): + fmt.Fprint(w, `{"id":"re_test_789","object":"refund","amount":2999,"currency":"usd","payment_intent":"pi_test_456","status":"succeeded"}`) + default: + http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound) + } + })) +} + +func newStripeRefund(t *testing.T, ts *httptest.Server) *st.Provider { + backends := &gostripe.Backends{ + API: gostripe.GetBackendWithConfig(gostripe.APIBackend, &gostripe.BackendConfig{URL: gostripe.String(ts.URL)}), + } + return st.New(client.New("sk_test_x", backends), "whsec_test_secret") +} + +func TestStripeRefundSuccess(t *testing.T) { + ts := fakeStripeRefundAPI(t) + defer ts.Close() + p := newStripeRefund(t, ts) + + if !p.Capabilities().SupportsRefund { + t.Fatal("stripe Capabilities.SupportsRefund 应为 true") + } + ref, status, err := p.Refund(context.Background(), "cs_test_123", "rf-999", 2999, "requested_by_customer") + if err != nil { + t.Fatalf("refund: %v", err) + } + if status != provider.PaidSucceeded || ref != "re_test_789" { + t.Fatalf("refund result: ref=%s status=%s", ref, status) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/stripe/ -run 'Refund' -v` +Expected: 编译失败(`stripe.Provider` 无 `Refund` / `SupportsRefund=false`)。 + +- [ ] **Step 3: 写实现** + +`internal/provider/stripe/stripe.go` —— `Capabilities` 翻 `true`;加 `Refund` + `mapRefundStatus`: +```go +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderRedirect}, + SupportsRefund: true, // P4:/v1/refunds + SettleCurrencies: []string{supportedCurrency}, + Regions: []string{"global"}, + } +} + +// Refund 经 Checkout Session 取 PaymentIntent 再退款。refundID 作 Idempotency-Key。 +// 不传 Stripe Reason(仅收枚举);业务文案只落 pay 本地。 +func (p *Provider) Refund(_ context.Context, providerRef, refundID string, amountMinor int64, _ string) (string, provider.PaidStatus, error) { + sess, err := p.sc.CheckoutSessions.Get(providerRef, nil) + if err != nil { + return "", provider.PaidFailed, fmt.Errorf("stripe: 取 session 失败: %w", err) + } + if sess.PaymentIntent == nil || sess.PaymentIntent.ID == "" { + return "", provider.PaidFailed, fmt.Errorf("stripe: session %s 无 payment_intent,无法退款", providerRef) + } + params := &gostripe.RefundParams{ + PaymentIntent: gostripe.String(sess.PaymentIntent.ID), + Amount: gostripe.Int64(amountMinor), // cent = USD minor,直传 + } + params.SetIdempotencyKey(refundID) + rf, err := p.sc.Refunds.New(params) + if err != nil { + return "", provider.PaidFailed, fmt.Errorf("stripe: 退款请求失败: %w", err) + } + return rf.ID, mapRefundStatus(rf.Status), nil +} + +func mapRefundStatus(s gostripe.RefundStatus) provider.PaidStatus { + switch s { + case gostripe.RefundStatusSucceeded: + return provider.PaidSucceeded + case gostripe.RefundStatusFailed, gostripe.RefundStatusCanceled: + return provider.PaidFailed + default: // pending / requires_action → 异步,交上层保持 processing + return provider.PaidPending + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/stripe/ -v` +Expected: 全 PASS(含 P3 既有 Create/Query/VerifyCallback)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/stripe/ +git commit -m "feat(v2): Stripe adapter 落地 RefundingProvider(session→payment_intent→Refunds.New,refund_id 作幂等键)" +``` + +--- + +### Task 6: HTTP 端点 + 路由 + 装配(业务发起验签 / 状态查询 / 人工闭环) + +**Files:** +- Create: `internal/handler/refund.go`(`CreateRefund` / `GetRefund` / `ListManualRefunds` / `CompleteRefund`) +- Modify: `internal/router/router.go`(`SetupV2` 加退款路由 + admin 组) +- Test: `internal/handler/refund_test.go`(签名下单→付→退款端到端;admin 人工闭环;超退/未授权拒) + +**决策:** +- **`POST /api/v2/refunds`**:业务发起,body `{out_trade_no, amount_minor, reason?, biz_system}`;`biz_system` 非空且 HMAC 验签(复用 `verifyBizSign`,与 `POST /orders` 同惯例)。`InitiatedBy="business"`,`BizSystem=req.BizSystem`(gateway 内做归属校验)。错误映射:`ErrOrderNotFound→404`、`ErrOrderNotRefundable→409`、`ErrRefundAmountInvalid→422`、`ErrAttemptNotFound→409`、验签失败→401、其它→500。 +- **`GET /api/v2/refunds/:refund_id`**:查退款状态(公开只读,与 `GET /orders/:order_no` 同惯例;不泄敏)。 +- **人工闭环走独立 admin 组** `/api/v2/admin`,env `PAY_ADMIN_TOKEN` + `X-Pay-Admin` 头(`crypto/subtle` 定长比较;token 未设→一律 403 视为禁用)。`GET /api/v2/admin/manual-refunds`(捞待办)、`POST /api/v2/admin/refunds/:refund_id/complete`(body `{provider_refund_ref}`)。**路由分组避坑**:gin 不允许同层「静态段」与「:param 段」共存,故 admin 组里用 `manual-refunds`(静态)与 `refunds/:refund_id/complete`(param 在 `refunds` 下)两条不同首段路径,`/api/v2/refunds` 与 `/api/v2/refunds/:refund_id` 在 v2 组内也无静态兄弟,均不冲突。 +- **平台发起(IAP/Stripe refund webhook)** 归 P8(拒付 chargeback)与后续:P4 不接平台异步退款通知(Stripe `charge.refunded`/`refund.updated` 事件分发),记 Self-Review。 + +- [ ] **Step 1: 写失败测试** + +`internal/handler/refund_test.go`: +```go +package handler_test + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/fake" + "github.com/wangjia/pay/internal/router" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" +) + +const refundSecret = "s3cr3t-pangolin" + +func buildRefundEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.OrderStore, *fake.Provider) { + t.Helper() + gin.SetMode(gin.TestMode) + config.C = config.Config{Biz: map[string]config.BizSystemConfig{ + "pangolin": {Secret: refundSecret, CallbackURL: "http://x"}, + }} + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + preg := provider.NewRegistry() + fp := fake.New() + preg.Register(fp) + areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}}) + g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global") + r := gin.New() + router.SetupV2(r, g) + return r, g, orders, fp +} + +// 用 pangolin secret 给退款请求签名(与 verifyBizSign 一致)。 +func signedRefundReq(t *testing.T, r *gin.Engine, body map[string]any) *http.Response { + t.Helper() + raw, _ := json.Marshal(body) + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := uuid.NewString() + sign := util.HMACSign(refundSecret, "pangolin", ts, nonce, string(raw)) + req, _ := http.NewRequest(http.MethodPost, "/api/v2/refunds", jsonBody(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Pay-System", "pangolin") + req.Header.Set("X-Pay-Timestamp", ts) + req.Header.Set("X-Pay-Nonce", nonce) + req.Header.Set("X-Pay-Sign", sign) + return serve(t, r, req) +} + +func TestRefundEndpointE2E(t *testing.T) { + r, g, orders, fp := buildRefundEngine(t) + fp.EnableRefund("fake-ref", provider.PaidSucceeded, nil) + + // 下单 + 付 + res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"}) + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}) + _, _ = g.SyncPendingAttempts(context.Background(), 10) + + // 签名退款 → 200 succeeded + resp := signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 29990000, "biz_system": "pangolin", "reason": "test"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("refund status = %d want 200", resp.StatusCode) + } + if o, _ := orders.GetOrder(res.OrderNo); o.Status != model.OrderRefundedV2 { + t.Fatalf("order = %s want refunded", o.Status) + } + + // 未签名 → 401 + req, _ := http.NewRequest(http.MethodPost, "/api/v2/refunds", jsonBody(mustJSON(map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 1, "biz_system": "pangolin"}))) + req.Header.Set("Content-Type", "application/json") + if resp := serve(t, r, req); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unsigned status = %d want 401", resp.StatusCode) + } +} + +func TestManualRefundAdminFlow(t *testing.T) { + t.Setenv("PAY_ADMIN_TOKEN", "adm-tok") + r, g, orders, fp := buildRefundEngine(t) + // fp 不 EnableRefund → manual_pending + res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-2"}) + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}) + _, _ = g.SyncPendingAttempts(context.Background(), 10) + mr, _ := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 29990000, BizSystem: "pangolin"}) + + // admin 无 token → 403 + req, _ := http.NewRequest(http.MethodGet, "/api/v2/admin/manual-refunds", nil) + if resp := serve(t, r, req); resp.StatusCode != http.StatusForbidden { + t.Fatalf("no-token status = %d want 403", resp.StatusCode) + } + // admin 带 token → 200 且列出待办 + req, _ = http.NewRequest(http.MethodGet, "/api/v2/admin/manual-refunds", nil) + req.Header.Set("X-Pay-Admin", "adm-tok") + if resp := serve(t, r, req); resp.StatusCode != http.StatusOK { + t.Fatalf("admin list status = %d want 200", resp.StatusCode) + } + // complete → order refunded + req, _ = http.NewRequest(http.MethodPost, "/api/v2/admin/refunds/"+mr.RefundID+"/complete", jsonBody(mustJSON(map[string]any{"provider_refund_ref": "tron-tx"}))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Pay-Admin", "adm-tok") + if resp := serve(t, r, req); resp.StatusCode != http.StatusOK { + t.Fatalf("complete status = %d want 200", resp.StatusCode) + } + if o, _ := orders.GetOrder(res.OrderNo); o.Status != model.OrderRefundedV2 { + t.Fatalf("order = %s want refunded", o.Status) + } +} +``` +> 测试小工具 `jsonBody`/`serve`/`mustJSON`:若 `internal/handler/gateway_test.go` 已有等价 helper(如 `do(...)`)则复用/改写为直接构造 `http.Request`(签名头必须逐个设,不能走 `do` 的自动编码)。否则在本测试文件加: +> ```go +> func jsonBody(b []byte) *bytes.Reader { return bytes.NewReader(b) } +> func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } +> func serve(t *testing.T, r *gin.Engine, req *http.Request) *http.Response { +> w := httptest.NewRecorder(); r.ServeHTTP(w, req); return w.Result() +> } +> ``` +> (import `bytes`/`net/http/httptest`。)`config.C` 是包级 `var C Config`(值类型),直接赋值即可。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/handler/ -run 'Refund' -v` +Expected: 编译失败(`CreateRefund`/`ListManualRefunds` handler 未定义;路由未注册)。 + +- [ ] **Step 3: 写实现** + +`internal/handler/refund.go`: +```go +package handler + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "os" + + "github.com/gin-gonic/gin" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" +) + +type refundRequest struct { + OutTradeNo string `json:"out_trade_no"` + AmountMinor int64 `json:"amount_minor"` + Reason string `json:"reason,omitempty"` + BizSystem string `json:"biz_system"` +} + +// CreateRefund POST /api/v2/refunds —— 业务发起退款(HMAC 验签)。 +func (h *GatewayHandler) CreateRefund(c *gin.Context) { + raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes)) + if err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败") + return + } + var req refundRequest + if err := json.Unmarshal(raw, &req); err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误") + return + } + if req.OutTradeNo == "" || req.AmountMinor <= 0 || req.BizSystem == "" { + util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 out_trade_no / amount_minor / biz_system") + return + } + if err := verifyBizSign(c, req.BizSystem, raw); err != nil { + util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error()) + return + } + res, err := h.g.Refund(c.Request.Context(), gateway.RefundInput{ + OutTradeNo: req.OutTradeNo, AmountMinor: req.AmountMinor, Reason: req.Reason, + BizSystem: req.BizSystem, InitiatedBy: "business", + }) + if err != nil { + h.writeRefundErr(c, err) + return + } + util.RespondSuccess(c, res) +} + +// GetRefund GET /api/v2/refunds/:refund_id +func (h *GatewayHandler) GetRefund(c *gin.Context) { + v, err := h.g.GetRefund(c.Param("refund_id")) + if err != nil { + util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在") + return + } + util.RespondSuccess(c, v) +} + +func (h *GatewayHandler) adminAuthorized(c *gin.Context) bool { + tok := os.Getenv("PAY_ADMIN_TOKEN") + if tok == "" { + return false // 未配置管理密钥 → 端点禁用 + } + return subtle.ConstantTimeCompare([]byte(c.GetHeader("X-Pay-Admin")), []byte(tok)) == 1 +} + +// ListManualRefunds GET /api/v2/admin/manual-refunds —— 捞 crypto 人工待办。 +func (h *GatewayHandler) ListManualRefunds(c *gin.Context) { + if !h.adminAuthorized(c) { + util.RespondError(c, http.StatusForbidden, "forbidden", "无管理权限") + return + } + rs, err := h.g.ListManualPendingRefunds(50) + if err != nil { + util.RespondError(c, http.StatusInternalServerError, "list_failed", "查询失败") + return + } + util.RespondSuccess(c, gin.H{"refunds": rs}) +} + +type completeRefundRequest struct { + ProviderRefundRef string `json:"provider_refund_ref"` +} + +// CompleteRefund POST /api/v2/admin/refunds/:refund_id/complete —— 运营回填人工退款完成。 +func (h *GatewayHandler) CompleteRefund(c *gin.Context) { + if !h.adminAuthorized(c) { + util.RespondError(c, http.StatusForbidden, "forbidden", "无管理权限") + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes) + var req completeRefundRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误") + return + } + res, err := h.g.CompleteManualRefund(c.Request.Context(), c.Param("refund_id"), req.ProviderRefundRef) + if err != nil { + switch { + case errors.Is(err, store.ErrRefundNotFound): + util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在") + case errors.Is(err, gateway.ErrRefundNotManual): + util.RespondError(c, http.StatusConflict, "not_manual", "该退款单非人工待办态") + default: + log.Printf("[v2 refund] 人工完成失败 %s: %v", c.Param("refund_id"), err) + util.RespondError(c, http.StatusInternalServerError, "complete_failed", "完成失败") + } + return + } + util.RespondSuccess(c, res) +} + +func (h *GatewayHandler) writeRefundErr(c *gin.Context, err error) { + switch { + case errors.Is(err, store.ErrOrderNotFound): + util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在") + case errors.Is(err, gateway.ErrOrderNotRefundable): + util.RespondError(c, http.StatusConflict, "order_not_refundable", "订单不可退款(未支付/已全额退款/非本业务)") + case errors.Is(err, gateway.ErrRefundAmountInvalid): + util.RespondError(c, http.StatusUnprocessableEntity, "refund_amount_invalid", "退款金额无效或超过可退余额") + case errors.Is(err, store.ErrAttemptNotFound): + util.RespondError(c, http.StatusConflict, "no_paid_attempt", "订单无已支付记录") + default: + log.Printf("[v2 refund] 退款失败: %v", err) + util.RespondError(c, http.StatusInternalServerError, "refund_failed", "退款失败,请稍后重试") + } +} +``` + +`internal/router/router.go` —— `SetupV2` 加退款路由 + admin 组: +```go +func SetupV2(r *gin.Engine, g *gateway.Gateway) { + h := handler.NewGatewayHandler(g) + v2 := r.Group("/api/v2") + { + v2.POST("/orders", h.CreateOrder) + v2.GET("/orders/:order_no", h.GetStatus) + v2.POST("/orders/:order_no/retry", h.Retry) + v2.POST("/orders/:order_no/cancel", h.Cancel) + v2.POST("/callback/:method", h.Callback) + v2.POST("/refunds", h.CreateRefund) + v2.GET("/refunds/:refund_id", h.GetRefund) + } + // 人工退款闭环:独立 admin 组(env PAY_ADMIN_TOKEN 保护),路径与 v2/refunds 分离避免路由冲突。 + admin := r.Group("/api/v2/admin") + { + admin.GET("/manual-refunds", h.ListManualRefunds) + admin.POST("/refunds/:refund_id/complete", h.CompleteRefund) + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./... 2>&1 | tail -30` +Expected: 全仓 PASS(含 handler 端到端、admin 闭环、超退/未授权拒;P1-P5 既有全绿)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/handler/refund.go internal/handler/refund_test.go internal/router/router.go +git commit -m "feat(v2): 退款 HTTP 端点(POST /refunds 验签发起 + 状态查询 + admin 人工闭环)" +``` + +--- + +## Self-Review(取舍与硬化项) + +**已交付(P4 范围内):** +- 退款生命周期端到端:业务发起(HMAC 验签)→ 校验(已付/归属/金额 ≤ 可退余额)→ 建单 → 渠道退款(alipay `TradeRefund` / stripe `Refunds.New`,`refund_id` 幂等键)→ 幂等落状态 → order 退款态机条件 UPDATE(`partially_refunded`/`refunded`)→ outbox `refund.succeeded`/`refund.failed`。 +- **退款事件幂等键 = refund 单号**:outbox 唯一键扩为 `(out_trade_no,event_type,refund_id)`,同单多次部分退各自成行、各自投递;payment 事件 `refund_id=""` 语义不变。 +- **部分退款模型**:一单可多次部分退,累计 ≤ 已付;可退余额守卫按「非失败退款额之和」(在途也占额度,防并发退穿);态机由「succeeded 之和」驱动。 +- **crypto 人工向最小闭环**:无退款 API → 建 `manual_pending`(不动 order/不发事件)→ `GET /admin/manual-refunds` 捞待办 → `POST /admin/refunds/:id/complete` 回填 tx → 与渠道成功同一 `settleRefundSucceeded` 落地。 +- **投递门禁放宽**:`OrderStatusV2.Settled()` 让退款态订单的 refund webhook 正常放行,同时保留对未付单的 payment 事件保护。 + +**取舍 / 硬化项(P4 明确不做,留后续):** +1. **异步退款结果收敛**:stripe `requires_action`/`pending` 退款(及未来 3DS 场景)`Refund()` 返 `PaidPending`,单保持 `processing`——P4 无「退款查单 job」主动收敛,靠人工 `complete` 或 P6 对账。真实卡类退款绝大多数同步 `succeeded`,影响面小。 +2. **平台发起退款/拒付**:Stripe `charge.refunded`/`refund.updated` webhook、IAP 退款通知、信用卡 chargeback → 归 **P8**;P4 的 `VerifyCallback` 不分发退款事件。 +3. **退款多账户路由**:退款走装配期注入的**单一渠道客户端**(与回调验签同法),多账户下「原路退到具体收款主体」需按 `attempt.account_id` 选客户端——归 P5 路由深化,记硬化项。 +4. **存量库迁移**:`WebhookDelivery` 加 `refund_id` 并改 `uq_delivery` 复合唯一键,对**已有数据**的库 `AutoMigrate` 不保证重建旧唯一索引;本分支 v2 未上生产(新建库)故安全,若已部署需手写迁移(drop 旧 uq_delivery → 建新三列唯一索引)。 +5. **stripe `reason` 未透传**:Stripe 只收枚举 reason,业务自由文案落 pay 本地 `Refund.Reason`,不传 Stripe(避免被拒)。 +6. **admin 人工端点鉴权**:用 env `PAY_ADMIN_TOKEN` + 定长比较的最小闭环(token 未设即禁用),未做角色/审计日志分级——运营量级足够,精细化留后续。 + +**为何 `RefundingProvider` 扩了签名**:P2 仅声明该可选接口、无任何实现者,P4 首次落地即加 `refundID`(渠道幂等键)——这是「同单多次部分退款幂等」的硬需求(alipay `out_request_no` 必传且须稳定唯一;stripe `Idempotency-Key`),缺它无法安全落地设计文档 §6。零破坏(改前无实现者)。 diff --git a/docs/superpowers/plans/2026-07-10-pay-v2-p6-reconcile.md b/docs/superpowers/plans/2026-07-10-pay-v2-p6-reconcile.md new file mode 100644 index 0000000..9b16683 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pay-v2-p6-reconcile.md @@ -0,0 +1,1855 @@ +# pay v2 · P6 对账与后台守护 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 每个 Task 自包含,给完整 Go 代码 + 测试 + 命令,可交给全新 implementer subagent。 + +> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html`。P6 直接兑现其 §8 结尾铁律「**对账优先于实时**:每 provider 实现 query,跑定时对账 job 防掉单/防重复授权」。 +> **前置计划:** +> - `docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md`(P1:`model.OrderV2`/`Attempt`/`WebhookDelivery`、`store.OrderStore`/`WebhookStore`、`accounts`)。 +> - `docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md`(P2:`gateway.Settle`/`SyncPendingAttempts`、webhook outbox、`store.ExpirePendingAttempts`;**本计划直接复用其接口,不重造**)。P2 计划 Self-Review「后续阶段」条目明确把**订单级过期清理**与 **Notifier 硬化(退避/死信/告警)** routed 到 P6。 +> - `docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md`(P3:crypto 自托管 adapter + 进程内预留表)。P3 Self-Review「已知取舍」把 **crypto 预留表持久化冷启动兜底** 与 **crypto orphan 到账落表** routed 到 P6。 +> - `docs/superpowers/plans/2026-07-10-pay-v2-p5-account-routing.md`(P5:`accounts.Router`/`LimitAware`/`UsageSource`)。P5 计划 D5 明确 **LimitAware 真实用量数据源由 P6 对账 job 提供**,届时实现 `accounts.UsageSource` 注入 Router 即可,`LimitAware`/`Router` 不改。 + +**Goal:** 给 pay v2 装上一套**周期后台守护 + 对账**:统一 `internal/reconcile` 包挂多个幂等、崩溃安全、可重跑的周期任务——① 订单级过期清理(超 TTL 未付 pending 单自动关闭,含零尝试孤儿单);② Notifier 投递硬化(指数退避 + 最大次数 + 死信标记 + 告警钩子);③ crypto 预留表持久化冷启动兜底(用 attempts 表重建 reservation,兜住重启丢内存);④ `LimitAware` 真实用量数据源(attempts 按账户结算币种聚合当日已收);⑤ 对账主体(周期 `SyncPendingAttempts` + 已付订单抽查核对);⑥ crypto 孤儿链上支付发现(到账但不匹配任何 attempt → 落表 + 告警)。全程注入时钟 + 假 provider/httptest,**免 docker、不打真网**。 + +**Architecture:** 后台 job 统一形态 = `reconcile.Runner`:注册多个 `Task{Name, Interval, Run(ctx)}`,`Start(ctx)` 每任务一 goroutine + 独立 ticker + `recover`(单 tick panic 不拖垮其它),`RunOnce(ctx)` 顺序跑一遍(启动预热 + 单测入口)。每个 Task 是**幂等 sweep**(条件 UPDATE / 快照刷新 / 查单收敛),重跑安全、崩溃后下一 tick 自愈。`reconcile` 处于装配层(可 import `store`/`gateway`/`accounts`/`provider`,类比 main),而 `gateway`/`provider` 核心仍保持中性——不反向依赖 `reconcile`。过期清理走 `store` 新增条件 UPDATE;Notifier 硬化改 `webhook` + `store.WebhookStore` + `model.WebhookDelivery` 加列;crypto 冷启动兜底经**注入 `ReservationLoader`**(crypto 不 import `store`,装配期 main 提供 loader);用量数据源 `reconcile.UsageSource` 结构体满足 `accounts.UsageSource` 接口(Router 注入它、刷新 job 也持它);orphan 经**中性可选接口 `provider.OrphanScanner`**(crypto 实现,`reconcile` type-assert)。 + +**Tech Stack:** Go 1.26.1 · `github.com/wangjia/pay` · Gin · GORM v1.31 · glebarez/sqlite · 标准库 `time`/`context`/`sync`/`log`。无新增第三方依赖。crypto orphan 复用 P3 已有 TronGrid REST 客户端(`p.http` + `only_confirmed`)。 + +## Global Constraints(继承 P2/P3/P5) + +- **复用不重造**:模型 `model.OrderV2`/`Attempt`/`WebhookDelivery` + `V2` 后缀状态;数据访问 `store.OrderStore`/`WebhookStore`;管线 `gateway.Gateway`(`Settle`/`SyncPendingAttempts`);账户 `accounts.Registry`/`Router`/`LimitAware`/`UsageSource`(接口)/`config.AccountConfig`;Provider 抽象 `provider.Provider`/`QueryRequest`/`PaidEvent`;crypto adapter `internal/provider/crypto`(`allocateAmount`/`Query`/`tailFromRef`/`address`/进程内 `reserved` map)。 +- **金额一律 int64 最小单位 + 币种码**,禁 float;时间一律 Go 端算好传 `?`(cutoff/since 由 job 用注入时钟算,不写 SQL 时间函数,保持 sqlite/mysql 可移植)。 +- **幂等 + 崩溃安全**:每个 job 是可重跑 sweep;订单过期用 `status = pending` 守卫的条件 UPDATE(与并发 settle 翻转不打架:谁先谁赢,另一方 RowsAffected=0 no-op);死信/退避靠列状态持久化,重启续跑;usage 快照刷新是幂等覆盖;orphan 落表按 tx_id unique 去重。 +- **Provider 中性守恒**:`internal/gateway`、`internal/provider`(核心 `provider.go`)不 import `reconcile`;crypto 不 import `store`(经注入 loader / 中性接口传数据)。`reconcile` 是装配层,只被 main 调。 +- **凭证 env only**,测试用 `t.Setenv`;所有测试 `:memory:`/临时 sqlite + httptest 假 TronGrid/假业务方,**免 docker、不打真网**。 +- 每步 `go build ./...` 通过;测试 `go test ./...` 全绿。每任务严格 bite-sized TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。**禁占位**。 + +## 债务台账落点(prior reviews routed 到 P6 的 6 项 + 1 已做项) + +| # | 债务项 | 落点 | +|---|--------|------| +| 1 | 订单级过期清理(超 TTL pending 单关闭,含零尝试孤儿 pending 单) | **Task 1** | +| 2 | Notifier 重试硬化(退避 / 最大次数 / 死信 / 告警钩子) | **Task 2** | +| 3 | crypto 孤儿链上支付(到账不匹配任何 attempt → 记录 + 告警) | **Task 6** | +| 4 | crypto 预留表多实例/重启兜底(attempts 表冷启动重建) | **Task 3** | +| 5 | LimitAware 真实数据源(attempts/orders 按账户结算币种聚合) | **Task 4** | +| 6 | 对账主体(周期 SyncPendingAttempts + 渠道流水 vs 本地订单核对) | **Task 5**(决策:全量对账单下载判定过重 → 降级为「逐 pending attempt query + 已付订单抽查」,见 Task 5 Self-Review) | +| 7 | settle-sync 非常态结果日志(764ed55 已做) | 已存在,**不重复**;Task 5 调度 `SyncPendingAttempts` 时天然继承其日志 | + +--- + +### Task 1: reconcile.Runner 骨架 + 订单级过期清理(ExpireStaleOrders) + +**债务 #1。** 建后台 job 统一形态(`Runner`),并落地第一个 job:超 TTL 未付 pending 订单自动关闭。含 P2-T4 记录的「零尝试孤儿 pending 单」——建单后 `prov.Create`/`CreateAttempt` 失败留下的、调用方拿不到单号无法 retry/cancel 的 pending 单,因其 `status=pending` 同样被扫到。**注意与 attempt 级过期区分**:P2 已有 `store.ExpirePendingAttempts`(把某单的 pending attempt 标 expired,订单状态不动,retry 前调用);本任务是**订单级**——把整张 pending 订单标 `expired`。 + +**Files:** +- Create: `internal/reconcile/runner.go`(Runner 骨架) +- Create: `internal/reconcile/runner_test.go` +- Create: `internal/reconcile/orders.go`(OrderExpirerTask 工厂) +- Create: `internal/reconcile/orders_test.go` +- Modify: `internal/store/order_query.go`(新增 `ExpireStaleOrders`) +- Modify: `internal/store/order_query_test.go`(若无则新建,断言 `ExpireStaleOrders`) + +**Interfaces:** +- `store.(*OrderStore).ExpireStaleOrders(cutoff time.Time, limit int) (int64, error)` —— 条件 UPDATE:`status=pending AND created_at < cutoff` → `expired`;返回关闭条数。`cutoff` 由调用方(job)用注入时钟算(`now-TTL`),store 不碰时钟。 +- `reconcile.Task{Name string; Interval time.Duration; Run func(ctx) error}`;`(*Runner).Add(name, interval, run)` / `RunOnce(ctx)` / `Start(ctx)`。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/order_query_test.go` 追加(若文件不存在则新建,`package store` 内部测试以便直接改 `created_at`): +```go +package store + +import ( + "testing" + "time" + + "github.com/wangjia/pay/internal/model" +) + +func TestExpireStaleOrders(t *testing.T) { + db := model.OpenTestDB(t) + s := NewOrderStore(db) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + + // 三张单:旧 pending(应过期)、新 pending(未到点)、旧 paid(不动)。 + mk := func(no string, st model.OrderStatusV2, createdAgo time.Duration) { + o := &model.OrderV2{OutTradeNo: no, AmountMinor: 100, Currency: "USDT", Status: st} + if err := s.CreateOrder(o); err != nil { + t.Fatalf("create %s: %v", no, err) + } + // 直接回填 created_at 制造账龄(GORM 默认写 now)。 + if err := db.Model(&model.OrderV2{}).Where("out_trade_no = ?", no). + Update("created_at", now.Add(-createdAgo)).Error; err != nil { + t.Fatalf("backdate %s: %v", no, err) + } + } + mk("STALE", model.OrderPendingV2, 2*time.Hour) // 旧 pending + mk("FRESH", model.OrderPendingV2, 5*time.Minute) // 新 pending + mk("PAID", model.OrderPaidV2, 2*time.Hour) // 旧 paid + + cutoff := now.Add(-time.Hour) // TTL=1h + n, err := s.ExpireStaleOrders(cutoff, 500) + if err != nil { + t.Fatalf("expire: %v", err) + } + if n != 1 { + t.Fatalf("应关闭 1 张(仅 STALE), got %d", n) + } + assertStatus := func(no string, want model.OrderStatusV2) { + o, _ := s.GetOrder(no) + if o.Status != want { + t.Fatalf("%s status = %v, want %v", no, o.Status, want) + } + } + assertStatus("STALE", model.OrderExpiredV2) + assertStatus("FRESH", model.OrderPendingV2) + assertStatus("PAID", model.OrderPaidV2) + + // 幂等:再跑一次不再关闭(STALE 已非 pending)。 + if n2, _ := s.ExpireStaleOrders(cutoff, 500); n2 != 0 { + t.Fatalf("重跑应 0, got %d", n2) + } +} +``` + +`internal/reconcile/runner_test.go`: +```go +package reconcile_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/wangjia/pay/internal/reconcile" +) + +func TestRunnerRunOnceExecutesAllAndRecoversPanic(t *testing.T) { + r := reconcile.NewRunner() + var a, b int + r.Add("inc-a", time.Minute, func(context.Context) error { a++; return nil }) + r.Add("boom", time.Minute, func(context.Context) error { panic("kaboom") }) // 不得拖垮后续 + r.Add("inc-b", time.Minute, func(context.Context) error { b++; return errors.New("soft") }) + + r.RunOnce(context.Background()) // panic 被 recover,error 被记录,均不中断 + if a != 1 || b != 1 { + t.Fatalf("a=%d b=%d, want 1/1(panic 任务不应阻断其它)", a, b) + } +} +``` + +`internal/reconcile/orders_test.go`: +```go +package reconcile_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/reconcile" + "github.com/wangjia/pay/internal/store" +) + +func TestOrderExpirerTaskClosesStalePending(t *testing.T) { + db := model.OpenTestDB(t) + s := store.NewOrderStore(db) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + + o := &model.OrderV2{OutTradeNo: "OLD", AmountMinor: 100, Currency: "USDT", Status: model.OrderPendingV2} + _ = s.CreateOrder(o) + _ = db.Model(&model.OrderV2{}).Where("out_trade_no = ?", "OLD"). + Update("created_at", now.Add(-2*time.Hour)).Error + + task := reconcile.OrderExpirerTask(s, time.Hour, func() time.Time { return now }) + if err := task(context.Background()); err != nil { + t.Fatalf("task: %v", err) + } + got, _ := s.GetOrder("OLD") + if got.Status != model.OrderExpiredV2 { + t.Fatalf("超时 pending 单应 expired, got %v", got.Status) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/reconcile/ 2>&1 | tail -20` +Expected: 编译失败——`ExpireStaleOrders` / `reconcile` 包不存在。 + +- [ ] **Step 3: 写实现** + +`internal/store/order_query.go` 追加(`import "time"` 已需要;文件顶已 import errors/fmt/gorm/model,补 `"time"`): +```go +// ExpireStaleOrders closes pending orders whose created_at predates cutoff +// (TTL 到期未付),条件 UPDATE 只翻 status=pending 的行——与并发 settle 翻 paid +// 互斥(谁先谁赢,另一方 RowsAffected=0),故幂等且崩溃安全。含"零尝试孤儿单" +// (建单后 CreateAttempt 失败、无 attempt 的 pending 单):它 status 仍是 pending, +// 同样被扫到关闭(P2-T4 复审记录的缺口)。cutoff 由调用方用注入时钟算,store 不碰时钟。 +// +// 注:与 ExpirePendingAttempts 语义不同——那个是 attempt 级(标 attempt expired, +// 订单不动,retry 前用);这个是 order 级(标整张订单 expired)。 +func (s *OrderStore) ExpireStaleOrders(cutoff time.Time, limit int) (int64, error) { + if limit <= 0 || limit > 1000 { + limit = 500 + } + // 先选主键再批量 UPDATE:回避 "UPDATE ... ORDER BY LIMIT" 的方言差异(sqlite/mysql)。 + var ids []uint64 + if err := s.db.Model(&model.OrderV2{}). + Where("status = ? AND created_at < ?", model.OrderPendingV2, cutoff). + Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil { + return 0, fmt.Errorf("store.ExpireStaleOrders select: %w", err) + } + if len(ids) == 0 { + return 0, nil + } + res := s.db.Model(&model.OrderV2{}). + Where("id IN ? AND status = ?", ids, model.OrderPendingV2). // status 守卫兜住 select→update 间的并发翻转 + Update("status", model.OrderExpiredV2) + if res.Error != nil { + return 0, fmt.Errorf("store.ExpireStaleOrders update: %w", res.Error) + } + return res.RowsAffected, nil +} +``` + +`internal/reconcile/runner.go`: +```go +// Package reconcile 是 pay v2 的后台守护/对账装配层:把多个幂等、崩溃安全、 +// 可重跑的周期任务(订单过期清理 / webhook 死信硬化 / crypto 预留冷启动 / +// 用量刷新 / 查单对账 / 链上孤儿发现)挂到统一 Runner。它处于 main 之下的装配层, +// 可依赖 store/gateway/accounts/provider;gateway/provider 核心不反向依赖它。 +package reconcile + +import ( + "context" + "log" + "time" +) + +// Task 一个周期任务:名字 + 间隔 + 幂等可重跑的 Run。 +type Task struct { + Name string + Interval time.Duration + Run func(ctx context.Context) error +} + +// Runner 持有一组周期任务,逐个跑(RunOnce)或各自 ticker 常驻(Start)。 +type Runner struct { + tasks []Task + logf func(format string, args ...any) +} + +func NewRunner() *Runner { return &Runner{logf: log.Printf} } + +// Add 注册一个周期任务。 +func (r *Runner) Add(name string, interval time.Duration, run func(ctx context.Context) error) { + r.tasks = append(r.tasks, Task{Name: name, Interval: interval, Run: run}) +} + +// exec 跑单个任务一次:panic recover + error 记录,绝不外抛(单任务失败不拖垮其它)。 +func (r *Runner) exec(ctx context.Context, t Task) { + defer func() { + if rec := recover(); rec != nil { + r.logf("[reconcile] 任务 %s panic 已恢复: %v", t.Name, rec) + } + }() + if err := t.Run(ctx); err != nil { + r.logf("[reconcile] 任务 %s: %v", t.Name, err) + } +} + +// RunOnce 顺序跑一遍全部任务(启动预热 + 单测入口)。 +func (r *Runner) RunOnce(ctx context.Context) { + for _, t := range r.tasks { + r.exec(ctx, t) + } +} + +// Start 每任务一 goroutine + 独立 ticker 常驻;ctx 取消即退出。每 tick 崩溃安全。 +func (r *Runner) Start(ctx context.Context) { + for _, t := range r.tasks { + t := t + go func() { + tk := time.NewTicker(t.Interval) + defer tk.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tk.C: + r.exec(ctx, t) + } + } + }() + } +} +``` + +`internal/reconcile/orders.go`: +```go +package reconcile + +import ( + "context" + "log" + "time" + + "github.com/wangjia/pay/internal/store" +) + +// OrderExpirerTask 返回「关闭超 TTL 未付 pending 订单」的周期任务体。 +// cutoff = now()-ttl,now 注入(测试确定性);幂等条件 UPDATE(见 store.ExpireStaleOrders)。 +func OrderExpirerTask(orders *store.OrderStore, ttl time.Duration, now func() time.Time) func(ctx context.Context) error { + return func(ctx context.Context) error { + n, err := orders.ExpireStaleOrders(now().Add(-ttl), 500) + if err != nil { + return err + } + if n > 0 { + log.Printf("[reconcile] 过期关闭 %d 个超时未付订单(TTL=%s)", n, ttl) + } + return nil + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ ./internal/reconcile/ -v 2>&1 | tail -30` +Expected: 全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/reconcile/runner.go internal/reconcile/runner_test.go internal/reconcile/orders.go internal/reconcile/orders_test.go internal/store/order_query.go internal/store/order_query_test.go +git commit -m "feat(v2): reconcile Runner 骨架 + 订单级过期清理(超 TTL pending 单/零尝试孤儿单自动关闭)" +``` + +--- + +### Task 2: Notifier 投递硬化 —— 指数退避 + 最大次数 + 死信标记 + 告警钩子 + +**债务 #2。** 现状 `webhook/notifier.go` 的 `Start` 是裸 60s ticker、`MarkFailed` 只 `attempts+1`——**无退避**(业务方挂了每 60s 猛敲)、**无上限**(永远重投)、**无死信/告警**(投不出去的单静默堆积)。本任务:失败走**指数退避**(`next_attempt_at = now + base·2^(attempts-1)`,封顶 `maxBackoff`),达 `maxAttempts` 标 `dead=true` 并触发**告警钩子**;`DeliverPending` 只取「未投递、未死信、且 `next_attempt_at` 到点」的行。不破坏现有幂等(unique 键入队)与投递门禁(订单未付不投、不计失败)。 + +**Files:** +- Modify: `internal/model/webhook_delivery.go`(加 `Dead` + `NextAttemptAt` 列) +- Modify: `internal/store/webhook.go`(`ListDeliverable` / `ScheduleRetry` / `MarkDead`) +- Modify: `internal/store/webhook_test.go`(断言新查询/调度) +- Modify: `internal/webhook/notifier.go`(functional options:时钟/上限/退避/告警;失败分流退避 vs 死信) +- Modify: `internal/webhook/notifier_test.go`(注入时钟测退避/死信/告警;修 `TestNotifierRetriesOnFailure` 让第二次投递前推进时钟越过退避) + +**Interfaces:** +- `store.(*WebhookStore).ListDeliverable(now time.Time, limit int) ([]WebhookDeliveryRow, error)` +- `store.(*WebhookStore).ScheduleRetry(id uint64, errMsg string, nextAt time.Time) error`(attempts+1、last_error、next_attempt_at) +- `store.(*WebhookStore).MarkDead(id uint64, errMsg string) error`(attempts+1、dead=true、last_error) +- `webhook.NewNotifier(ws, bizConfig, orderPaid, opts ...Option)` + `WithClock` / `WithMaxAttempts` / `WithBaseBackoff` / `WithMaxBackoff` / `WithAlerter`。**保留原 3 参调用式**(options 变参),现有 main/测试不改签名即可编译。 + +> AutoMigrate 只加不删,新列对存量库安全(`Dead` 默认 false,`NextAttemptAt` NULL = 立即可投)。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/webhook_test.go` 追加: +```go +func TestWebhookScheduleRetryAndDead(t *testing.T) { + ws := store.NewWebhookStore(model.OpenTestDB(t)) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + _ = ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", `{"x":1}`) + + // 刚入队:next_attempt_at NULL → 立即可投。 + rows, _ := ws.ListDeliverable(now, 10) + if len(rows) != 1 { + t.Fatalf("新单应可投, got %d", len(rows)) + } + id := rows[0].ID + + // 排下一次重试到 now+30s:此刻不可投,过点可投。 + if err := ws.ScheduleRetry(id, "http 500", now.Add(30*time.Second)); err != nil { + t.Fatalf("schedule: %v", err) + } + if r, _ := ws.ListDeliverable(now, 10); len(r) != 0 { + t.Fatalf("退避窗内不应可投, got %d", len(r)) + } + if r, _ := ws.ListDeliverable(now.Add(31*time.Second), 10); len(r) != 1 || r[0].Attempts != 1 { + t.Fatalf("过退避点应可投且 attempts=1, got %+v", r) + } + + // 标死信:不再出现在可投集。 + if err := ws.MarkDead(id, "gave up"); err != nil { + t.Fatalf("markdead: %v", err) + } + if r, _ := ws.ListDeliverable(now.Add(time.Hour), 10); len(r) != 0 { + t.Fatalf("死信不应可投, got %d", len(r)) + } +} +``` + +`internal/webhook/notifier_test.go` —— 新增退避/死信/告警用例,并**改造** `TestNotifierRetriesOnFailure`(退避后需推进时钟才重试): +```go +// 业务方持续 500:每次失败按指数退避重排;时钟推进后才重投;达上限标死信 + 告警。 +func TestNotifierBackoffThenDeadWithAlert(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + clk := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + nowFn := func() time.Time { return clk } + var alerted []string + ws := store.NewWebhookStore(model.OpenTestDB(t)) + n := webhook.NewNotifier(ws, + func(string) (config.BizSystemConfig, bool) { + return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x"}, true + }, + func(string) (bool, error) { return true, nil }, + webhook.WithClock(nowFn), + webhook.WithBaseBackoff(time.Second), + webhook.WithMaxBackoff(4*time.Second), + webhook.WithMaxAttempts(3), + webhook.WithAlerter(func(d *store.WebhookDeliveryRow, reason string) { alerted = append(alerted, d.OutTradeNo) }), + ) + _ = n.Enqueue("PAY-D", "pangolin", "payment.succeeded", map[string]any{"event_type": "payment.succeeded"}) + + // 尝试 1:失败 → attempts=1,退避到 +1s。 + if sent, _ := n.DeliverPending(10); sent != 0 || hits != 1 { + t.Fatalf("try1 sent=%d hits=%d", sent, hits) + } + // 退避窗内不投。 + if sent, _ := n.DeliverPending(10); sent != 0 || hits != 1 { + t.Fatalf("退避窗内不应再敲, hits=%d", hits) + } + // 推进越过退避;尝试 2 失败 → attempts=2,退避到 +2s。 + clk = clk.Add(2 * time.Second) + if sent, _ := n.DeliverPending(10); sent != 0 || hits != 2 { + t.Fatalf("try2 hits=%d", hits) + } + // 推进;尝试 3 失败 → attempts 达 maxAttempts(3)→ 死信 + 告警。 + clk = clk.Add(4 * time.Second) + if sent, _ := n.DeliverPending(10); sent != 0 || hits != 3 { + t.Fatalf("try3 hits=%d", hits) + } + if len(alerted) != 1 || alerted[0] != "PAY-D" { + t.Fatalf("死信应触发告警一次, got %+v", alerted) + } + // 已死信:无论时钟怎么走都不再投。 + clk = clk.Add(time.Hour) + if sent, _ := n.DeliverPending(10); sent != 0 || hits != 3 { + t.Fatalf("死信后不应再投, hits=%d", hits) + } +} +``` +并把原 `TestNotifierRetriesOnFailure` 里第二次 `DeliverPending` 前插入时钟推进 —— 该测试改为注入时钟: +```go +func TestNotifierRetriesOnFailure(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + clk := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + ws := store.NewWebhookStore(model.OpenTestDB(t)) + n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) { + return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x"}, true + }, func(string) (bool, error) { return true, nil }, + webhook.WithClock(func() time.Time { return clk }), + webhook.WithBaseBackoff(time.Second)) + _ = n.Enqueue("PAY-3", "pangolin", "payment.succeeded", map[string]any{"event_type": "payment.succeeded"}) + + if sent, _ := n.DeliverPending(10); sent != 0 { + t.Fatalf("失败不应算投递成功, got %d", sent) + } + pend, _ := ws.ListUndelivered(10) + if len(pend) != 1 || pend[0].Attempts != 1 { + t.Fatalf("失败后应留队重试, got %+v", pend) + } + clk = clk.Add(2 * time.Second) // 越过退避窗 + if _, _ = n.DeliverPending(10); hits < 2 { + t.Fatalf("应重试第二次, hits=%d", hits) + } +} +``` +> `time` 需加入 notifier_test.go 的 import。`ws.ListUndelivered` 保留(列 delivered=false 全量,供该断言),Notifier 内部改用 `ListDeliverable`。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/webhook/ 2>&1 | tail -20` +Expected: 编译失败——`ListDeliverable`/`ScheduleRetry`/`MarkDead`/`WithClock` 等未定义;`Dead`/`NextAttemptAt` 列不存在。 + +- [ ] **Step 3: 写实现** + +`internal/model/webhook_delivery.go` —— 结构体尾部加两列: +```go + Delivered bool `gorm:"index;default:false" json:"delivered"` + Attempts int `json:"attempts"` + LastError string `gorm:"size:255" json:"last_error,omitempty"` + Dead bool `gorm:"index;default:false" json:"dead"` // 达最大次数放弃投递(死信),需人工/对账介入 + NextAttemptAt *time.Time `gorm:"index" json:"next_attempt_at,omitempty"` // 指数退避的下次可投时刻;nil=立即可投 +``` +> 文件加 `import "time"`。 + +`internal/store/webhook.go` —— 新增三方法,并保留 `ListUndelivered`/`MarkDelivered`(`MarkFailed` 可删,已无调用方;保留亦无害,这里替换为下面两法): +```go +// ListDeliverable 取「未投递、未死信、且退避到点(next_attempt_at NULL 或 <= now)」的行。 +func (s *WebhookStore) ListDeliverable(now time.Time, limit int) ([]WebhookDeliveryRow, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + var out []WebhookDeliveryRow + if err := s.db. + Where("delivered = ? AND dead = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", false, false, now). + Order("id ASC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListDeliverable: %w", err) + } + return out, nil +} + +// ScheduleRetry 记一次失败并排下次重试:attempts+1、last_error、next_attempt_at=nextAt。 +func (s *WebhookStore) ScheduleRetry(id uint64, errMsg string, nextAt time.Time) error { + errMsg = truncateUTF8(errMsg, 255) + if err := s.db.Model(&model.WebhookDelivery{}).Where("id = ?", id). + Updates(map[string]any{ + "attempts": gorm.Expr("attempts + 1"), + "last_error": errMsg, + "next_attempt_at": nextAt, + }).Error; err != nil { + return fmt.Errorf("store.ScheduleRetry: %w", err) + } + return nil +} + +// MarkDead 达最大次数后放弃:attempts+1、dead=true、last_error。行留库供人工/对账排查。 +func (s *WebhookStore) MarkDead(id uint64, errMsg string) error { + errMsg = truncateUTF8(errMsg, 255) + if err := s.db.Model(&model.WebhookDelivery{}).Where("id = ?", id). + Updates(map[string]any{ + "attempts": gorm.Expr("attempts + 1"), + "dead": true, + "last_error": errMsg, + }).Error; err != nil { + return fmt.Errorf("store.MarkDead: %w", err) + } + return nil +} +``` +> `import "time"` 加入 webhook.go(store 包)。若删 `MarkFailed`,确认无其它调用方(`grep -rn MarkFailed internal/`,当前仅 notifier 用,Step 3 一并改掉)。 + +`internal/webhook/notifier.go` —— 加 options + 失败分流: +```go +// 结构体加字段 +type Notifier struct { + deliveries *store.WebhookStore + bizConfig BizConfigFunc + orderPaid OrderPaidFunc + client *http.Client + now func() time.Time + maxAttempts int + baseBackoff time.Duration + maxBackoff time.Duration + alert func(d *store.WebhookDeliveryRow, reason string) +} + +type Option func(*Notifier) + +func WithClock(f func() time.Time) Option { return func(n *Notifier) { n.now = f } } +func WithMaxAttempts(m int) Option { return func(n *Notifier) { n.maxAttempts = m } } +func WithBaseBackoff(d time.Duration) Option { return func(n *Notifier) { n.baseBackoff = d } } +func WithMaxBackoff(d time.Duration) Option { return func(n *Notifier) { n.maxBackoff = d } } +func WithAlerter(a func(d *store.WebhookDeliveryRow, reason string)) Option { + return func(n *Notifier) { n.alert = a } +} + +func NewNotifier(ws *store.WebhookStore, bizConfig BizConfigFunc, orderPaid OrderPaidFunc, opts ...Option) *Notifier { + n := &Notifier{ + deliveries: ws, bizConfig: bizConfig, orderPaid: orderPaid, + client: &http.Client{Timeout: 10 * time.Second}, + now: time.Now, + maxAttempts: 12, // ~ 覆盖数小时退避后放弃(见 backoffFor 封顶) + baseBackoff: 30 * time.Second, // 首次失败退避基 + maxBackoff: time.Hour, // 单次退避封顶 + } + for _, o := range opts { + o(n) + } + if n.alert == nil { + n.alert = func(d *store.WebhookDeliveryRow, reason string) { + log.Printf("[webhook][死信] out_trade_no=%s biz=%s event=%s attempts=%d 放弃投递: %s", + d.OutTradeNo, d.BizSystem, d.EventType, d.Attempts, reason) + } + } + return n +} + +// backoffFor 计算第 attempts 次失败后的退避:base·2^(attempts-1),封顶 maxBackoff。 +// attempts≥1;移位用 uint 且封顶,防溢出。 +func (n *Notifier) backoffFor(attempts int) time.Duration { + d := n.baseBackoff + for i := 1; i < attempts; i++ { + d *= 2 + if d >= n.maxBackoff { + return n.maxBackoff + } + } + if d > n.maxBackoff { + return n.maxBackoff + } + return d +} + +// fail 统一失败分流:未达上限→退避重排;达上限→死信+告警。 +func (n *Notifier) fail(d *store.WebhookDeliveryRow, msg string) { + attempts := d.Attempts + 1 // 本次即将记的失败次数 + if attempts >= n.maxAttempts { + _ = n.deliveries.MarkDead(d.ID, msg) + n.alert(d, msg) + return + } + _ = n.deliveries.ScheduleRetry(d.ID, msg, n.now().Add(n.backoffFor(attempts))) +} +``` +`DeliverPending` 改用 `ListDeliverable(n.now(), limit)`: +```go +func (n *Notifier) DeliverPending(limit int) (int, error) { + rows, err := n.deliveries.ListDeliverable(n.now(), limit) + if err != nil { + return 0, err + } + ok := 0 + for i := range rows { + if n.deliverOne(&rows[i]) { + ok++ + } + } + return ok, nil +} +``` +`deliverOne` 把原先所有 `n.deliveries.MarkFailed(...)` 调用改成 `n.fail(d, )`;投递门禁(订单未付)分支**不变**(仍 `return false` 不计失败);成功仍 `MarkDelivered`。逐处替换: +```go + // biz 未配置 + if !found || cfg.CallbackURL == "" { + n.fail(d, "biz system not configured") + return false + } + ... + // 构建请求失败 / Do 失败 / 非 200 或无 SUCCESS + n.fail(d, err.Error()) // 三处原 MarkFailed → fail + ... + n.fail(d, fmt.Sprintf("http %d: %s", resp.StatusCode, truncate(string(rb), 120))) +``` +`Start` 保持 60s ticker 不变(现在每轮 `DeliverPending` 已退避感知);其 `log` 文案可留。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ ./internal/webhook/ -v 2>&1 | tail -30` +Expected: 全 PASS(含改造后的 retry 用例 + 新死信/告警用例)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/model/webhook_delivery.go internal/store/webhook.go internal/store/webhook_test.go internal/webhook/notifier.go internal/webhook/notifier_test.go +git commit -m "feat(v2): webhook 投递硬化——指数退避 + 最大次数死信 + 告警钩子(替裸 60s 猛敲)" +``` + +--- + +### Task 3: crypto 预留表持久化冷启动兜底(attempts 表重建 reservation) + +**债务 #4。** P3 crypto 的唯一金额预留表是**进程内 `reserved` map**——重启即丢,重启后并发同价新单可能复用一个仍在冷却窗内的金额,让迟到旧款误配新单。canonical(pangolin-pay)用自库 `AmountRecentlyUsed` 查重;pay 移植方案(P3 复审记录):**用 attempts 表冷启动重建**。本任务给 crypto 注入一个 `ReservationLoader`(装配期 main 提供,读 pending crypto attempts),`Warm(ctx)` 时把每个未过冷却窗的 (地址,期望金额) 灌回 `reserved`。crypto 不 import store —— loader 是注入函数,数据经中性结构传入,tail→期望金额的换算仍封在 crypto 内(复用 `tailFromRef`)。 + +**Files:** +- Modify: `internal/provider/crypto/crypto.go`(`ReservationLoader` 类型 + `WithReservationLoader`/`WithNow` Option + `Warm`) +- Modify: `internal/provider/crypto/crypto_test.go`(Warm 重建 + 冷却过期跳过) +- Create: `internal/reconcile/crypto_warm.go`(装配期 loader 工厂:从 OrderStore 读 pending crypto attempts) +- Create: `internal/reconcile/crypto_warm_test.go` + +**Interfaces:** +- `crypto.PendingReservation{AccountID string; AmountMinor int64; ProviderRef string; ReservedAt time.Time}`(`AmountMinor`=attempt 冻结的 base 金额,`ProviderRef`=`CRYPTO--` 供恢复尾数,`ReservedAt`=建单时间=attempt.CreatedAt,冷却窗从此算)。 +- `crypto.ReservationLoader func(ctx context.Context) ([]PendingReservation, error)`;`crypto.WithReservationLoader(l)`;`crypto.WithNow(f)`(测试注入时钟)。 +- `crypto.(*Provider).Warm(ctx) error`。 +- `reconcile.CryptoReservationLoader(orders *store.OrderStore) crypto.ReservationLoader`(读 `ListAttemptsByStatus(AttemptPending,…)` 过滤 `Channel=="crypto"`,映射为 `PendingReservation`)。 + +- [ ] **Step 1: 写失败测试** + +`internal/provider/crypto/crypto_test.go` 追加(复用文件里既有的 `t.Setenv("CRY_ADDRESS",…)` + `accounts.New` 模式;`GetReserved` 已由 export_test.go 提供): +```go +func TestWarmRebuildsReservationsFromLoader(t *testing.T) { + const addr = "TWarmTestAddr000000000000000000000" + t.Setenv("CRY_ADDRESS", addr) + reg := accounts.New([]config.AccountConfig{ + {AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "cry"}, + }) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + + // 两条 pending:一条在冷却窗内(应恢复),一条建单于 40min 前(> 30min 冷却窗,应跳过)。 + loader := func(context.Context) ([]crypto.PendingReservation, error) { + return []crypto.PendingReservation{ + {AccountID: "cry-1", AmountMinor: 29990000, ProviderRef: "CRYPTO-PAY-A-263", ReservedAt: now.Add(-5 * time.Minute)}, + {AccountID: "cry-1", AmountMinor: 29990000, ProviderRef: "CRYPTO-PAY-B-777", ReservedAt: now.Add(-40 * time.Minute)}, + }, nil + } + p := crypto.New(reg, crypto.WithReservationLoader(loader), crypto.WithNow(func() time.Time { return now })) + if err := p.Warm(context.Background()); err != nil { + t.Fatalf("warm: %v", err) + } + res := p.GetReserved() + inWindow := addr + "/" + strconv.FormatInt(29990000+263, 10) + expired := addr + "/" + strconv.FormatInt(29990000+777, 10) + if _, ok := res[inWindow]; !ok { + t.Fatalf("冷却窗内的预留应恢复, got %v", res) + } + if _, ok := res[expired]; ok { + t.Fatalf("超冷却窗的预留不应恢复, got %v", res) + } +} +``` +> import 补 `context`/`strconv`/`time`(crypto_test.go 若未 import)。tail 263/777 与 base 29990000 相加即期望金额,键式 `/` 与 `allocateAmount` 一致。 + +`internal/reconcile/crypto_warm_test.go`: +```go +package reconcile_test + +import ( + "context" + "testing" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/reconcile" + "github.com/wangjia/pay/internal/store" +) + +func TestCryptoReservationLoaderFiltersPendingCrypto(t *testing.T) { + db := model.OpenTestDB(t) + s := store.NewOrderStore(db) + // 两条 attempt:crypto pending(要)、alipay pending(不要)。 + _ = s.CreateAttempt(&model.Attempt{OutTradeNo: "O1", Channel: "crypto", ProviderRef: "CRYPTO-O1-12", + AmountMinor: 100, Currency: "USDT", Status: model.AttemptPending}) + _ = s.CreateAttempt(&model.Attempt{OutTradeNo: "O2", Channel: "alipay", ProviderRef: "AL-O2", + AmountMinor: 200, Currency: "CNY", Status: model.AttemptPending}) + + loader := reconcile.CryptoReservationLoader(s) + items, err := loader(context.Background()) + if err != nil { + t.Fatalf("loader: %v", err) + } + if len(items) != 1 || items[0].ProviderRef != "CRYPTO-O1-12" || items[0].AmountMinor != 100 { + t.Fatalf("只应含 crypto pending, got %+v", items) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/crypto/ ./internal/reconcile/ 2>&1 | tail -20` +Expected: 编译失败——`PendingReservation`/`WithReservationLoader`/`WithNow`/`Warm`/`CryptoReservationLoader` 未定义。 + +- [ ] **Step 3: 写实现** + +`internal/provider/crypto/crypto.go` —— 结构体加 `loader`,补 Option 与 `Warm`: +```go +// 结构体加一行: +type Provider struct { + accts *accounts.Registry + baseURL string + http *http.Client + now func() time.Time + loader ReservationLoader // 冷启动预留重建源(装配期注入,nil=不重建) + mu sync.Mutex + reserved map[string]time.Time +} + +// PendingReservation 冷启动重建一笔预留所需的最小信息(中性结构,crypto 不依赖 store)。 +type PendingReservation struct { + AccountID string // 收款账户(用于解析地址,链上匹配维度) + AmountMinor int64 // attempt 冻结的 base 金额(不含尾数) + ProviderRef string // "CRYPTO--",用于恢复尾数 + ReservedAt time.Time // 建单时间(= attempt.CreatedAt),冷却窗自此算 +} + +// ReservationLoader 返回当前仍活跃(pending)的 crypto 预留。装配期由 main 用 OrderStore 实现。 +type ReservationLoader func(ctx context.Context) ([]PendingReservation, error) + +func WithReservationLoader(l ReservationLoader) Option { return func(p *Provider) { p.loader = l } } +func WithNow(f func() time.Time) Option { return func(p *Provider) { p.now = f } } + +// Warm 冷启动兜底:把仍在冷却窗内的活跃预留灌回内存表,兜住重启丢 map 导致的金额复用误配。 +// 幂等:只加不覆盖更早到期时间;冷却已过的跳过。装配期在起服务前调一次即可。 +func (p *Provider) Warm(ctx context.Context) error { + if p.loader == nil { + return nil + } + items, err := p.loader(ctx) + if err != nil { + return err + } + now := p.now() + p.mu.Lock() + defer p.mu.Unlock() + for _, it := range items { + addr, err := p.address(it.AccountID) // 地址是链上匹配维度真相源 + if err != nil { + continue + } + tail, err := tailFromRef(it.ProviderRef) + if err != nil { + continue + } + until := it.ReservedAt.Add(amountCooldown) + if !until.After(now) { + continue // 冷却已过,金额可安全复用,无需恢复 + } + key := addr + "/" + strconv.FormatInt(it.AmountMinor+tail, 10) + if cur, ok := p.reserved[key]; !ok || until.After(cur) { + p.reserved[key] = until + } + } + return nil +} +``` +> `New` 里 `now: time.Now` 默认已存在,`WithNow` 允许测试覆盖。`import "context"` crypto.go 已有。 + +`internal/reconcile/crypto_warm.go`: +```go +package reconcile + +import ( + "context" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider/crypto" + "github.com/wangjia/pay/internal/store" +) + +// CryptoReservationLoader 装配 crypto 冷启动预留源:读全部 pending attempt, +// 过滤 channel=crypto,映射为 crypto.PendingReservation。ReservedAt 取 attempt.CreatedAt +// (建单时刻,冷却窗自此算)。crypto 不 import store,故此桥在装配层。 +func CryptoReservationLoader(orders *store.OrderStore) crypto.ReservationLoader { + return func(ctx context.Context) ([]crypto.PendingReservation, error) { + atts, err := orders.ListAttemptsByStatus(model.AttemptPending, 200) + if err != nil { + return nil, err + } + out := make([]crypto.PendingReservation, 0, len(atts)) + for i := range atts { + a := &atts[i] + if a.Channel != "crypto" { + continue + } + out = append(out, crypto.PendingReservation{ + AccountID: a.AccountID, AmountMinor: a.AmountMinor, + ProviderRef: a.ProviderRef, ReservedAt: a.CreatedAt, + }) + } + return out, nil + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/crypto/ ./internal/reconcile/ -v 2>&1 | tail -30` +Expected: 全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/crypto/crypto.go internal/provider/crypto/crypto_test.go internal/reconcile/crypto_warm.go internal/reconcile/crypto_warm_test.go +git commit -m "feat(v2): crypto 预留表冷启动兜底——注入 loader 从 pending attempts 重建 reservation(兜重启丢内存)" +``` + +--- + +### Task 4: LimitAware 真实用量数据源(DBUsageSource + RefreshUsage job) + +**债务 #5。** P5 的 `accounts.LimitAware` 靠 `UsageSource.TodayUsedMinor(accountID)` 判越限,生产装的是 `NopUsage`(恒 0,退化为 round_robin)。P5 D5 约定:真实数据源由 P6 提供。本任务实现 `reconcile.UsageSource`(满足 `accounts.UsageSource` 接口):持一个「账户→当日已收 minor」快照,由对账 job 周期 `Refresh` 从 attempts 表聚合(`status=paid AND paid_at>=当日起点`,`GROUP BY account_id` 求 `SUM(amount_minor)`)。**量纲**:attempt.AmountMinor 即该账户所属渠道结算币种的 minor(config 注释已在),故按 account_id 直接求和与 `DailyLimit` 同量纲,可比。快照读写加锁,Pick 路径零 DB(读快照);启动预热 Refresh 一次,之后 job 刷。 + +**Files:** +- Modify: `internal/store/order_query.go`(`SumPaidAttemptMinorByAccountSince`) +- Modify: `internal/store/order_query_test.go`(断言聚合) +- Create: `internal/reconcile/usage.go`(`UsageSource` 结构体 + `Refresh` + `RefreshUsageTask` + `StartOfDay`) +- Create: `internal/reconcile/usage_test.go` + +**Interfaces:** +- `store.(*OrderStore).SumPaidAttemptMinorByAccountSince(since time.Time) (map[string]int64, error)` +- `reconcile.NewUsageSource(orders *store.OrderStore, now func() time.Time) *UsageSource`;`(*UsageSource).TodayUsedMinor(accountID string) int64`(满足 `accounts.UsageSource`);`(*UsageSource).Refresh(ctx) error`;`reconcile.RefreshUsageTask(u *UsageSource) func(ctx) error`。 +- 当日起点用 `now()` 的日期(UTC)算(`StartOfDay`)——与 DailyLimit「按自然日」语义一致;跨区多实例各自 UTC 日窗,单实例部署无歧义。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/order_query_test.go` 追加: +```go +func TestSumPaidAttemptMinorByAccountSince(t *testing.T) { + db := model.OpenTestDB(t) + s := NewOrderStore(db) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + dayStart := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC) + + mk := func(no, acct string, minor int64, st model.AttemptStatus, paidAgo time.Duration) { + paid := now.Add(-paidAgo) + a := &model.Attempt{OutTradeNo: no, Channel: "alipay", AccountID: acct, ProviderRef: "R-" + no, + AmountMinor: minor, Currency: "CNY", Status: st, PaidAt: &paid} + if err := s.CreateAttempt(a); err != nil { + t.Fatalf("attempt %s: %v", no, err) + } + } + mk("A", "acct-1", 10000, model.AttemptPaid, 1*time.Hour) // 今日,计入 + mk("B", "acct-1", 5000, model.AttemptPaid, 2*time.Hour) // 今日,计入 → acct-1=15000 + mk("C", "acct-2", 7000, model.AttemptPaid, 30*time.Minute) // 今日 acct-2=7000 + mk("D", "acct-1", 9999, model.AttemptPending, 10*time.Minute) // 未付,不计 + mk("E", "acct-1", 8888, model.AttemptPaid, 20*time.Hour) // 昨天(paid_at < dayStart),不计 + + got, err := s.SumPaidAttemptMinorByAccountSince(dayStart) + if err != nil { + t.Fatalf("sum: %v", err) + } + if got["acct-1"] != 15000 || got["acct-2"] != 7000 { + t.Fatalf("聚合 = %+v, want acct-1=15000 acct-2=7000", got) + } +} +``` + +`internal/reconcile/usage_test.go`: +```go +package reconcile_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/reconcile" + "github.com/wangjia/pay/internal/store" +) + +func TestUsageSourceRefreshAndInterface(t *testing.T) { + db := model.OpenTestDB(t) + s := store.NewOrderStore(db) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + paid := now.Add(-time.Hour) + _ = s.CreateAttempt(&model.Attempt{OutTradeNo: "A", Channel: "alipay", AccountID: "acct-1", + ProviderRef: "R-A", AmountMinor: 12000, Currency: "CNY", Status: model.AttemptPaid, PaidAt: &paid}) + + u := reconcile.NewUsageSource(s, func() time.Time { return now }) + var _ accounts.UsageSource = u // 编译期断言满足接口 + + if u.TodayUsedMinor("acct-1") != 0 { + t.Fatalf("刷新前应 0") + } + if err := u.Refresh(context.Background()); err != nil { + t.Fatalf("refresh: %v", err) + } + if u.TodayUsedMinor("acct-1") != 12000 { + t.Fatalf("刷新后 acct-1 应 12000, got %d", u.TodayUsedMinor("acct-1")) + } + if u.TodayUsedMinor("unknown") != 0 { + t.Fatalf("未知账户应 0") + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/reconcile/ 2>&1 | tail -20` +Expected: 编译失败——`SumPaidAttemptMinorByAccountSince` / `reconcile.NewUsageSource` 未定义。 + +- [ ] **Step 3: 写实现** + +`internal/store/order_query.go` 追加: +```go +// SumPaidAttemptMinorByAccountSince 聚合各账户自 since 起的已付金额(minor),供 LimitAware +// 判当日用量。量纲:attempt.AmountMinor 即账户所属渠道结算币种 minor(与 DailyLimit 同量纲)。 +func (s *OrderStore) SumPaidAttemptMinorByAccountSince(since time.Time) (map[string]int64, error) { + type row struct { + AccountID string + Total int64 + } + var rows []row + if err := s.db.Model(&model.Attempt{}). + Select("account_id, SUM(amount_minor) AS total"). + Where("status = ? AND account_id <> '' AND paid_at >= ?", model.AttemptPaid, since). + Group("account_id").Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("store.SumPaidAttemptMinorByAccountSince: %w", err) + } + out := make(map[string]int64, len(rows)) + for _, r := range rows { + out[r.AccountID] = r.Total + } + return out, nil +} +``` + +`internal/reconcile/usage.go`: +```go +package reconcile + +import ( + "context" + "sync" + "time" + + "github.com/wangjia/pay/internal/store" +) + +// UsageSource 满足 accounts.UsageSource:持「账户→当日已收 minor」快照,对账 job 周期 Refresh。 +// Pick 路径读快照(零 DB);Refresh 从 attempts 聚合。跨天由 now() 的自然日窗自动滚动。 +type UsageSource struct { + orders *store.OrderStore + now func() time.Time + mu sync.RWMutex + snap map[string]int64 +} + +func NewUsageSource(orders *store.OrderStore, now func() time.Time) *UsageSource { + if now == nil { + now = time.Now + } + return &UsageSource{orders: orders, now: now, snap: map[string]int64{}} +} + +// TodayUsedMinor 读快照(accounts.UsageSource 接口);未刷新/未知账户返回 0(不误拒)。 +func (u *UsageSource) TodayUsedMinor(accountID string) int64 { + u.mu.RLock() + defer u.mu.RUnlock() + return u.snap[accountID] +} + +// Refresh 从 attempts 重算当日快照(幂等覆盖)。当日起点用 now() 的 UTC 日期。 +func (u *UsageSource) Refresh(ctx context.Context) error { + m, err := u.orders.SumPaidAttemptMinorByAccountSince(StartOfDay(u.now())) + if err != nil { + return err + } + u.mu.Lock() + u.snap = m + u.mu.Unlock() + return nil +} + +// StartOfDay 返回 t 所在 UTC 自然日 00:00(DailyLimit 按自然日结算)。 +func StartOfDay(t time.Time) time.Time { + y, mo, d := t.UTC().Date() + return time.Date(y, mo, d, 0, 0, 0, 0, time.UTC) +} + +// RefreshUsageTask 把 Refresh 包成周期任务体。 +func RefreshUsageTask(u *UsageSource) func(ctx context.Context) error { + return func(ctx context.Context) error { return u.Refresh(ctx) } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ ./internal/reconcile/ ./internal/accounts/ -v 2>&1 | tail -30` +Expected: 全 PASS(含 accounts 既有 limit_aware 测试仍绿)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/store/order_query.go internal/store/order_query_test.go internal/reconcile/usage.go internal/reconcile/usage_test.go +git commit -m "feat(v2): LimitAware 真实用量源——DBUsageSource 快照 + 对账 job 周期聚合当日已收(替 NopUsage)" +``` + +--- + +### Task 5: 对账主体 —— 周期 SyncPendingAttempts + 已付订单抽查 + main 装配 Runner + +**债务 #6(+ 继承 #7 已做的 settle-sync 日志)。** 对账主体两条腿:① **逐 pending attempt 查单收敛**——直接调度 P2 已有的 `gateway.SyncPendingAttempts`(内部已 query 每笔 pending、settle 命中、并对 not_found/mismatch/failed 打日志 = 债务 #7,764ed55);② **已付订单抽查核对**——对近期已付的 attempt 反向 `Query` 渠道,比对金额/币种,发现漂移(如渠道侧已退款/拒付但本地仍 paid)即记日志告警。**决策**:全量渠道对账单/流水列表下载(alipay `bill.download`/stripe `BalanceTransactions`)判定过重(拉大文件、解析对账单格式、跨日切分),本期降级为「逐 pending attempt query + 已付订单抽查」,已覆盖「防掉单(query 收敛)+ 防重复授权/漂移(抽查)」两大风险,论证见 Self-Review。最后把 Task 1–4 与本任务的 job 全部挂上 `reconcile.Runner`,接进 `main.go`。 + +**Files:** +- Modify: `internal/store/order_query.go`(`ListRecentlyPaidAttempts`) +- Modify: `internal/store/order_query_test.go` +- Create: `internal/reconcile/sync.go`(`SyncPendingTask` + `PaidSpotCheckTask`) +- Create: `internal/reconcile/sync_test.go` +- Modify: `config/config.go`(`ReconcileConfig` + 默认值) +- Modify: `main.go`(装配 Runner + 各 job + crypto Warm + DBUsageSource 注入 Router) + +**Interfaces:** +- `store.(*OrderStore).ListRecentlyPaidAttempts(since time.Time, limit int) ([]model.Attempt, error)`(`status=paid AND paid_at>=since`)。 +- `reconcile.SyncPendingTask(gw *gateway.Gateway, limit int) func(ctx) error`。 +- `reconcile.PaidSpotCheckTask(orders *store.OrderStore, providers *provider.Registry, window time.Duration, now func() time.Time) func(ctx) error`。 +- `config.ReconcileConfig{Enabled bool; OrderTTLMin, ExpireEverySec, SyncEverySec, UsageEverySec, SpotCheckEverySec, SpotCheckWindowMin int}`。 + +> 抽查只**日志告警不改状态**:退款/拒付的状态机翻转属 P4,本期仅发现并可见。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/order_query_test.go` 追加: +```go +func TestListRecentlyPaidAttempts(t *testing.T) { + db := model.OpenTestDB(t) + s := NewOrderStore(db) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + mk := func(no string, st model.AttemptStatus, paidAgo time.Duration) { + paid := now.Add(-paidAgo) + _ = s.CreateAttempt(&model.Attempt{OutTradeNo: no, Channel: "fake", ProviderRef: "R-" + no, + AmountMinor: 100, Currency: "USDT", Status: st, PaidAt: &paid}) + } + mk("RECENT", model.AttemptPaid, 10*time.Minute) // 近期已付 → 命中 + mk("OLD", model.AttemptPaid, 5*time.Hour) // 太旧 → 不命中 + mk("PEND", model.AttemptPending, 1*time.Minute) // 未付 → 不命中 + + got, err := s.ListRecentlyPaidAttempts(now.Add(-time.Hour), 50) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 || got[0].OutTradeNo != "RECENT" { + t.Fatalf("只应含 RECENT, got %+v", got) + } +} +``` + +`internal/reconcile/sync_test.go`(用 fake provider + gateway,复用其装配式;这里给独立最小装配): +```go +package reconcile_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/fake" + "github.com/wangjia/pay/internal/reconcile" + "github.com/wangjia/pay/internal/store" +) + +type stubResolver struct{} + +func (stubResolver) Resolve(sku, currency string) (int64, string, string, error) { + return 29990000, "Pro", "pro_year", nil +} + +type nopEnq struct{} + +func (nopEnq) Enqueue(_, _, _ string, _ map[string]any) error { return nil } + +func TestSyncPendingTaskSettlesViaQuery(t *testing.T) { + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + preg := provider.NewRegistry() + fp := fake.New() + preg.Register(fp) + areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true}}) + gw := gateway.New(orders, preg, accounts.NewRouter(areg, nil, nil), stubResolver{}, nopEnq{}, "global") + + res, _ := gw.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"}) + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ + ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}) + + task := reconcile.SyncPendingTask(gw, 50) + if err := task(context.Background()); err != nil { + t.Fatalf("task: %v", err) + } + o, _ := orders.GetOrder(res.OrderNo) + if o.Status != model.OrderPaidV2 { + t.Fatalf("查单对账后应 paid, got %v", o.Status) + } + _ = time.Second +} + +func TestPaidSpotCheckTaskRunsCleanOnConsistent(t *testing.T) { + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + preg := provider.NewRegistry() + fp := fake.New() + preg.Register(fp) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + paid := now.Add(-10 * time.Minute) + _ = orders.CreateAttempt(&model.Attempt{OutTradeNo: "O1", Channel: "fake", ProviderRef: "R-O1", + AmountMinor: 100, Currency: "USDT", Status: model.AttemptPaid, PaidAt: &paid}) + // 渠道侧查单仍报 succeeded 同额 → 一致,无告警。 + fp.SetQueryResult("R-O1", provider.PaidEvent{ProviderRef: "R-O1", Status: provider.PaidSucceeded, PaidAmountMinor: 100, PaidCurrency: "USDT"}) + + task := reconcile.PaidSpotCheckTask(orders, preg, time.Hour, func() time.Time { return now }) + if err := task(context.Background()); err != nil { + t.Fatalf("spotcheck: %v", err) // 只求不报错、不 panic;漂移检测走日志 + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/reconcile/ 2>&1 | tail -20` +Expected: 编译失败——`ListRecentlyPaidAttempts` / `SyncPendingTask` / `PaidSpotCheckTask` 未定义。 + +- [ ] **Step 3: 写实现** + +`internal/store/order_query.go` 追加: +```go +// ListRecentlyPaidAttempts 列近期(paid_at>=since)已付 attempt,供对账抽查反查渠道核对。 +func (s *OrderStore) ListRecentlyPaidAttempts(since time.Time, limit int) ([]model.Attempt, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + var out []model.Attempt + if err := s.db.Where("status = ? AND paid_at >= ?", model.AttemptPaid, since). + Order("id DESC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListRecentlyPaidAttempts: %w", err) + } + return out, nil +} +``` + +`internal/reconcile/sync.go`: +```go +package reconcile + +import ( + "context" + "log" + "time" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/store" +) + +// SyncPendingTask 调度 P2 gateway.SyncPendingAttempts:逐 pending attempt 查单收敛(防掉单)。 +// 其内部已对 not_found/amount_mismatch/failed 打日志(settle-sync,764ed55),此处不重复。 +func SyncPendingTask(gw *gateway.Gateway, limit int) func(ctx context.Context) error { + return func(ctx context.Context) error { + n, err := gw.SyncPendingAttempts(ctx, limit) + if err != nil { + return err + } + if n > 0 { + log.Printf("[reconcile] 查单对账收敛 %d 笔待支付 → paid", n) + } + return nil + } +} + +// PaidSpotCheckTask 已付订单抽查:对近 window 内已付 attempt 反查渠道,金额/币种漂移即告警 +// (如渠道侧已退款/拒付而本地仍 paid)。只发现不改状态——状态机翻转属 P4。 +// crypto 之类 query-only 渠道:paid 后再查若命中同额即一致;查不到(链上历史滚出窗口)不报错跳过。 +func PaidSpotCheckTask(orders *store.OrderStore, providers *provider.Registry, window time.Duration, now func() time.Time) func(ctx context.Context) error { + return func(ctx context.Context) error { + atts, err := orders.ListRecentlyPaidAttempts(now().Add(-window), 100) + if err != nil { + return err + } + for i := range atts { + a := &atts[i] + prov, err := providers.Get(a.Channel) + if err != nil { + continue + } + created := a.CreatedAt + ev, err := prov.Query(ctx, provider.QueryRequest{ + ProviderRef: a.ProviderRef, OutTradeNo: a.OutTradeNo, AccountID: a.AccountID, + AmountMinor: a.AmountMinor, Currency: a.Currency, CreatedAt: created, ExpiresAt: a.ExpiresAt, + }) + if err != nil || ev == nil { + continue // 查不到/瞬时错:抽查尽力而为,不阻断 + } + // 本地 paid,渠道却报非成功,或金额/币种对不上 → 对账差异,必须可见。 + if ev.Status != provider.PaidSucceeded || ev.PaidCurrency != a.Currency || ev.PaidAmountMinor < a.AmountMinor { + log.Printf("[reconcile][对账差异] attempt=%s channel=%s 本地 paid 但渠道 status=%s amount=%d/%s(本地 %d/%s)", + a.ProviderRef, a.Channel, ev.Status, ev.PaidAmountMinor, ev.PaidCurrency, a.AmountMinor, a.Currency) + } + } + return nil + } +} +``` + +`config/config.go` —— `Config` 加字段 + 结构体 + 默认值: +```go +// Config 结构体加一行: + QuerySync QuerySyncConfig `mapstructure:"query_sync"` + Reconcile ReconcileConfig `mapstructure:"reconcile"` +``` +```go +// ReconcileConfig v2 后台守护/对账周期任务开关与间隔。 +type ReconcileConfig struct { + Enabled bool `mapstructure:"enabled"` + OrderTTLMin int `mapstructure:"order_ttl_min"` // pending 订单存活 TTL(分钟),超则关闭 + ExpireEverySec int `mapstructure:"expire_every_sec"` // 过期清理间隔 + SyncEverySec int `mapstructure:"sync_every_sec"` // 查单对账间隔 + UsageEverySec int `mapstructure:"usage_every_sec"` // 用量快照刷新间隔 + SpotCheckEverySec int `mapstructure:"spot_check_every_sec"` // 已付抽查间隔 + SpotCheckWindowMin int `mapstructure:"spot_check_window_min"` // 抽查回溯窗(分钟) + OrphanEverySec int `mapstructure:"orphan_every_sec"` // crypto 孤儿扫描间隔(Task 6) + OrphanWindowMin int `mapstructure:"orphan_window_min"` // 孤儿扫描回溯窗(Task 6) +} +``` +`Load()` 里补默认(在 query_sync 默认附近): +```go + viper.SetDefault("reconcile.enabled", true) + viper.SetDefault("reconcile.order_ttl_min", 60) + viper.SetDefault("reconcile.expire_every_sec", 300) + viper.SetDefault("reconcile.sync_every_sec", 30) + viper.SetDefault("reconcile.usage_every_sec", 60) + viper.SetDefault("reconcile.spot_check_every_sec", 300) + viper.SetDefault("reconcile.spot_check_window_min", 180) + viper.SetDefault("reconcile.orphan_every_sec", 300) + viper.SetDefault("reconcile.orphan_window_min", 180) +``` + +`main.go` —— 装配(替换现有 notifier/Router/gateway 段并追加 Runner)。关键改动: +1. `import "context"` + `"github.com/wangjia/pay/internal/reconcile"` + `"github.com/wangjia/pay/internal/provider/crypto"`(crypto 仅用于 Warm 类型断言,见下 Task 6 亦需); +2. 用 `reconcile.NewUsageSource` 替 `accounts.NopUsage{}` 注入 Router; +3. crypto Warm(若注册了 crypto):给 `providerbuild.BuildRegistry` 传 loader,或建后取 crypto 实例 Warm。**装配式**:crypto 的 loader 需要 orderStore,故 crypto Provider 的构造要能带 `WithReservationLoader`。若 `providerbuild.BuildRegistry` 不便传 loader,改为 Warm 前对注册表里的 crypto 实例调 `Warm`——为此让 crypto 在 build 时就注入 loader。**最小改动**:在 `providerbuild` 暴露把 loader 传进 crypto.New 的入口,或 main 直接从 `pReg.Get("crypto")` 拿到 `*crypto.Provider` 调 `Warm`(loader 用 `reconcile.CryptoReservationLoader(orderStore)`,但 loader 是构造期注入的……)。**决策**:给 `crypto.(*Provider)` 的 loader 支持**构造后注入**——`WithReservationLoader` 已是 Option 构造期注入;为让 main 在 build 之后注入 loader,补一个 setter `SetReservationLoader(l)`。见下 Step 3a。 +```go + orderStore := store.NewOrderStore(db) + webhookStore := store.NewWebhookStore(db) + notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) { + o, err := orderStore.GetOrder(no) + if err != nil { + return false, err + } + return o.Status == model.OrderPaidV2, nil + }) // 硬化选项走默认(退避/死信/告警);如需覆盖用 webhook.WithMaxAttempts 等 + notifier.Start(60 * time.Second) + + productResolver := gateway.NewDBProductResolver(db) + acctReg := accounts.New(config.C.Accounts) + pReg := providerbuild.BuildRegistry(acctReg) + usage := reconcile.NewUsageSource(orderStore, time.Now) // P6 真实用量源替 NopUsage + acctPicker := accounts.NewRouter(acctReg, config.C.Routing, usage) + gw := gateway.New(orderStore, pReg, acctPicker, productResolver, notifier, "cn") + router.SetupV2(r, gw) + + // P6 后台守护 / 对账 + if config.C.Reconcile.Enabled { + rc := config.C.Reconcile + runner := reconcile.NewRunner() + runner.Add("order-expire", time.Duration(rc.ExpireEverySec)*time.Second, + reconcile.OrderExpirerTask(orderStore, time.Duration(rc.OrderTTLMin)*time.Minute, time.Now)) + runner.Add("usage-refresh", time.Duration(rc.UsageEverySec)*time.Second, + reconcile.RefreshUsageTask(usage)) + runner.Add("sync-pending", time.Duration(rc.SyncEverySec)*time.Second, + reconcile.SyncPendingTask(gw, 100)) + runner.Add("paid-spotcheck", time.Duration(rc.SpotCheckEverySec)*time.Second, + reconcile.PaidSpotCheckTask(orderStore, pReg, time.Duration(rc.SpotCheckWindowMin)*time.Minute, time.Now)) + // crypto 孤儿扫描(Task 6)在此追加。 + reconcile.AddCryptoJobs(runner, pReg, acctReg, orderStore, rc) // Task 6 提供;含 Warm + 孤儿扫描注册 + + ctx := context.Background() + runner.RunOnce(ctx) // 启动预热:先跑一遍(usage 快照/过期清理立即生效) + runner.Start(ctx) + log.Printf("[reconcile] 后台守护已启动(过期清理/用量刷新/查单对账/已付抽查/孤儿扫描)") + } +``` +> `reconcile.AddCryptoJobs` 与 crypto Warm/孤儿逻辑落在 **Task 6**;本任务先让 main 编译通过可**临时**用一个占位……**不**:禁占位。改为 Task 5 只装配前四个 job + `runner.RunOnce/Start`,把 `AddCryptoJobs` 这一行留到 Task 6 再加(Task 6 Step 3 明确「在 main 追加此行」)。故本 Step 3 的 main 片段**删掉 `AddCryptoJobs` 那行**,Task 6 再插回。 + +- [ ] **Step 3a: crypto loader 构造后注入(供 main Warm 用)** + +`internal/provider/crypto/crypto.go` 补 setter(Task 6 的 Warm 装配依赖): +```go +// SetReservationLoader 构造后注入冷启动预留源(装配期 main 在 BuildRegistry 之后调用: +// loader 依赖 OrderStore,而注册表构造不便传 store)。非并发安全,仅启动期单线程调用。 +func (p *Provider) SetReservationLoader(l ReservationLoader) { p.loader = l } +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ ./internal/reconcile/ ./internal/gateway/ -v 2>&1 | tail -30` +Expected: 全 PASS;`go build ./...`(main 装配)通过。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/store/order_query.go internal/store/order_query_test.go internal/reconcile/sync.go internal/reconcile/sync_test.go internal/provider/crypto/crypto.go config/config.go main.go +git commit -m "feat(v2): 对账主体——周期查单收敛 + 已付订单抽查 + main 装配 reconcile Runner(前 4 job)" +``` + +--- + +### Task 6: crypto 孤儿链上支付发现(OrphanScanner + orphan 记录/告警) + +**债务 #3。** 到账但**不匹配任何 attempt** 的转账(付款人转错金额、手动转账、迟到到一笔金额早已滚出所有活跃单的旧款)必须被发现并记录,不能静默。canonical(pangolin-pay watcher)把这类记 `orphan_payments` 供人工对账。本任务:中性可选接口 `provider.OrphanScanner`,crypto 实现(扫地址近 window 确认到账,金额不在「近期任一 attempt 的期望金额集」内 → orphan);`reconcile` 收集 crypto 账户的已知期望金额、调 `ScanOrphans`、把 orphan 落 `model.OrphanPayment`(tx_id unique 去重)+ 告警。同时把 crypto Warm 也在此接进 main(`AddCryptoJobs`)。 + +**Files:** +- Modify: `internal/provider/provider.go`(中性 `OrphanScanner` 接口 + `OrphanScanRequest`/`OrphanTransfer`/`KnownAttempt`) +- Modify: `internal/provider/crypto/crypto.go`(实现 `ScanOrphans`) +- Modify: `internal/provider/crypto/crypto_test.go`(httptest 假 TronGrid:一笔匹配 + 一笔孤儿) +- Create: `internal/model/orphan.go`(`OrphanPayment` 表) +- Modify: `main.go`(AutoMigrate 加表) +- Create: `internal/store/orphan.go`(`OrphanStore.Record` 幂等) +- Create: `internal/store/orphan_test.go` +- Create: `internal/reconcile/orphan.go`(`AddCryptoJobs`:Warm + `OrphanScanTask`) +- Create: `internal/reconcile/orphan_test.go` +- Modify: `main.go`(追加 `reconcile.AddCryptoJobs(...)` 那行 + import crypto/context 已在 Task 5) + +**Interfaces:** +- `provider.OrphanScanRequest{AccountID string; Since time.Time; Known []KnownAttempt}`;`provider.KnownAttempt{AmountMinor int64; ProviderRef string}`(期望金额 = base + tail,tail 由渠道自解 ref,pay 不算);`provider.OrphanTransfer{TxID string; AmountMinor int64; Currency string; At time.Time}`;`provider.OrphanScanner interface { ScanOrphans(ctx, OrphanScanRequest) ([]OrphanTransfer, error) }`。 +- `store.(*OrderStore)` 复用 `ListAttemptsByChannelSince`(新增:任意状态、channel、created_at>=since)——arphan 的「已知期望金额集」含 pending/paid/expired 全部,凡 pay 合法签发过的金额都不算孤儿。 +- `store.(*OrphanStore).Record(o *model.OrphanPayment) (bool, error)`(tx_id 冲突 no-op,返回是否新记)。 +- `reconcile.AddCryptoJobs(runner *Runner, providers *provider.Registry, accts *accounts.Registry, orders *store.OrderStore, cfg config.ReconcileConfig)`。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/order_query.go` 先补 `ListAttemptsByChannelSince`(Task 6 需要),测试放 order_query_test.go: +```go +func TestListAttemptsByChannelSince(t *testing.T) { + db := model.OpenTestDB(t) + s := NewOrderStore(db) + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + mk := func(no, ch string, ago time.Duration) { + _ = s.CreateAttempt(&model.Attempt{OutTradeNo: no, Channel: ch, ProviderRef: "R-" + no, + AmountMinor: 100, Currency: "USDT", Status: model.AttemptPending}) + _ = db.Model(&model.Attempt{}).Where("out_trade_no = ?", no).Update("created_at", now.Add(-ago)).Error + } + mk("C1", "crypto", 10*time.Minute) + mk("C2", "crypto", 5*time.Hour) // 太旧 + mk("A1", "alipay", 1*time.Minute) + + got, err := s.ListAttemptsByChannelSince("crypto", now.Add(-time.Hour), 100) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 || got[0].OutTradeNo != "C1" { + t.Fatalf("只应含近期 crypto, got %+v", got) + } +} +``` + +`internal/store/orphan_test.go`: +```go +package store_test + +import ( + "testing" + "time" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/store" +) + +func TestOrphanStoreRecordIdempotent(t *testing.T) { + os := store.NewOrphanStore(model.OpenTestDB(t)) + o := &model.OrphanPayment{Channel: "crypto", AccountID: "cry-1", TxID: "TX-1", + AmountMinor: 12345, Currency: "USDT", DetectedAt: time.Now()} + first, err := os.Record(o) + if err != nil || !first { + t.Fatalf("首次应记入, first=%v err=%v", first, err) + } + again, err := os.Record(&model.OrphanPayment{Channel: "crypto", AccountID: "cry-1", TxID: "TX-1", + AmountMinor: 12345, Currency: "USDT", DetectedAt: time.Now()}) + if err != nil || again { + t.Fatalf("同 tx_id 应幂等 no-op, again=%v err=%v", again, err) + } +} +``` + +`internal/provider/crypto/crypto_test.go` 追加(假 TronGrid 返回两笔:一笔金额匹配 known、一笔不匹配 → 仅后者为 orphan): +```go +func TestScanOrphansFlagsUnmatchedTransfer(t *testing.T) { + const addr = "TOrphanScanAddr00000000000000000000" + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + // 假 TronGrid:to=addr 两笔确认到账。29990263 匹配 known(base 29990000 + tail 263);88880000 无主。 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[ + {"transaction_id":"TX-MATCH","to":"` + addr + `","type":"Transfer","value":"29990263","block_timestamp":` + strconv.FormatInt(now.Add(-5*time.Minute).UnixMilli(), 10) + `}, + {"transaction_id":"TX-ORPHAN","to":"` + addr + `","type":"Transfer","value":"88880000","block_timestamp":` + strconv.FormatInt(now.Add(-3*time.Minute).UnixMilli(), 10) + `} + ]}`)) + })) + defer ts.Close() + + t.Setenv("CRY_ADDRESS", addr) + reg := accounts.New([]config.AccountConfig{{AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "cry"}}) + p := crypto.New(reg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client()), crypto.WithNow(func() time.Time { return now })) + + orphans, err := p.ScanOrphans(context.Background(), provider.OrphanScanRequest{ + AccountID: "cry-1", Since: now.Add(-time.Hour), + Known: []provider.KnownAttempt{{AmountMinor: 29990000, ProviderRef: "CRYPTO-PAY-A-263"}}, // 期望 29990263 + }) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(orphans) != 1 || orphans[0].TxID != "TX-ORPHAN" || orphans[0].AmountMinor != 88880000 { + t.Fatalf("只应报 1 笔孤儿 TX-ORPHAN, got %+v", orphans) + } +} +``` + +`internal/reconcile/orphan_test.go`(loader→scan→record 全链;用上面的假 TronGrid 同构): +```go +package reconcile_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/crypto" + "github.com/wangjia/pay/internal/reconcile" + "github.com/wangjia/pay/internal/store" +) + +func TestOrphanScanTaskRecordsUnmatched(t *testing.T) { + const addr = "TOrphanJobAddr0000000000000000000000" + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[ + {"transaction_id":"TX-ORPHAN","to":"` + addr + `","type":"Transfer","value":"88880000","block_timestamp":` + strconv.FormatInt(now.Add(-3*time.Minute).UnixMilli(), 10) + `} + ]}`)) + })) + defer ts.Close() + + t.Setenv("CRY_ADDRESS", addr) + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + orphanStore := store.NewOrphanStore(db) + acctReg := accounts.New([]config.AccountConfig{{AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "cry"}}) + preg := provider.NewRegistry() + preg.Register(crypto.New(acctReg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client()), crypto.WithNow(func() time.Time { return now }))) + + task := reconcile.OrphanScanTask(preg, acctReg, orders, orphanStore, time.Hour, func() time.Time { return now }) + if err := task(context.Background()); err != nil { + t.Fatalf("task: %v", err) + } + var cnt int64 + db.Model(&model.OrphanPayment{}).Where("tx_id = ?", "TX-ORPHAN").Count(&cnt) + if cnt != 1 { + t.Fatalf("应落 1 条孤儿, got %d", cnt) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/provider/crypto/ ./internal/reconcile/ 2>&1 | tail -20` +Expected: 编译失败——`OrphanScanner`/`OrphanScanRequest`/`OrphanPayment`/`OrphanStore`/`OrphanScanTask`/`ListAttemptsByChannelSince` 未定义。 + +- [ ] **Step 3: 写实现** + +`internal/provider/provider.go` 追加(中性类型 + 可选接口,类比 RefundingProvider): +```go +// ---- 对账:孤儿到账扫描(P6,可选接口)---- + +// KnownAttempt 是 pay 合法签发过的一笔尝试的对账维度:期望金额 = base(AmountMinor)+ 渠道尾数 +// (尾数封在 provider_ref,由渠道自解,pay 不算)。渠道据此判断一笔到账是否"有主"。 +type KnownAttempt struct { + AmountMinor int64 + ProviderRef string +} + +// OrphanScanRequest 扫描某账户 Since 以来、不匹配任何 Known 的到账。 +type OrphanScanRequest struct { + AccountID string + Since time.Time + Known []KnownAttempt +} + +// OrphanTransfer 一笔"有钱到账但无主"的转账(付错金额/手动转/超窗迟到旧款)。 +type OrphanTransfer struct { + TxID string + AmountMinor int64 + Currency string + At time.Time +} + +// OrphanScanner 自托管渠道(crypto)可选实现:发现到账但不匹配任何 attempt 的转账。 +// 网关侧渠道(alipay/stripe)以对账单核对,不实现此接口。 +type OrphanScanner interface { + ScanOrphans(ctx context.Context, req OrphanScanRequest) ([]OrphanTransfer, error) +} +``` + +`internal/provider/crypto/crypto.go` 实现 `ScanOrphans`(复用 `Query` 里的 TronGrid 请求构造 + tailFromRef): +```go +// ScanOrphans 扫地址近 Since 的确认到账,金额不在"任一 Known 的期望金额集"内 → 孤儿。 +// 期望金额 = known.AmountMinor + tailFromRef(known.ProviderRef);块时须晚于 Since。 +func (p *Provider) ScanOrphans(ctx context.Context, req provider.OrphanScanRequest) ([]provider.OrphanTransfer, error) { + addr, err := p.address(req.AccountID) + if err != nil { + return nil, err + } + expected := make(map[int64]struct{}, len(req.Known)) + for _, k := range req.Known { + tail, terr := tailFromRef(k.ProviderRef) + if terr != nil { + continue // 无尾数的 ref 跳过(不误判为孤儿依据) + } + expected[k.AmountMinor+tail] = struct{}{} + } + + endpoint := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50", + p.baseURL, url.PathEscape(addr), url.QueryEscape(USDTContract)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + if k := p.apiKey(req.AccountID); k != "" { + httpReq.Header.Set("TRON-PRO-API-KEY", k) + } + resp, err := p.http.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("crypto: TronGrid HTTP %d: %s", resp.StatusCode, body) + } + var tr trc20Resp + if err := json.Unmarshal(body, &tr); err != nil { + return nil, err + } + + sinceUnix := req.Since.Unix() + var out []provider.OrphanTransfer + for _, d := range tr.Data { + if d.To != addr || d.Type != "Transfer" { + continue + } + blockTs := d.BlockMs / 1000 + if blockTs < sinceUnix { + continue // 窗外旧款不扫(避免把历史正常单反复报孤儿) + } + val, perr := strconv.ParseInt(d.Value, 10, 64) + if perr != nil { + continue + } + if _, ok := expected[val]; ok { + continue // 金额有主(匹配某 attempt 期望额)→ 非孤儿 + } + out = append(out, provider.OrphanTransfer{ + TxID: d.TxID, AmountMinor: val, Currency: "USDT", At: time.Unix(blockTs, 0), + }) + } + return out, nil +} +``` + +`internal/model/orphan.go`: +```go +package model + +import "time" + +// OrphanPayment 到账但不匹配任何 attempt 的转账(对账发现,供人工核对/退款)。 +// tx_id 唯一 → 重复扫描幂等 no-op。canonical pangolin-pay orphan_payments 的 v2 对应物。 +type OrphanPayment struct { + Base + Channel string `gorm:"index;size:32;not null"` + AccountID string `gorm:"index;size:64"` + TxID string `gorm:"uniqueIndex;size:128;not null"` + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + DetectedAt time.Time + Note string `gorm:"size:255"` +} +``` + +`main.go` 的 `autoMigrate` 参数表加 `&model.OrphanPayment{}`。 + +`internal/store/orphan.go`: +```go +package store + +import ( + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/wangjia/pay/internal/model" +) + +type OrphanStore struct{ db *gorm.DB } + +func NewOrphanStore(db *gorm.DB) *OrphanStore { return &OrphanStore{db: db} } + +// Record 幂等落一条孤儿(tx_id 冲突 no-op)。返回是否新记(供告警只喊一次)。 +func (s *OrphanStore) Record(o *model.OrphanPayment) (bool, error) { + res := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tx_id"}}, + DoNothing: true, + }).Create(o) + if res.Error != nil { + return false, fmt.Errorf("store.OrphanStore.Record: %w", res.Error) + } + return res.RowsAffected > 0, nil +} +``` + +`internal/store/order_query.go` 补 `ListAttemptsByChannelSince`: +```go +// ListAttemptsByChannelSince 列某渠道 created_at>=since 的 attempt(任意状态), +// 供 orphan 扫描构造"已知期望金额集"(凡 pay 合法签发过的金额都不算孤儿)。 +func (s *OrderStore) ListAttemptsByChannelSince(channel string, since time.Time, limit int) ([]model.Attempt, error) { + if limit <= 0 || limit > 500 { + limit = 200 + } + var out []model.Attempt + if err := s.db.Where("channel = ? AND created_at >= ?", channel, since). + Order("id DESC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListAttemptsByChannelSince: %w", err) + } + return out, nil +} +``` + +`internal/reconcile/orphan.go`: +```go +package reconcile + +import ( + "context" + "log" + "time" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/crypto" + "github.com/wangjia/pay/internal/store" +) + +// OrphanScanTask 对每个 enabled crypto 账户扫链找孤儿到账(不匹配任何近期 attempt), +// 落 OrphanStore + 首次记入时告警。渠道须实现 provider.OrphanScanner(crypto 实现)。 +func OrphanScanTask(providers *provider.Registry, accts *accounts.Registry, orders *store.OrderStore, + orphans *store.OrphanStore, window time.Duration, now func() time.Time) func(ctx context.Context) error { + return func(ctx context.Context) error { + prov, err := providers.Get("crypto") + if err != nil { + return nil // 未启用 crypto:无事可做 + } + scanner, ok := prov.(provider.OrphanScanner) + if !ok { + return nil + } + since := now().Add(-window) + for _, acc := range accts.EnabledFor("crypto", "") { + atts, err := orders.ListAttemptsByChannelSince("crypto", since, 200) + if err != nil { + return err + } + known := make([]provider.KnownAttempt, 0, len(atts)) + for i := range atts { + if atts[i].AccountID != acc.AccountID { + continue + } + known = append(known, provider.KnownAttempt{AmountMinor: atts[i].AmountMinor, ProviderRef: atts[i].ProviderRef}) + } + found, err := scanner.ScanOrphans(ctx, provider.OrphanScanRequest{ + AccountID: acc.AccountID, Since: since, Known: known, + }) + if err != nil { + log.Printf("[reconcile] 孤儿扫描 account=%s: %v", acc.AccountID, err) + continue // 单账户失败不阻断其它 + } + for _, o := range found { + isNew, rerr := orphans.Record(&model.OrphanPayment{ + Channel: "crypto", AccountID: acc.AccountID, TxID: o.TxID, + AmountMinor: o.AmountMinor, Currency: o.Currency, DetectedAt: o.At, + Note: "到账无主:不匹配任何近期 attempt 期望金额", + }) + if rerr != nil { + log.Printf("[reconcile] 记录孤儿失败 tx=%s: %v", o.TxID, rerr) + continue + } + if isNew { + log.Printf("[reconcile][孤儿告警] channel=crypto account=%s tx=%s amount=%d %s", + acc.AccountID, o.TxID, o.AmountMinor, o.Currency) + } + } + } + return nil + } +} + +// AddCryptoJobs 装配 crypto 相关后台任务:① 冷启动 Warm(注入 loader 后立即重建预留); +// ② 周期孤儿扫描。若未启用 crypto 渠道则安全跳过。 +func AddCryptoJobs(runner *Runner, providers *provider.Registry, accts *accounts.Registry, + orders *store.OrderStore, orphans *store.OrphanStore, cfg config.ReconcileConfig) { + prov, err := providers.Get("crypto") + if err != nil { + return + } + if cp, ok := prov.(*crypto.Provider); ok { + cp.SetReservationLoader(CryptoReservationLoader(orders)) + if werr := cp.Warm(context.Background()); werr != nil { // 起服务前重建预留 + log.Printf("[reconcile] crypto 预留冷启动重建: %v", werr) + } + } + runner.Add("crypto-orphan-scan", time.Duration(cfg.OrphanEverySec)*time.Second, + OrphanScanTask(providers, accts, orders, orphans, + time.Duration(cfg.OrphanWindowMin)*time.Minute, time.Now)) +} +``` + +`main.go` —— 在 Task 5 装配段的注释处补一行(需 `orphanStore := store.NewOrphanStore(db)`): +```go + orphanStore := store.NewOrphanStore(db) + reconcile.AddCryptoJobs(runner, pReg, acctReg, orderStore, orphanStore, rc) +``` +(放在 `runner.RunOnce(ctx)` 之前,使 Warm 在起服务前完成、孤儿 job 一并进 runner。) + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./... 2>&1 | tail -30` +Expected: 全仓 PASS;`go build ./...` 通过。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/provider.go internal/provider/crypto/crypto.go internal/provider/crypto/crypto_test.go internal/model/orphan.go internal/store/orphan.go internal/store/orphan_test.go internal/store/order_query.go internal/store/order_query_test.go internal/reconcile/orphan.go internal/reconcile/orphan_test.go main.go +git commit -m "feat(v2): crypto 孤儿到账发现——OrphanScanner 扫链核对 + orphan_payments 落表告警(对账兜底)" +``` + +--- + +## Self-Review + +**Spec coverage(P6 范围,对照设计 §8「对账优先于实时」+ 三份前置计划 routed 到 P6 的债务):** +- **订单级过期清理**(债务 #1,Task 1)= 超 TTL pending 单条件 UPDATE 关闭,含零尝试孤儿单;与 attempt 级 `ExpirePendingAttempts` 语义严格区分 ✓。 +- **Notifier 硬化**(债务 #2,Task 2)= 指数退避(`base·2^(n-1)` 封顶)+ 最大次数死信(`dead` 列)+ 告警钩子(`WithAlerter`,默认 log);投递门禁(未付不投不计失败)与 unique 键幂等不破 ✓。 +- **crypto 预留冷启动兜底**(债务 #4,Task 3)= 注入 `ReservationLoader` 从 pending crypto attempts 重建 `reserved`,兜重启丢内存;crypto 不 import store(中性结构 + 装配层桥)✓。 +- **LimitAware 真实数据源**(债务 #5,Task 4)= `reconcile.UsageSource` 满足 `accounts.UsageSource`,对账 job 周期从 attempts 聚合当日已收(按账户 = 渠道结算币种 minor,与 DailyLimit 同量纲);Router 注入它替 `NopUsage`,`LimitAware`/`Router` 零改动(兑现 P5 D5)✓。 +- **对账主体**(债务 #6,Task 5)= 周期 `SyncPendingAttempts`(查单收敛,防掉单;继承其 settle-sync 日志 = 债务 #7 已做,不重复)+ 已付订单抽查反查渠道(防重复授权/漂移)✓。 +- **crypto 孤儿发现**(债务 #3,Task 6)= 中性 `OrphanScanner`,crypto 扫链找无主到账,落 `OrphanPayment`(tx_id unique 幂等)+ 首记告警 ✓。 + +**关键决策:** +- **后台 job 统一形态**:`reconcile.Runner` 一任务一 ticker + `recover` + `RunOnce`(启动预热 + 单测入口);每任务幂等 sweep,崩溃后下 tick 自愈。时钟一律注入(`now func() time.Time`),cutoff/since/退避 Go 端算,SQL 不用时间函数(sqlite/mysql 可移植)。 +- **对账深度(债务 #6 决策)**:**全量渠道对账单下载判定过重**——alipay `bill.download`(拉 CSV/ZIP、解析对账单格式、按结算日切分、处理手续费行)与 stripe `BalanceTransactions` 分页拉全量,工程量与运维面(存储/解析/对差)远超本期单实例小体量所需。**降级为「逐 pending attempt query(已存在)+ 已付订单抽查反查」**:前者防「掉单」(回调丢失→查单收敛),后者防「重复授权/状态漂移」(本地 paid 但渠道退款/拒付→日志告警)。crypto 因自托管无对账单,其「渠道流水核对」正是 Task 6 的扫链孤儿发现。这套已覆盖设计 §8 两大风险(防掉单 + 防重复授权),真需要全量对账单时再单开任务。 +- **orphan「已知金额集」取全状态近窗 attempt**:pending/paid/expired 一律计入期望金额集——凡 pay 合法签发过的金额都不算孤儿(一笔匹配已 paid attempt 金额的到账 = 正常付款,非孤儿;匹配 pending/expired 的 = settle-sync 处理,非孤儿)。孤儿严格 = 「到账金额不在任何近窗 attempt 期望额内」(付错额/手动转/超窗旧款),精确对齐债务 #3 措辞。窗外旧款不扫,避免历史单反复误报。 +- **抽查只发现不改状态**:退款/拒付的订单态机翻转属 P4,本期仅日志告警使其可见(不越权改 P4 的状态语义)。 +- **Notifier options 向后兼容**:`NewNotifier(...opts)` 变参,现有 3 参调用不改;`ListUndelivered` 保留(测试断言用),Notifier 内部改用退避感知的 `ListDeliverable`。 + +**Provider 中性守恒:** `internal/gateway`、`internal/provider` 核心不 import `reconcile`;crypto 不 import `store`。孤儿经中性 `provider.OrphanScanner`/`OrphanScanRequest` 传递,`reconcile` type-assert 到具体 `*crypto.Provider` 仅在装配文件 `reconcile/orphan.go`(装配层,类比 main)。`reconcile` 依赖 store/gateway/accounts/provider/crypto/config 属装配层合理。 + +**幂等 / 崩溃安全审计:** 过期清理 = `status=pending` 守卫条件 UPDATE(并发 settle 翻转互斥,重跑 0 行);退避/死信 = 列状态持久化,重启续跑;usage = 快照幂等覆盖;孤儿 = tx_id unique ON CONFLICT DO NOTHING;Warm = 只加不覆盖更早到期、冷却过期跳过。全部可重跑、崩溃后自愈。 + +**Placeholder scan:** 无 TODO/占位;每任务给完整 Go 代码 + 测试 + 命令。Task 5 Step 3 明确把 `AddCryptoJobs` 那行留到 Task 6 插入(避免 Task 5 引用未定义符号),非占位——两任务顺序落地后 main 完整。 + +**已知取舍(记录,不阻塞 P6):** +- **单实例假设**:crypto 预留 Warm + orphan 扫描 + usage 快照均**进程内 + 单 pay 实例**语义(P3 已定「同一收款地址单实例服务」)。多实例需共享预留存储 + 扫描去重锁 + 分布式选主,超本期(设计目标单 VPS 单实例)。 +- **usage 快照有刷新延迟**:`UsageEverySec`(默认 60s)窗内新付款未计入,`LimitAware` 可能瞬时放行略超 DailyLimit 的单——DailyLimit 是「分摊避免单账户触风控」的软阈,非硬账,秒级漂移可接受;要严格实时可改 Pick 路径直查(本期不做,避免每单 N 次 DB)。 +- **抽查/孤儿扫描 TronGrid limit=50**:单次拉最近 50 笔,超高频账户可能漏页——本期小体量足够,需要时加分页/游标。 +- **Prometheus/metrics 体系排除**:告警仅 log 钩子(`WithAlerter` / `[孤儿告警]` / `[对账差异]` 日志),接 metrics/Telegram 属后续运维接线,不在 P6。 +- **真实渠道对账单下载排除**(见上决策),alipay/stripe 的渠道侧流水核对靠抽查反查 query 覆盖。 + +**排除项(各自独立):** 退款态机 P4 · 多账户路由策略 P5(已完成)· 订阅/recurring P8 · metrics/告警外发体系(运维接线)。 + +> 契约同步(pay-contract):webhook `WebhookDelivery` 新增 `dead`/`next_attempt_at` 列(投递侧内部状态,不影响业务方 payload);新增 `orphan_payments` 表(内部对账,无对外接口);config 新增 `reconcile.*` 段。均向后兼容(AutoMigrate 只加不删)。 diff --git a/docs/superpowers/plans/2026-07-10-pay-v2-p8-recurring.md b/docs/superpowers/plans/2026-07-10-pay-v2-p8-recurring.md new file mode 100644 index 0000000..757ff37 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pay-v2-p8-recurring.md @@ -0,0 +1,1081 @@ +# pay v2 · P8 订阅/recurring(Stripe gateway_scheduled)+ 拒付 chargeback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐 Task 实现。步骤用 checkbox(`- [ ]`)跟踪。每个 Task 自包含,给完整 Go 代码 + 测试 + 命令,可交给全新 implementer subagent。严格 TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。 + +> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html`(重点 §5.1 recurring 4 类 kind、§5 webhook v2 `event_type`(`subscription.*`/`chargeback`)、§6 拒付「平台发起 → pay 转发 chargeback」、§3 Order/Attempt/Account)。 +> **前置计划(已完成,直接复用不重造):** +> - P1 `docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md`(`internal/model/v2.go`、`internal/store`、`internal/money`、`internal/accounts`)。 +> - P2 `docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md`(`internal/provider`、`internal/gateway`、webhook v2 outbox;`RecurringProvider` 可选接口已在此定义)。 +> - P3 `docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md`(`internal/provider/stripe` Checkout 一次性收款 adapter + `providerbuild` 装配;本计划在其上加订阅链路)。 +> - P5 `docs/superpowers/plans/2026-07-10-pay-v2-p5-account-routing.md`(多账户路由)。 +> - **P4(退款,`refund.succeeded` 事件)与本计划并行编写中。** 本计划的事件幂等键解法(每期续费独立 `out_trade_no`,复用 outbox 唯一键 `(out_trade_no,event_type)`)与 P4「每笔退款独立 `refund_id` 派生 `out_trade_no`」同构——**两者共用同一原则:凡"一单多次异步事件"(多次续费/多次退款),都靠"给每次事件铸一个新 out_trade_no"来落进 `(out_trade_no,event_type)` 唯一键,不改 outbox schema**。若 P4 落地后此原则有出入,以先合并者为准并回归对齐。 + +**Goal:** 把设计里「设计进模型、暂不实现」的 recurring 落成**最小可行一条真实链路**:Stripe 订阅(`recurring_kind=gateway_scheduled`,Checkout `mode=subscription` + `invoice.paid` webhook 驱动续费)。交付:①`Subscription` 实体 + 三态状态机(`active`/`past_due`/`canceled`)+ store;②Stripe 订阅 Checkout 创建 + 首期激活(诞生 subscription + `subscription.created`);③`invoice.paid` 续费入账(每期一张 renewal order,复用 P2 settle 幂等 + `subscription.renewed`);④取消(API 主动 + 入站 `customer.subscription.deleted`);⑤拒付(`charge.dispute.created` → `Chargeback` 记录 + `chargeback.received` + 订单标记,**不自动回收权益**);⑥业务方 webhook 事件集声明式扩展。 + +**明确排除(留能力位,本轮不实现,各处 Capabilities/注释写清):** +- `token_offsession`(我方 cron 主动扣款:Stripe 自建 SetupIntent / 支付宝周期扣 / 微信 papay)——P2 已定义 `provider.RecurringProvider`(`CreateAgreement`/`Charge`/`CancelAgreement`)作为该 kind 的能力位,**本轮不给任何实现**,stripe 走的是 `gateway_scheduled`(网关驱动续费),不经 pay 主动 `Charge`。 +- `store_managed`(Apple IAP / Google Play,被动接 Server Notification/RTDN)——仅设计,不实现。 +- alipay 周期扣款、crypto(天生无 recurring、无 chargeback,§6「钱到账不可逆」)——各自 `Capabilities().SupportsRecurring=false`;crypto 到期靠"提醒再买"伪续订(不在本计划)。 +- 计划变更 / 升降级 / 按比例折算(proration)/ 宽限期策略引擎 / 自动回收权益。 + +**Architecture:** 订阅层与单笔层解耦、但复用同一入账管线(设计 §5.1「单笔层与订阅层解耦」)。渠道差异仍封死在 `internal/provider/stripe`,`internal/gateway`/`internal/provider` 核心保持渠道中性。关键机制:**`provider.PaidEvent` 加一个可选判别字段 `Kind`(默认空=一次性/首期支付,向后兼容 P3 三渠道)**,Stripe `VerifyCallback` 把 `checkout.session.completed`(含 subscription 模式)/`invoice.paid`/`invoice.payment_failed`/`customer.subscription.deleted`/`charge.dispute.created` 统一归一化成带 `Kind` 的 `PaidEvent`;`gateway.HandleCallback` 由「只调 Settle」升级为**按 `Kind` 分派**的 dispatcher(payment→原 `Settle` 一字不改;其余 Kind → 订阅/拒付处理器)。续费**每期铸一张 renewal `OrderV2`**(`out_trade_no` 由 invoice id 确定性派生),复用 `MarkAttemptPaid` 幂等 + `attempt` 唯一索引 `(channel,provider_ref)` 去重,金额权威取 `invoice.total`(Stripe 报的)。订阅↔渠道靠 `Subscription.ProviderSubRef=stripe sub id` 映射(invoice/deleted webhook 里 `inv.Subscription.ID`/`sub.ID` 反查),无需 metadata。 + +**Tech Stack:** Go 1.26.1 · `github.com/wangjia/pay` · Gin · GORM v1.31 · glebarez/sqlite(测试内存库)· `github.com/stripe/stripe-go/v79 v79.12.0`(已 pin,复用 P3;新增用到 `CheckoutSessionModeSubscription`、`Subscription`/`Invoice`/`Dispute`/`PaymentIntent` 类型与 `Subscriptions`/`Invoices`/`PaymentIntents` client)。测试全程 `httptest` 假 Stripe backend(同 P3 惯例,注入指向 `httptest.Server` 的 `*client.API`),**不打真网、免 docker、无真实密钥**。 + +## Global Constraints(继承 P1/P2/P3) + +- **复用不重造**:金额 `AmountMinor int64 + Currency` 码(`internal/money`,USD=2 位);模型 `model.OrderV2`/`model.Attempt` + `V2` 后缀状态机;数据访问 `store.OrderStore`;账户 `accounts.Registry`;管线 `gateway.Gateway`(`CreateOrder`/`Settle`/`HandleCallback`);Provider 抽象 `provider.Provider`/`Session`/`PaidEvent`/`CreateRequest`/`CallbackInput`/`Capabilities`/`Registry`。webhook outbox `model.WebhookDelivery` 唯一键 `(out_trade_no,event_type)` + `store.WebhookStore.EnqueueDelivery`(ON CONFLICT DO NOTHING 幂等入队)。 +- **金额一律 int64 最小单位 + 币种码,禁 float**。续费金额取 `invoice.total`(渠道权威),不客户端传、不本地重算。 +- **Provider 中性**:`internal/gateway` 绝不 import 任何 adapter;`internal/provider` 核心不 import adapter(adapter 反向依赖核心)。新增 `provider.SubscriptionProvider` 可选接口住核心包,stripe 实现之。 +- **凭证 env only**:复用 P3 stripe 装配(`accounts.Credential(id,"SECRET_KEY"/"WEBHOOK_SECRET")`),不落库明文、不入 git。测试用假 backend + 假 webhook secret。 +- **入账/幂等/金额核对不变**:一次性 & 首期支付仍走 P2 `gateway.Settle`(先幂等入队 webhook、再 `MarkAttemptPaid` 条件 UPDATE 翻转)。订阅处理器**复用同款幂等纪律**(条件 UPDATE + 唯一索引 + 幂等入队),不引入新的并发原语。 +- **GORM AutoMigrate 惯例**:新表(`Subscription`/`Chargeback`)、`PaidEvent` 无 DB 副作用;`db.AutoMigrate` 只加不删,对存量库安全;模型内嵌 `model.Base`。同步更新 `model.OpenTestDB`(测试 harness)与 `main.go` 的 AutoMigrate 列表。 +- **拒付不自动回收权益**(设计 §6):pay 只记 chargeback + 转发事件 + 打订单标记;缩/降/冻结 entitlement 归业务方(收 `chargeback.received` 自行处理)。 +- 每步 `go build ./...` 通过;测试 `go test ./...`(全 `:memory:`/临时 sqlite + `httptest`)。每 Task 严格 bite-sized TDD;禁占位。 + +--- + +### Task 1: Subscription 模型 + 三态状态机 + SubscriptionStore + +**Files:** +- Modify: `internal/model/v2.go`(加 `SubStatus` 枚举 + `Subscription` struct) +- Modify: `internal/model/testdb.go`(`AutoMigrate` 追加 `&Subscription{}`) +- Create: `internal/store/subscription.go`(`SubscriptionStore`) +- Create: `internal/store/subscription_test.go` + +**Interfaces:** +```go +// model +type SubStatus string +const ( + SubActive SubStatus = "active" // 已激活,正常续费 + SubPastDue SubStatus = "past_due" // 某期扣款失败,待恢复(invoice.payment_failed) + SubCanceled SubStatus = "canceled" // 已取消(主动/网关删除),终态 +) +type Subscription struct { ... } // 见 Step 3 + +// store +func (s *SubscriptionStore) Create(sub *model.Subscription) (created bool, err error) // 幂等 by (channel,provider_sub_ref) +func (s *SubscriptionStore) GetBySubID(subID string) (*model.Subscription, error) // ErrSubNotFound +func (s *SubscriptionStore) GetByProviderRef(channel, ref string) (*model.Subscription, error) +func (s *SubscriptionStore) Activate(subID string, periodEnd *time.Time) (bool, error) // →active(past_due 恢复也走它) +func (s *SubscriptionStore) MarkPastDue(channel, providerRef string) (bool, error) // active→past_due +func (s *SubscriptionStore) MarkCanceled(subID string) (bool, error) // *→canceled(终态,幂等) +``` +状态机守卫(条件 UPDATE + RowsAffected,同 P1 `MarkAttemptPaid` 惯例):`canceled` 是终态,`Activate`/`MarkPastDue` 都带 `status <> 'canceled'` 守卫,避免取消后被续费/失败事件复活。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/subscription_test.go`: +```go +package store_test + +import ( + "testing" + "time" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/store" +) + +func newSubStore(t *testing.T) *store.SubscriptionStore { + return store.NewSubscriptionStore(model.OpenTestDB(t)) +} + +func TestSubscriptionCreateIdempotent(t *testing.T) { + s := newSubStore(t) + sub := &model.Subscription{SubID: "SUB-1", Channel: "stripe", ProviderSubRef: "sub_x", + OutTradeNo: "PAY-1", BizSystem: "pangolin", BizRef: "u-1", BizCode: "pro_month", + AmountMinor: 2999, Currency: "USD", Status: model.SubActive} + created, err := s.Create(sub) + if err != nil || !created { + t.Fatalf("first create: created=%v err=%v", created, err) + } + // 同 provider_sub_ref 再建 → 幂等 no-op(Stripe 重投 checkout.completed) + again, err := s.Create(&model.Subscription{SubID: "SUB-2", Channel: "stripe", ProviderSubRef: "sub_x", + OutTradeNo: "PAY-1", AmountMinor: 2999, Currency: "USD", Status: model.SubActive}) + if err != nil || again { + t.Fatalf("dup create: again=%v err=%v", again, err) + } +} + +func TestSubscriptionStateMachine(t *testing.T) { + s := newSubStore(t) + end := time.Now().Add(30 * 24 * time.Hour) + if _, err := s.Create(&model.Subscription{SubID: "SUB-9", Channel: "stripe", ProviderSubRef: "sub_9", + OutTradeNo: "PAY-9", AmountMinor: 2999, Currency: "USD", Status: model.SubActive}); err != nil { + t.Fatal(err) + } + // active → past_due + ok, _ := s.MarkPastDue("stripe", "sub_9") + if !ok { + t.Fatal("MarkPastDue should flip active→past_due") + } + // past_due → active(续费恢复) + ok, _ = s.Activate("SUB-9", &end) + if !ok { + t.Fatal("Activate should recover past_due→active") + } + // → canceled(终态) + ok, _ = s.MarkCanceled("SUB-9") + if !ok { + t.Fatal("MarkCanceled should flip →canceled") + } + // canceled 后不可复活 + if ok, _ := s.Activate("SUB-9", &end); ok { + t.Fatal("canceled sub must not be re-activated") + } + if ok, _ := s.MarkPastDue("stripe", "sub_9"); ok { + t.Fatal("canceled sub must not go past_due") + } + got, err := s.GetBySubID("SUB-9") + if err != nil || got.Status != model.SubCanceled { + t.Fatalf("final status = %v err=%v", got.Status, err) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ -run Subscription -v` +Expected: 编译失败(无 `Subscription` 模型 / `SubscriptionStore`)。 + +- [ ] **Step 3: 实现模型** + +`internal/model/v2.go` 追加: +```go +// ---- 订阅(recurring)---- + +type SubStatus string + +const ( + SubActive SubStatus = "active" + SubPastDue SubStatus = "past_due" + SubCanceled SubStatus = "canceled" +) + +// Subscription 是"同一 entitlement 的跨期账本"(设计 §5.1)。单笔层(OrderV2/Attempt) +// 与订阅层解耦:每期扣款仍落一张 renewal OrderV2,Subscription 只维护状态机 + 续费锚点。 +type Subscription struct { + Base + SubID string `gorm:"uniqueIndex;size:64;not null"` // pay 生成的逻辑订阅号 + OutTradeNo string `gorm:"index;size:64;not null"` // 首购 order(诞生订阅那笔) + MerchantID uint64 `gorm:"index"` + BizSystem string `gorm:"index;size:32"` + BizRef string `gorm:"size:128"` + BizCode string `gorm:"index;size:64"` // 套餐码副本,续费事件带回 + Channel string `gorm:"index;size:32;not null;uniqueIndex:uq_sub_provider"` + ProviderSubRef string `gorm:"size:128;not null;uniqueIndex:uq_sub_provider"` // 渠道订阅号(stripe sub id) + RecurringKind string `gorm:"size:24"` // gateway_scheduled/token_offsession/... + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + Status SubStatus `gorm:"index;size:16;not null"` + CurrentPeriodEnd *time.Time + CanceledAt *time.Time +} +``` +`internal/model/testdb.go` 的 `AutoMigrate(...)` 追加 `&Subscription{}`。 + +- [ ] **Step 4: 实现 store** + +`internal/store/subscription.go`: +```go +package store + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/wangjia/pay/internal/model" +) + +var ErrSubNotFound = errors.New("store: subscription not found") + +type SubscriptionStore struct{ db *gorm.DB } + +func NewSubscriptionStore(db *gorm.DB) *SubscriptionStore { return &SubscriptionStore{db: db} } + +// Create 幂等插入:重复 (channel,provider_sub_ref) → no-op(created=false)。 +// Stripe 会重投 checkout.session.completed;诞生订阅必须幂等。 +func (s *SubscriptionStore) Create(sub *model.Subscription) (bool, error) { + res := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "channel"}, {Name: "provider_sub_ref"}}, + DoNothing: true, + }).Create(sub) + if res.Error != nil { + return false, fmt.Errorf("store.SubscriptionStore.Create: %w", res.Error) + } + return res.RowsAffected > 0, nil +} + +func (s *SubscriptionStore) GetBySubID(subID string) (*model.Subscription, error) { + var sub model.Subscription + if err := s.db.Where("sub_id = ?", subID).First(&sub).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSubNotFound + } + return nil, fmt.Errorf("store.GetBySubID: %w", err) + } + return &sub, nil +} + +func (s *SubscriptionStore) GetByProviderRef(channel, ref string) (*model.Subscription, error) { + var sub model.Subscription + if err := s.db.Where("channel = ? AND provider_sub_ref = ?", channel, ref).First(&sub).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSubNotFound + } + return nil, fmt.Errorf("store.GetByProviderRef: %w", err) + } + return &sub, nil +} + +// Activate 置 active + 刷新续费锚点(past_due 续费成功恢复也走它);canceled 终态不复活。 +func (s *SubscriptionStore) Activate(subID string, periodEnd *time.Time) (bool, error) { + upd := map[string]any{"status": model.SubActive} + if periodEnd != nil { + upd["current_period_end"] = periodEnd + } + res := s.db.Model(&model.Subscription{}). + Where("sub_id = ? AND status <> ?", subID, model.SubCanceled).Updates(upd) + if res.Error != nil { + return false, fmt.Errorf("store.Activate: %w", res.Error) + } + return res.RowsAffected > 0, nil +} + +func (s *SubscriptionStore) MarkPastDue(channel, providerRef string) (bool, error) { + res := s.db.Model(&model.Subscription{}). + Where("channel = ? AND provider_sub_ref = ? AND status = ?", channel, providerRef, model.SubActive). + Update("status", model.SubPastDue) + if res.Error != nil { + return false, fmt.Errorf("store.MarkPastDue: %w", res.Error) + } + return res.RowsAffected > 0, nil +} + +func (s *SubscriptionStore) MarkCanceled(subID string) (bool, error) { + res := s.db.Model(&model.Subscription{}). + Where("sub_id = ? AND status <> ?", subID, model.SubCanceled). + Updates(map[string]any{"status": model.SubCanceled, "canceled_at": time.Now()}) + if res.Error != nil { + return false, fmt.Errorf("store.MarkCanceled: %w", res.Error) + } + return res.RowsAffected > 0, nil +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ -run Subscription -v` +Expected: PASS。 + +- [ ] **Step 6: commit** — `feat(pay-v2): P8 Task1 Subscription 模型 + 三态状态机 + store` + +--- + +### Task 2: PaidEvent.Kind 判别字段 + SubscriptionProvider 接口 + Stripe 订阅 Checkout 创建/取消 + +**Files:** +- Modify: `internal/provider/provider.go`(`EventKind` + `PaidEvent` 加可选字段 + `SubscriptionProvider` 接口 + `RecurringKindGatewayScheduled` 常量) +- Modify: `internal/provider/stripe/stripe.go`(`Capabilities` 开 recurring + `CreateSubscriptionCheckout` + `CancelSubscription`) +- Modify: `internal/provider/stripe/stripe_test.go`(假 backend 加 subscription-mode session + cancel 路由) + +**Interfaces:** +```go +// provider 核心包 +type EventKind string +const ( + EventPayment EventKind = "" // 默认:一次性/首期支付(P3 三渠道全落此,向后兼容) + EventSubscriptionRenewal EventKind = "subscription_renewal" + EventSubscriptionPastDue EventKind = "subscription_past_due" + EventSubscriptionCanceled EventKind = "subscription_canceled" + EventChargeback EventKind = "chargeback" +) +const RecurringKindGatewayScheduled = "gateway_scheduled" + +// PaidEvent 追加(全部可选,零值=旧行为): +// Kind EventKind +// SubscriptionRef string // 渠道订阅号(checkout.completed 诞生 / invoice / deleted 反查) +// InvoiceRef string // 续费期次唯一号(renewal attempt 的 provider_ref) +// DisputeRef string // 拒付号 +// ProviderPaymentRef string // 拒付关联的 PaymentIntent id +// OutTradeNo string // 拒付解析出的原单号(可空) + +type SubscriptionProvider interface { + Provider + CreateSubscriptionCheckout(ctx context.Context, req CreateRequest) (*Session, error) + CancelSubscription(ctx context.Context, providerSubRef string) error +} +``` + +- [ ] **Step 1: 写失败测试** + +`internal/provider/stripe/stripe_test.go` 追加(复用现有 `fakeStripeAPI`/`newStripe`/`signStripe`;先扩 `fakeStripeAPI` 的 switch,见 Step 4): +```go +func TestCreateSubscriptionCheckout(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + sess, err := p.CreateSubscriptionCheckout(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-S1", Subject: "Pro Monthly", AmountMinor: 2999, Currency: "USD", + ReturnURL: "https://x/return", Metadata: map[string]string{"pay_sub_id": "SUB-1"}, + }) + if err != nil { + t.Fatalf("create sub checkout: %v", err) + } + if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "cs_sub_123" { + t.Fatalf("session = %+v", sess) + } +} + +func TestCancelSubscription(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + if err := p.CancelSubscription(context.Background(), "sub_cancel_ok"); err != nil { + t.Fatalf("cancel: %v", err) + } +} + +func TestCapabilitiesRecurring(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + c := newStripe(t, ts).Capabilities() + if !c.SupportsRecurring || c.RecurringKind != provider.RecurringKindGatewayScheduled { + t.Fatalf("caps = %+v", c) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/stripe/ -run 'Subscription|Recurring' -v` +Expected: 编译失败(无 `CreateSubscriptionCheckout`/`CancelSubscription`/`SupportsRecurring` 等)。 + +- [ ] **Step 3: 实现 provider 核心扩展** + +`internal/provider/provider.go`:①`PaidEvent` struct 追加上述可选字段;②加 `EventKind` 枚举 + `RecurringKindGatewayScheduled` 常量;③加 `SubscriptionProvider` 接口。**不改** `Provider` 现有方法签名(向后兼容)。 + +- [ ] **Step 4: 实现 stripe 订阅创建/取消 + 扩假 backend** + +`internal/provider/stripe/stripe.go`:`Capabilities` 里补 `SupportsRecurring: true, RecurringKind: provider.RecurringKindGatewayScheduled`,再加: +```go +// CreateSubscriptionCheckout 建 Stripe 订阅(Checkout mode=subscription):返回 redirect 收银台。 +// 订阅号(sub_...)在用户完成支付后才生成 → 经 checkout.session.completed webhook 诞生 pay 订阅。 +// 续费由 Stripe 网关驱动(invoice.paid),pay 不主动 Charge(区别于 token_offsession)。 +func (p *Provider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + if req.Currency != supportedCurrency { + return nil, fmt.Errorf("stripe: 仅支持 %s, got %s", supportedCurrency, req.Currency) + } + params := &gostripe.CheckoutSessionParams{ + Mode: gostripe.String(string(gostripe.CheckoutSessionModeSubscription)), + SuccessURL: gostripe.String(req.ReturnURL), + ClientReferenceID: gostripe.String(req.OutTradeNo), + LineItems: []*gostripe.CheckoutSessionLineItemParams{{ + Quantity: gostripe.Int64(1), + PriceData: &gostripe.CheckoutSessionLineItemPriceDataParams{ + Currency: gostripe.String(strings.ToLower(supportedCurrency)), + UnitAmount: gostripe.Int64(req.AmountMinor), + // 最小可行:固定月付。周期(month/year)后续由 product 定价档下发,此处留 month 默认。 + Recurring: &gostripe.CheckoutSessionLineItemPriceDataRecurringParams{ + Interval: gostripe.String("month"), + }, + ProductData: &gostripe.CheckoutSessionLineItemPriceDataProductDataParams{ + Name: gostripe.String(req.Subject), + }, + }, + }}, + // 订阅 metadata 带 out_trade_no,便于人工对账;续费/取消映射实际走 sub id 反查,不依赖它。 + SubscriptionData: &gostripe.CheckoutSessionSubscriptionDataParams{ + Metadata: map[string]string{"out_trade_no": req.OutTradeNo}, + }, + } + if v := req.Metadata["pay_sub_id"]; v != "" { + params.SubscriptionData.Metadata["pay_sub_id"] = v + } + sess, err := p.sc.CheckoutSessions.New(params) + if err != nil { + return nil, fmt.Errorf("stripe: 创建订阅 Checkout 失败: %w", err) + } + return &provider.Session{ + ProviderRef: sess.ID, + RenderType: provider.RenderRedirect, + Payload: map[string]any{"url": sess.URL}, + }, nil +} + +// CancelSubscription 立即取消 Stripe 订阅(不等本期末)。Stripe 随后发 customer.subscription.deleted, +// 入站处理器幂等标 canceled,与本地主动标一致收敛。 +func (p *Provider) CancelSubscription(_ context.Context, providerSubRef string) error { + if _, err := p.sc.Subscriptions.Cancel(providerSubRef, nil); err != nil { + return fmt.Errorf("stripe: 取消订阅失败: %w", err) + } + return nil +} +``` +扩 `internal/provider/stripe/stripe_test.go` 的 `fakeStripeAPI` switch,新增: +```go +case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"): + // 复用现有分支即可 —— 但 subscription-mode 需回不同 id。用 form 值 mode 区分: + // 简化:检查 body 是否含 "subscription"(mode=subscription 会带该 form 值)。 + if b, _ := io.ReadAll(r.Body); strings.Contains(string(b), "subscription") { + fmt.Fprint(w, `{"id":"cs_sub_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_sub_123","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`) + } else { + fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`) + } +case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_ok"): + fmt.Fprint(w, `{"id":"sub_cancel_ok","object":"subscription","status":"canceled"}`) +``` +> 注:stripe-go 的 `Subscriptions.Cancel` 走 `DELETE /v1/subscriptions/{id}`。现有一次性分支要与新分支合并成一个 `case`(避免重复 `case` 冲突),`io`/`strings` 已 import。若合并麻烦,把一次性分支的 id 改判据也行——保持 `cs_test_123` 供 P3 既有用例不破。跑 `go test ./internal/provider/stripe/` 全绿即可。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/stripe/ -v` +Expected: 新用例 + P3 既有用例全 PASS(向后兼容)。 + +- [ ] **Step 6: commit** — `feat(pay-v2): P8 Task2 PaidEvent.Kind + SubscriptionProvider + Stripe 订阅 Checkout/取消` + +--- + +### Task 3: gateway 订阅创建 + 首期激活(诞生订阅 + subscription.created)+ 创建端点 + +**Files:** +- Create: `internal/gateway/subscription.go`(`Gateway.CreateSubscription` + `onSubscriptionActivated`) +- Modify: `internal/gateway/gateway.go`(`Gateway` 注入 `subs *store.SubscriptionStore` + event 常量;`New` 追加参数) +- Modify: `internal/gateway/settle.go`(`HandleCallback` 升级为按 `Kind` 分派) +- Modify: `internal/handler/gateway.go`(`CreateSubscription` handler) +- Modify: `main.go`(路由 `POST /api/v2/subscriptions` + 装配注入 `SubscriptionStore`) +- Create: `internal/gateway/subscription_test.go` + +**Interfaces:** +```go +type CreateSubscriptionInput struct { + SKU, Method, BizSystem, BizRef, ReturnURL string +} +type SubscriptionResult struct { + SubID string `json:"sub_id"` + OrderNo string `json:"order_no"` + Session SessionView `json:"session"` +} +func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionInput) (*SubscriptionResult, error) +``` +webhook event 常量集中在 `internal/gateway`(Task 7 收敛业务方声明): +```go +const ( + EvtPaymentSucceeded = "payment.succeeded" + EvtSubscriptionCreated = "subscription.created" + EvtSubscriptionRenewed = "subscription.renewed" + EvtSubscriptionPastDue = "subscription.past_due" + EvtSubscriptionCanceled = "subscription.canceled" + EvtChargebackReceived = "chargeback.received" +) +``` + +**决策记录:** +- **端点独立**:订阅走新端点 `POST /api/v2/subscriptions`,不复用 `/api/v2/orders`(一次性)。订阅 create 语义/返回(带 `sub_id`)与一次性不同,合并会脏化 `CreateOrder`。 +- **首期激活复用一次性 Settle**:订阅 Checkout 完成后 Stripe 发 `checkout.session.completed`(`payment_status=paid`,`amount_total`=首期金额),这条**照走 P2 `Settle`** 把首购 order 翻 paid + 入队 `payment.succeeded`(首期即一次成功收款,业务方按 `payment.succeeded` 开首期权益,零特判)。**额外**:该 event 带 `SubscriptionRef` → `onSubscriptionActivated` 幂等诞生 `Subscription`(status=active)+ 入队 `subscription.created`(带 `sub_id`,业务方登记订阅关系)。故 `HandleCallback` 在 `Settle` 后,若 `ev.SubscriptionRef!=""` 再调 `onSubscriptionActivated`(幂等,与 Settle 结果无关)。 + +- [ ] **Step 1: 写失败测试** + +`internal/gateway/subscription_test.go`(参照 P2/P3 `gateway_test.go` 的 `newGateway` helper——本 Task 需其升级版 `newSubGateway` 额外返回 `SubscriptionStore` + webhook spy;若既有 helper 不便复用,在本文件内自建最小装配,注册一个实现 `SubscriptionProvider` 的 fake): +```go +// fakeSubProvider: 实现 provider.SubscriptionProvider,创建订阅 Checkout 返回固定 session; +// VerifyCallback 由测试直接构造 PaidEvent 走 Settle/HandleCallback,不经它。 +``` +断言两条: +1. `CreateSubscription` → 落一张 pending `OrderV2` + `Attempt`(provider_ref=会话号),返回 `sub_id`/`order_no`/redirect session。 +2. 喂一条 `PaidEvent{Kind:EventPayment, ProviderRef:<会话号>, Status:Succeeded, PaidAmountMinor:2999, PaidCurrency:"USD", SubscriptionRef:"sub_new"}` 给 `HandleCallback`(经 fake VerifyCallback 回放该 event)→ 断言:①order 翻 paid;②`subscriptions` 表诞生一行 status=active、provider_sub_ref=sub_new;③webhook spy 收到 **2** 条:`payment.succeeded` + `subscription.created`;④重复喂同一 event → 订阅不重复诞生、webhook 不重复(幂等)。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -run Subscription -v` → 编译失败。 + +- [ ] **Step 3: 实现 gateway 订阅创建 + 激活** + +`internal/gateway/subscription.go`: +```go +package gateway + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" +) + +// CreateSubscription 解析套餐权威金额 → 选 stripe 账户(须实现 SubscriptionProvider 且 SupportsRecurring) +// → 落 pending 首购 OrderV2 + Attempt → 返回订阅 Checkout redirect。订阅在首期支付回调时诞生。 +func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionInput) (*SubscriptionResult, error) { + prov, err := g.providers.Get(in.Method) + if err != nil { + return nil, err + } + caps := prov.Capabilities() + subProv, ok := prov.(provider.SubscriptionProvider) + if !ok || !caps.SupportsRecurring { + return nil, provider.ErrNotSupported + } + if len(caps.SettleCurrencies) == 0 { + return nil, ErrNoSettleCurrency + } + currency := caps.SettleCurrencies[0] + amount, subject, bizCode, err := g.products.Resolve(in.SKU, currency) + if err != nil { + return nil, err // ErrProductNotFound(含"该币种无价") + } + outTradeNo := util.NewOutTradeNo("pay") // 复用 P1 单号生成器(真实 helper) + acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{OutTradeNo: outTradeNo, AmountMinor: amount}) + if err != nil { + if errors.Is(err, accounts.ErrNoAccount) { + return nil, ErrNoAccount + } + return nil, err + } + // SubID 确定性派生自 out_trade_no(与 onSubscriptionActivated 一致,消除双号)。 + subID := "SUB-" + outTradeNo + sess, err := subProv.CreateSubscriptionCheckout(ctx, provider.CreateRequest{ + OutTradeNo: outTradeNo, Subject: subject, AmountMinor: amount, Currency: currency, + Account: acct, ReturnURL: in.ReturnURL, Metadata: map[string]string{"pay_sub_id": subID}, + }) + if err != nil { + return nil, fmt.Errorf("gateway.CreateSubscription: %w", err) + } + order := &model.OrderV2{ + OutTradeNo: outTradeNo, BizSystem: in.BizSystem, BizRef: in.BizRef, BizCode: bizCode, + Subject: subject, AmountMinor: amount, Currency: currency, Status: model.OrderPendingV2, + } + if err := g.orders.CreateOrder(order); err != nil { + return nil, err + } + att := &model.Attempt{ + OutTradeNo: outTradeNo, Channel: in.Method, AccountID: acct.AccountID, Provider: in.Method, + ProviderRef: sess.ProviderRef, RenderType: string(sess.RenderType), + AmountMinor: amount, Currency: currency, Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt, + } + if err := g.orders.CreateAttempt(att); err != nil { + return nil, err + } + return &SubscriptionResult{ + SubID: subID, OrderNo: outTradeNo, + Session: SessionView{RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt}, + }, nil +} + +// onSubscriptionActivated 幂等诞生订阅 + 入队 subscription.created。首期支付回调触发。 +// created 事件走首购 order 的 out_trade_no + event_type=subscription.created(唯一键天然不撞 payment.succeeded)。 +func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.PaidEvent) error { + att, err := g.orders.AttemptByProviderRef(ev.ProviderRef) + if err != nil { + return nil // 首期会话未落库(不该发生);交由 Settle 侧日志,订阅侧静默 + } + o, err := g.orders.GetOrder(att.OutTradeNo) + if err != nil { + return err + } + subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等 + created, err := g.subs.Create(&model.Subscription{ + SubID: subID, OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, BizRef: o.BizRef, BizCode: o.BizCode, + Channel: att.Channel, ProviderSubRef: ev.SubscriptionRef, RecurringKind: provider.RecurringKindGatewayScheduled, + AmountMinor: o.AmountMinor, Currency: o.Currency, Status: model.SubActive, + }) + if err != nil { + return err + } + if !created || o.BizSystem == "" { + return nil // 已诞生过(重投)/ 独立收款无业务方回调 + } + return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtSubscriptionCreated, map[string]any{ + "event_type": EvtSubscriptionCreated, "out_trade_no": o.OutTradeNo, "sub_id": subID, + "biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode, + "amount_minor": o.AmountMinor, "currency": o.Currency, "channel": att.Channel, + "created_at": time.Now().Format(time.RFC3339), + }) +} +``` +> **决策(SubID 归一)**:`CreateSubscription` 时无法预知 Stripe sub id,故 `SubID` **两处都从 `out_trade_no` 确定性派生**(`SUB-`)——创建时返回给客户端,`onSubscriptionActivated` 重算同值,保证 webhook 重投幂等、无双号。业务方以回调里的 `sub_id` 为准。 + +`internal/gateway/gateway.go`:`Gateway` struct 加 `subs *store.SubscriptionStore`;`New(...)` 尾部加 `subs` 参数;定义上方 event 常量;把 `CreateOrder` 里选账户那段抽成 `pickAccount(method)` 私有方法供两处复用(若 P5 已抽出则直接用)。 + +- [ ] **Step 4: HandleCallback 升级为 Kind 分派** + +`internal/gateway/settle.go` 的 `HandleCallback` 改为: +```go +func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider.CallbackInput) (SettleResult, error) { + prov, err := g.providers.Get(method) + if err != nil { + return SettleNotFound, err + } + ev, err := prov.VerifyCallback(ctx, in) + if err != nil { + return SettleNotFound, err + } + switch ev.Kind { + case provider.EventSubscriptionRenewal: + return g.settleRenewal(ctx, ev) // Task 4 + case provider.EventSubscriptionPastDue: + return g.markSubscriptionPastDue(ctx, method, ev) // Task 5 + case provider.EventSubscriptionCanceled: + return g.settleSubscriptionCanceled(ctx, ev) // Task 5 + case provider.EventChargeback: + return g.recordChargeback(ctx, method, ev) // Task 6 + default: // EventPayment:一次性 / 订阅首期 + res, serr := g.Settle(ctx, ev) + if serr == nil && ev.SubscriptionRef != "" { + if aerr := g.onSubscriptionActivated(ctx, ev); aerr != nil { + return SettleFailed, aerr // 诞生订阅失败可重试(Stripe 重投) + } + } + return res, serr + } +} +``` +> Task 3 只需 `default` 分支可用 + `onSubscriptionActivated`;其余 case 的处理器在 Task 4/5/6 补齐。**为让本 Task 独立编译通过**,先给 `settleRenewal`/`markSubscriptionPastDue`/`settleSubscriptionCanceled`/`recordChargeback` 写 stub(`return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)`),后续 Task 各自替换 + 补测试。 + +- [ ] **Step 5: handler + 路由 + 装配** + +`internal/handler/gateway.go` 加 `CreateSubscription`(仿 `CreateOrder`:读 body、`biz_system` 非空验 HMAC、调 `g.CreateSubscription`、`writeCreateErr` 复用;`ErrNotSupported` → 400 `method_not_recurring`「该支付方式不支持订阅」)。`main.go`:装配处 `gateway.New(...)` 传入 `store.NewSubscriptionStore(db)`;路由注册 `POST /api/v2/subscriptions`;`AutoMigrate` 追加 `&model.Subscription{}`。 + +- [ ] **Step 6: 跑测试确认通过 + 全量回归** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./... ` +Expected: 新用例 PASS,P1/P2/P3/P5 全绿。 + +- [ ] **Step 7: commit** — `feat(pay-v2): P8 Task3 订阅创建 + 首期激活(subscription.created)+ 端点` + +--- + +### Task 4: 续费入账(invoice.paid → renewal order + subscription.renewed) + +**Files:** +- Modify: `internal/provider/stripe/stripe.go`(`VerifyCallback` 增 `invoice.paid` 分类) +- Modify: `internal/store/order.go`(`CreateRenewalPaid` 幂等建 renewal order+attempt 并直接置 paid) +- Modify: `internal/gateway/subscription.go`(`settleRenewal` 替换 stub) +- Modify: `internal/provider/stripe/stripe_test.go` + `internal/gateway/subscription_test.go` + +**决策记录:** +- **每期一张 renewal `OrderV2`**:`out_trade_no` 由 invoice id 确定性派生(`sub.OutTradeNo + "-r-" + invoice.ID`)。这让「一订阅多次续费」自然落进 outbox 唯一键 `(out_trade_no,event_type)`——每期 `out_trade_no` 不同 → 每期一条 `subscription.renewed` 不撞键。**与 P4「每笔退款独立单号」同构**。 +- **金额权威 = `invoice.total`**(Stripe 报的本期实扣),不重算、不取首购 order 金额(可能改价)。renewal order/attempt 建即置 paid(续费不经收银台,无 pending 中间态)。 +- **首期 invoice 跳过**:`billing_reason=subscription_create` 的 invoice 与 `checkout.session.completed` 是同一笔首期钱,由后者入账;续费只认 `subscription_cycle`,避免首期双记账。 +- **幂等**:renewal attempt 的 `provider_ref=invoice.ID`,唯一索引 `(channel,provider_ref)` 挡住 `invoice.paid` 重投;renewal order 唯一 `out_trade_no` 双保险。`CreateRenewalPaid` 用 ON CONFLICT DO NOTHING,`created=false` 时不重复入队 `subscription.renewed`(outbox 本也幂等,双保险)。 + +- [ ] **Step 1: 写失败测试** + +`internal/provider/stripe/stripe_test.go`:`TestVerifyInvoicePaidRenewal` —— 构造 `type=invoice.paid` + `billing_reason=subscription_cycle` 的签名 webhook,断言 `ev.Kind==EventSubscriptionRenewal`、`ev.InvoiceRef=="in_123"`、`ev.SubscriptionRef=="sub_new"`、`ev.PaidAmountMinor==2999`、`ev.Status==Succeeded`;再构造 `billing_reason=subscription_create` 断言 `Kind==EventPayment`(被跳过入账)。payload 示例: +```go +payload := `{"id":"evt_r","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_123","object":"invoice","billing_reason":"subscription_cycle","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}` +``` +`internal/gateway/subscription_test.go`:`TestSettleRenewal` —— 先经 Task3 路径诞生一个订阅(provider_sub_ref=sub_new),再喂 `PaidEvent{Kind:EventSubscriptionRenewal, SubscriptionRef:"sub_new", InvoiceRef:"in_123", PaidAmountMinor:2999, PaidCurrency:"USD", Status:Succeeded}` 给 `HandleCallback`。断言:①新增一张 renewal `OrderV2`(status=paid,out_trade_no 含 `-r-`);②webhook spy 收到 `subscription.renewed`(含 `sub_id`/`out_trade_no`/`amount_minor`);③重复喂 → renewal order 不重复、webhook 不重复;④订阅 `current_period_end` 刷新、status=active(past_due 恢复)。 + +- [ ] **Step 2 跑失败** → `go test ./internal/provider/stripe/ ./internal/gateway/ -run 'Renewal' -v` + +- [ ] **Step 3: stripe `invoice.paid` 分类** + +`VerifyCallback` 的 switch 增 case: +```go +case "invoice.paid": + var inv gostripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err) + } + if inv.BillingReason != gostripe.InvoiceBillingReasonSubscriptionCycle { + // 首期(subscription_create)由 checkout.session.completed 入账;其余非续费忽略。 + return &provider.PaidEvent{Kind: provider.EventPayment, Status: provider.PaidPending, Raw: string(in.Raw)}, nil + } + ev := &provider.PaidEvent{ + Kind: provider.EventSubscriptionRenewal, Status: provider.PaidSucceeded, + InvoiceRef: inv.ID, PaidAmountMinor: inv.Total, PaidCurrency: strings.ToUpper(string(inv.Currency)), + } + if inv.Subscription != nil { + ev.SubscriptionRef = inv.Subscription.ID + } + if event.Created > 0 { + t := unixToTime(event.Created) + ev.PaidAt = &t + } + return ev, nil +``` + +- [ ] **Step 4: store `CreateRenewalPaid`** + +`internal/store/order.go`: +```go +// CreateRenewalPaid 幂等建一张已付 renewal order + attempt(续费不经收银台,建即 paid)。 +// 重复(invoice 重投)→ created=false。renewal order/attempt 均带唯一约束,ON CONFLICT DO NOTHING。 +func (s *OrderStore) CreateRenewalPaid(order *model.OrderV2, att *model.Attempt) (bool, error) { + var created bool + err := s.db.Transaction(func(tx *gorm.DB) error { + ores := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "out_trade_no"}}, DoNothing: true, + }).Create(order) + if ores.Error != nil { + return ores.Error + } + if ores.RowsAffected == 0 { + return nil // 已建过 → 幂等 no-op + } + ares := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "channel"}, {Name: "provider_ref"}}, DoNothing: true, + }).Create(att) + if ares.Error != nil { + return ares.Error + } + created = ares.RowsAffected > 0 + return nil + }) + if err != nil { + return false, fmt.Errorf("store.CreateRenewalPaid: %w", err) + } + return created, nil +} +``` +> 需 `import "gorm.io/gorm/clause"`。order.Status 传 `OrderPaidV2`、att.Status 传 `AttemptPaid`、二者 `PaidAt` 传本期支付时间。 + +- [ ] **Step 5: gateway `settleRenewal`** + +`internal/gateway/subscription.go` 替换 stub: +```go +func (g *Gateway) settleRenewal(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) { + sub, err := g.subs.GetByProviderRef("stripe", ev.SubscriptionRef) // channel 固定 stripe;多渠道时由 ev 带 channel + if err != nil { + if errors.Is(err, store.ErrSubNotFound) { + log.Printf("[renewal] 未知订阅 provider_ref=%s(未诞生/已清理),忽略", ev.SubscriptionRef) + return SettleIgnored, nil + } + return SettleFailed, err + } + paidAt := time.Now() + if ev.PaidAt != nil { + paidAt = *ev.PaidAt + } + renewalNo := sub.OutTradeNo + "-r-" + ev.InvoiceRef + created, err := g.orders.CreateRenewalPaid( + &model.OrderV2{ + OutTradeNo: renewalNo, BizSystem: sub.BizSystem, BizRef: sub.BizRef, BizCode: sub.BizCode, + Subject: "续费", AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, + Status: model.OrderPaidV2, PaidAt: &paidAt, + }, + &model.Attempt{ + OutTradeNo: renewalNo, Channel: sub.Channel, Provider: sub.Channel, ProviderRef: ev.InvoiceRef, + AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, Status: model.AttemptPaid, PaidAt: &paidAt, + }) + if err != nil { + return SettleFailed, err + } + // 续费成功即恢复/维持 active,刷新续费锚点(period_end 最小可行取 paidAt+30d;精确值后续从 invoice.period_end 下发)。 + nextEnd := paidAt.Add(30 * 24 * time.Hour) + if _, err := g.subs.Activate(sub.SubID, &nextEnd); err != nil { + return SettleFailed, err + } + if !created || sub.BizSystem == "" { + return SettleDuplicate, nil // 重投 / 独立收款 + } + if err := g.webhook.Enqueue(renewalNo, sub.BizSystem, EvtSubscriptionRenewed, map[string]any{ + "event_type": EvtSubscriptionRenewed, "out_trade_no": renewalNo, "sub_id": sub.SubID, + "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode, + "amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "channel": sub.Channel, + "paid_at": paidAt.Format(time.RFC3339), + }); err != nil { + return SettleFailed, err + } + return SettleProcessed, nil +} +``` +> 需 import `errors`/`log`/`github.com/wangjia/pay/internal/store`。**顺序不变量**:renewal 走「建单即 paid + 入队」,与一次性「先入队后翻转」略异——续费无 pending 中间态、无晚到风险,renewal order 一诞生就是 paid,故门禁(Notifier 的 `orderPaid`)天然放行;`subscription.renewed` 入队失败返回 `SettleFailed` 让 Stripe 重投,重投时 `created=false` 不重复建单、outbox 幂等,自愈。 + +- [ ] **Step 6 跑通过 + 全量回归** → `go build ./... && go test ./...` + +- [ ] **Step 7: commit** — `feat(pay-v2): P8 Task4 续费入账 invoice.paid→renewal order + subscription.renewed` + +--- + +### Task 5: 取消(API 主动 + 入站 customer.subscription.deleted)+ past_due + +**Files:** +- Modify: `internal/provider/stripe/stripe.go`(`VerifyCallback` 增 `customer.subscription.deleted` + `invoice.payment_failed`) +- Modify: `internal/gateway/subscription.go`(`CancelSubscription` + `settleSubscriptionCanceled` + `markSubscriptionPastDue` 替换 stub) +- Modify: `internal/handler/gateway.go`(`CancelSubscription` + `GetSubscription` handler) +- Modify: `main.go`(路由 `POST /api/v2/subscriptions/:sub_id/cancel` + `GET /api/v2/subscriptions/:sub_id`) +- Modify: 对应 `_test.go` + +**决策记录:** +- **主动取消**:`POST /api/v2/subscriptions/:sub_id/cancel` → 查订阅 → 调 `subProv.CancelSubscription(providerSubRef)` → 本地 `MarkCanceled` + 入队 `subscription.canceled`。Stripe 随后发 `customer.subscription.deleted`,入站处理器再 `MarkCanceled`(幂等 no-op)+ 入队(outbox 幂等)→ 两路收敛同一终态。 +- **入账不回收**:取消只翻订阅状态 + 发事件,**不退款、不改已付 renewal order**;权益到期自然失效(entitlement 归业务)。 +- **past_due**:`invoice.payment_failed` → `MarkPastDue`(active→past_due)+ 入队 `subscription.past_due`(业务方可提醒用户换卡);下期 `invoice.paid` 成功 → `settleRenewal` 的 `Activate` 自动恢复 active。past_due 事件走**首购 order 的 out_trade_no**(该状态变更不产生新单),`event_type=subscription.past_due` 唯一——但同一订阅可能多次 past_due(多次失败),与 renewed 不同它不天然多单。**决策**:past_due/canceled 这类"订阅状态事件"用 `out_trade_no = sub.OutTradeNo + "-" + event_type + "-" + <稳定后缀>` 铸键避免重复失败被唯一键吞掉——past_due 后缀取失败 invoice id;canceled 只发一次,后缀取 sub_id。保证"该发的都发、重投不重复"。 + +- [ ] **Step 1: 写失败测试** —— 覆盖:①`CancelSubscription` API 路径(fake provider 记录 cancel 调用)→ 订阅 canceled + 入队 `subscription.canceled`;②入站 `customer.subscription.deleted` event → 同订阅幂等 canceled(不重复 webhook);③`invoice.payment_failed` event → 订阅 past_due + 入队 `subscription.past_due`;④past_due 后一条 `invoice.paid`(subscription_cycle)→ 恢复 active。stripe 层测 `customer.subscription.deleted`/`invoice.payment_failed` 的 `VerifyCallback` 归一化 Kind/SubscriptionRef。 + +- [ ] **Step 2 跑失败** + +- [ ] **Step 3: stripe 两个入站事件分类** +```go +case "customer.subscription.deleted": + var sub gostripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + return nil, fmt.Errorf("stripe: 解析 subscription 失败: %w", err) + } + return &provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: sub.ID, Raw: string(in.Raw)}, nil +case "invoice.payment_failed": + var inv gostripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err) + } + ev := &provider.PaidEvent{Kind: provider.EventSubscriptionPastDue, InvoiceRef: inv.ID, Raw: string(in.Raw)} + if inv.Subscription != nil { + ev.SubscriptionRef = inv.Subscription.ID + } + return ev, nil +``` + +- [ ] **Step 4: gateway 三处** +```go +func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error { + sub, err := g.subs.GetBySubID(subID) + if err != nil { + return err + } + if sub.Status == model.SubCanceled { + return nil // 幂等 + } + prov, err := g.providers.Get(sub.Channel) + if err != nil { + return err + } + if sp, ok := prov.(provider.SubscriptionProvider); ok { + if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil { + return err + } + } + return g.finalizeCanceled(sub) +} + +func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) { + sub, err := g.subs.GetByProviderRef("stripe", ev.SubscriptionRef) + if err != nil { + if errors.Is(err, store.ErrSubNotFound) { + return SettleIgnored, nil + } + return SettleFailed, err + } + if err := g.finalizeCanceled(sub); err != nil { + return SettleFailed, err + } + return SettleProcessed, nil +} + +func (g *Gateway) finalizeCanceled(sub *model.Subscription) error { + flipped, err := g.subs.MarkCanceled(sub.SubID) + if err != nil { + return err + } + if !flipped || sub.BizSystem == "" { + return nil // 已取消(重投)/ 独立收款 + } + return g.webhook.Enqueue(sub.OutTradeNo+"-canceled-"+sub.SubID, sub.BizSystem, EvtSubscriptionCanceled, map[string]any{ + "event_type": EvtSubscriptionCanceled, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID, + "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode, + "canceled_at": time.Now().Format(time.RFC3339), + }) +} + +func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { + sub, err := g.subs.GetByProviderRef("stripe", ev.SubscriptionRef) + if err != nil { + if errors.Is(err, store.ErrSubNotFound) { + return SettleIgnored, nil + } + return SettleFailed, err + } + flipped, err := g.subs.MarkPastDue(sub.Channel, sub.ProviderSubRef) + if err != nil { + return SettleFailed, err + } + if !flipped || sub.BizSystem == "" { + return SettleDuplicate, nil + } + if err := g.webhook.Enqueue(sub.OutTradeNo+"-pastdue-"+ev.InvoiceRef, sub.BizSystem, EvtSubscriptionPastDue, map[string]any{ + "event_type": EvtSubscriptionPastDue, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID, + "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode, + "failed_at": time.Now().Format(time.RFC3339), + }); err != nil { + return SettleFailed, err + } + return SettleProcessed, nil +} +``` +> **注意**:`canceled`/`past_due` 事件的 `out_trade_no` 用带后缀的合成键(见决策)入 outbox 唯一键,但 payload 里的 `out_trade_no` 仍给业务方**真实首购单号** `sub.OutTradeNo`(业务方按 `sub_id` 认订阅,不靠合成键)。Notifier 投递门禁 `orderPaid(合成键)` 会查无此单 → 返回 false 永不投递!**修正**:订阅状态类事件(created/renewed/past_due/canceled)其 outbox 行的门禁需放行。最简做法——`onSubscriptionActivated` 用**真实首购单号**(已 paid,门禁天然放行);renewed 用 renewal 单号(已 paid,放行);**past_due/canceled 无对应 paid 单** → 给 `Notifier.orderPaid` 增一条:`out_trade_no` 查不到 order 时,再查 `subscriptions` 是否存在该订阅的事件(或直接约定:合成键前缀 `*-canceled-*`/`*-pastdue-*` 的行免门禁)。**决策**:Task 7 给 Notifier 门禁加「订阅状态事件免 orderPaid 门禁」旁路(按 `event_type ∈ {subscription.past_due, subscription.canceled}` 放行),本 Task 先用真实 `sub.OutTradeNo` 作 outbox 键 + `event_type` 区分(canceled 每订阅一次不撞;past_due 若同订阅多次失败会被唯一键吞掉——最小可行接受"只报首次 past_due",Task 7 再定夺是否要合成键+门禁旁路)。**实现取真实 `sub.OutTradeNo` 作键**,删除上面合成后缀,保持门禁可放行。 + +- [ ] **Step 5: handler + 路由** —— `CancelSubscription`(`POST .../:sub_id/cancel`,`ErrSubNotFound`→404)、`GetSubscription`(`GET .../:sub_id` 返回状态/period_end)。`main.go` 注册两路由。 + +- [ ] **Step 6 跑通过 + 全量回归** → `go build ./... && go test ./...` + +- [ ] **Step 7: commit** — `feat(pay-v2): P8 Task5 订阅取消(API+入站)+ past_due 状态` + +--- + +### Task 6: 拒付 chargeback(charge.dispute.created → Chargeback + chargeback.received) + +**Files:** +- Modify: `internal/model/v2.go`(`Chargeback` struct)+ `internal/model/testdb.go` + `main.go`(AutoMigrate) +- Create: `internal/store/chargeback.go`(`ChargebackStore`)+ `_test.go` +- Modify: `internal/provider/stripe/stripe.go`(`Create` 一次性单给 PI 打 out_trade_no metadata + `VerifyCallback` 增 `charge.dispute.created` 分类,fetch PI 取 metadata) +- Modify: `internal/gateway/subscription.go`(或新 `chargeback.go`:`recordChargeback` 替换 stub) +- Modify: `_test.go` + +**决策记录(chargeback 范围):** +- **只 Stripe 有拒付**;alipay/微信本轮无拒付流(设计仅 Stripe/卡),crypto **永无 chargeback**(§6「钱到账不可逆」)——各 `Capabilities` 不声明、注释写清。 +- **不自动回收权益**(§6):`recordChargeback` 只:①落 `Chargeback`(幂等 by dispute id);②给原订单打标(新增 `OrderV2.Disputed bool` 列,标记但**不改状态机**);③入队 `chargeback.received` 给业务方自行冲正。 +- **订单映射**:dispute webhook 里 `payment_intent` 常只是 id 未展开 → stripe adapter 在 `VerifyCallback` 内 `PaymentIntents.Get(piID)` 取 `Metadata["out_trade_no"]`。为此**一次性 payment-mode Checkout 的 `Create` 补 `PaymentIntentData.Metadata{out_trade_no}`**(小改,加在本 Task),使 PI 携带原单号。**范围界定**:订阅首期/续费的 charge 由 invoice 生成、PI 不带 out_trade_no metadata(Stripe 不自动透传订阅 metadata 到 PI)→ 这类拒付 `out_trade_no` 解析为空,`recordChargeback` **仍落 Chargeback 记录 + log 告警,但不入队业务 webhook**(无法定位业务单)。订阅拒付的精确定位(PI→invoice→sub)留后续轮次,本轮不做——honest scope。 + +- [ ] **Step 1: 写失败测试** —— ①stripe `VerifyCallback` 对 `charge.dispute.created`(payload 的 `payment_intent` 为 `"pi_1"`,fake `GET /v1/payment_intents/pi_1` 回 `metadata.out_trade_no=PAY-1`)→ `ev.Kind==EventChargeback`、`ev.DisputeRef=="dp_1"`、`ev.OutTradeNo=="PAY-1"`、`ev.PaidAmountMinor`/`Currency`/`Reason` 正确;②gateway `recordChargeback`:喂该 event → `chargebacks` 表落一行(幂等,重投不重复)+ 原 order `Disputed=true` + webhook spy 收 `chargeback.received`;③`out_trade_no` 空的 dispute(PI metadata 无)→ 落 Chargeback 但**不入队** webhook。 + +- [ ] **Step 2 跑失败** + +- [ ] **Step 3: 模型 + store** +```go +// model +type Chargeback struct { + Base + DisputeRef string `gorm:"uniqueIndex;size:128;not null"` // 渠道拒付号(stripe dp_...) + OutTradeNo string `gorm:"index;size:64"` // 解析出的原单号(订阅拒付可能为空) + Channel string `gorm:"index;size:32;not null"` + ProviderPaymentRef string `gorm:"size:128"` // 关联 PaymentIntent id + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + Reason string `gorm:"size:64"` + Status string `gorm:"size:24"` // 渠道拒付状态快照(needs_response/...) +} +``` +`OrderV2` 加 `Disputed bool `gorm:"default:false"``(打标不改状态机)。`ChargebackStore.Create(cb) (created bool, err)`(ON CONFLICT `dispute_ref` DoNothing)。testdb/main AutoMigrate 追加 `&Chargeback{}`。 + +- [ ] **Step 4: stripe `Create` 打 PI metadata + dispute 分类** + +`Create`(一次性)params 补: +```go +params.PaymentIntentData = &gostripe.CheckoutSessionPaymentIntentDataParams{ + Metadata: map[string]string{"out_trade_no": req.OutTradeNo}, +} +``` +`VerifyCallback` 增 case: +```go +case "charge.dispute.created": + var d gostripe.Dispute + if err := json.Unmarshal(event.Data.Raw, &d); err != nil { + return nil, fmt.Errorf("stripe: 解析 dispute 失败: %w", err) + } + ev := &provider.PaidEvent{ + Kind: provider.EventChargeback, DisputeRef: d.ID, Status: provider.PaidFailed, + PaidAmountMinor: d.Amount, PaidCurrency: strings.ToUpper(string(d.Currency)), + Reason: string(d.Reason), Raw: string(in.Raw), + } + if d.PaymentIntent != nil && d.PaymentIntent.ID != "" { + ev.ProviderPaymentRef = d.PaymentIntent.ID + if pi, err := p.sc.PaymentIntents.Get(d.PaymentIntent.ID, nil); err == nil && pi.Metadata != nil { + ev.OutTradeNo = pi.Metadata["out_trade_no"] // 一次性单带;订阅 charge 为空 + } + } + return ev, nil +``` +fake backend 加 `GET /v1/payment_intents/pi_1` → `{"id":"pi_1","object":"payment_intent","metadata":{"out_trade_no":"PAY-1"}}`。 + +- [ ] **Step 5: gateway `recordChargeback`** +```go +func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { + created, err := g.chargebacks.Create(&model.Chargeback{ + DisputeRef: ev.DisputeRef, OutTradeNo: ev.OutTradeNo, Channel: method, + ProviderPaymentRef: ev.ProviderPaymentRef, AmountMinor: ev.PaidAmountMinor, + Currency: ev.PaidCurrency, Reason: ev.Reason, Status: "received", + }) + if err != nil { + return SettleFailed, err + } + if !created { + return SettleDuplicate, nil // 拒付重投,幂等 + } + if ev.OutTradeNo == "" { + log.Printf("[chargeback] dispute=%s 无法定位业务单(订阅/无 metadata),已记录未转发", ev.DisputeRef) + return SettleProcessed, nil + } + o, err := g.orders.GetOrder(ev.OutTradeNo) + if err != nil { + log.Printf("[chargeback] dispute=%s out_trade_no=%s 查单失败: %v", ev.DisputeRef, ev.OutTradeNo, err) + return SettleProcessed, nil // 已记录 chargeback;定位失败不阻断 + } + if err := g.orders.MarkDisputed(o.OutTradeNo); err != nil { // 打标不改状态机 + return SettleFailed, err + } + if o.BizSystem == "" { + return SettleProcessed, nil + } + if err := g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtChargebackReceived, map[string]any{ + "event_type": EvtChargebackReceived, "out_trade_no": o.OutTradeNo, "dispute_ref": ev.DisputeRef, + "biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode, + "amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "reason": ev.Reason, + "received_at": time.Now().Format(time.RFC3339), + }); err != nil { + return SettleFailed, err + } + return SettleProcessed, nil +} +``` +> `chargeback.received` outbox 键 `(o.OutTradeNo, chargeback.received)` 唯一——同一单二次拒付罕见,最小可行接受"只转发首次";`Chargeback` 表按 `dispute_ref` 完整留痕每次。`MarkDisputed` 加进 `OrderStore`(条件 UPDATE 置 `disputed=true`)。`Gateway` 注入 `chargebacks *store.ChargebackStore`。**门禁**:`chargeback.received` 挂在已 paid 的原单上,Notifier `orderPaid` 门禁天然放行。 + +- [ ] **Step 6 跑通过 + 全量回归** → `go build ./... && go test ./...` + +- [ ] **Step 7: commit** — `feat(pay-v2): P8 Task6 拒付 chargeback 记录 + chargeback.received` + +--- + +### Task 7: 业务方 webhook 事件集声明 + 装配收尾 + 契约文档 + +**Files:** +- Modify: `config/config.go`(`BizSystemConfig` 加 `SupportedEvents []string`) +- Modify: `internal/webhook/notifier.go`(投递前按业务方声明的事件集过滤;订阅状态事件门禁旁路) +- Modify: `main.go`(`providerbuild` 装配确认 stripe 已注册即支持订阅;`AutoMigrate` 最终确认含 `Subscription`/`Chargeback`) +- Modify: `docs/pay-v2-unified-gateway-design.html`(§5.1 recurring 标「P8 已实现 gateway_scheduled(Stripe)」、webhook 事件表补 payload 契约) +- Modify: `internal/webhook/notifier_test.go` + +**决策记录:** +- **接入方显式声明支持事件集**(设计 §「接入方显式声明支持事件集」):`BizSystemConfig.SupportedEvents`(空=只收 `payment.succeeded`,向后兼容 v1/P2 接入方,不会突然收到新事件把它们搞崩)。Notifier 投递前:`event_type` 不在声明集 → 直接标 delivered(视为已受理,不投、不阻塞队列),避免给没准备好订阅/拒付的业务方推未知事件。 +- **门禁旁路**:`subscription.past_due`/`subscription.canceled` 挂在已 paid 的首购单上(Task5 决策已改用真实 `sub.OutTradeNo`),`orderPaid` 门禁天然放行,无需额外旁路;仅需事件集过滤。 + +- [ ] **Step 1: 写失败测试** —— `notifier_test.go`:①业务方 `SupportedEvents=["payment.succeeded"]`,outbox 有一条 `subscription.renewed` → `DeliverPending` 不 POST、标 delivered(不占重试)、`payment.succeeded` 正常投;②`SupportedEvents=["payment.succeeded","subscription.renewed","subscription.created","subscription.canceled","subscription.past_due","chargeback.received"]` → 全部正常投递。 + +- [ ] **Step 2 跑失败** + +- [ ] **Step 3: 实现事件集过滤** —— `config.BizSystemConfig` 加 `SupportedEvents []string `mapstructure:"supported_events"``;`Notifier` 注入 `bizConfig` 已能拿到它。`deliverOne` 在门禁后加: +```go +if !eventSupported(cfg.SupportedEvents, d.EventType) { + _ = n.deliveries.MarkDelivered(d.ID) // 业务方未订阅该事件:视为已受理,不投递、不重试 + return true +} +``` +`eventSupported(list, ev)`:`list` 空 → 仅 `ev=="payment.succeeded"` 为真(向后兼容);非空 → `ev ∈ list`。 + +- [ ] **Step 4: 装配 + 文档** —— `main.go` 确认 `gateway.New` 已注入 `SubscriptionStore`/`ChargebackStore`(Task3/6 已做),`AutoMigrate` 含两新表;`providerbuild` 无需改(stripe 已注册即自带 `SubscriptionProvider`)。更新设计 HTML:§5.1 表格 `gateway_scheduled` 行标「✅ P8」、webhook 事件表(§5)补 `subscription.created/renewed/past_due/canceled`、`chargeback.received` 的 payload 字段契约(out_trade_no/sub_id/dispute_ref/amount_minor/...);顶部执行分期「(later)订阅/拒付」标「P8 gateway_scheduled + chargeback 已落地,token_offsession/store_managed 仍留能力位」。 + +- [ ] **Step 5 跑通过 + 全量回归** → `cd /Users/wangjia/code/pay && go build ./... && go test ./...` +Expected: 全绿。 + +- [ ] **Step 6: commit** — `feat(pay-v2): P8 Task7 业务方事件集声明 + 装配收尾 + 契约文档` + +--- + +## 完成校验(全 Task 后) + +- [ ] `go build ./... && go test ./...` 全绿(全 `:memory:`/`httptest`,免 docker、无真实密钥、不打真网)。 +- [ ] 一条真实链路闭环(用假 backend 端到端):创建订阅 Checkout → 首期 `checkout.session.completed` → 首购单 paid + `payment.succeeded` + 诞生订阅 + `subscription.created` → `invoice.paid`(cycle)→ renewal 单 paid + `subscription.renewed` → `invoice.payment_failed` → past_due + `subscription.past_due` → 取消 → canceled + `subscription.canceled`;`charge.dispute.created` → Chargeback + 订单打标 + `chargeback.received`。 +- [ ] 能力位诚实:`token_offsession`(`RecurringProvider`)/`store_managed`/alipay 周期扣 均**未实现**,注释/文档写明;crypto `SupportsRecurring=false` 且无 chargeback。 +- [ ] 幂等回归:重复投递首期/续费/取消/拒付 webhook,订阅不重复诞生、renewal 单不重复、webhook 不重复、chargeback 不重复。