From 2383b9d59363188c41730f67dc9a0b1b4e5a7b76 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 07:56:26 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20pay=20v2=20P1=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AE=A1=E5=88=92(=E6=A0=B8=E5=BF=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B+=E8=B4=A6=E6=88=B7=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 TDD 任务:money(int64 最小单位+币种) / Order·Attempt·Account·Refund 模型+状态机 +:memory: 测试约定 / OrderStore(幂等标付/取消/列表) / 账户配置注册表(env 凭证)。 沿用 GORM AutoMigrate + glebarez sqlite。HTML 阅读版 + 登记 index + 与设计文档互链。 P2-P8 各自成计划。 Co-Authored-By: Claude Opus 4.8 --- docs/index.html | 7 +- docs/pay-v2-p1-plan.html | 78 ++ docs/pay-v2-unified-gateway-design.html | 1 + .../plans/2026-07-10-pay-v2-p1-core-model.md | 809 ++++++++++++++++++ 4 files changed, 894 insertions(+), 1 deletion(-) create mode 100644 docs/pay-v2-p1-plan.html create mode 100644 docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md diff --git a/docs/index.html b/docs/index.html index 147fa46..32e17a4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -52,7 +52,12 @@

📋 实现计划

-

见仓库根 README.md(运行与联调步骤)

+ +
pay v2 · P1 核心数据模型 + 配置账户注册表(阅读版)⭐
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md(含 checkbox)。pay v2 设计的首个落地阶段(8 阶段之 P1)。4 个 TDD 任务:money 包(int64 最小单位+币种)→ Order/Attempt/Account/Refund 模型+状态机+:memory: 测试约定 → OrderStore(幂等标付/取消/列表,条件UPDATE+RowsAffected)→ 账户配置注册表(凭证走 env)。沿用 GORM AutoMigrate/glebarez sqlite 惯例;金额从 string 元改 int64。P2-P8(Provider/收款管线/渠道 adapter/退款/路由/对账/codes 库/订阅)各自成计划、落地前细化。
+
P1 · 2026-07-10 · Go(Gin+GORM+sqlite) · 待执行
+ +

运行与联调步骤见仓库根 README.md

🔧 排障 Runbook

diff --git a/docs/pay-v2-p1-plan.html b/docs/pay-v2-p1-plan.html new file mode 100644 index 0000000..2acea78 --- /dev/null +++ b/docs/pay-v2-p1-plan.html @@ -0,0 +1,78 @@ + + + + + +pay v2 · P1 核心数据模型(阅读版) + + + +
+← 返回文档索引 +

pay v2 · P1 核心数据模型 + 配置账户注册表 (阅读版)

+

执行真相源 docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md(含 - [ ] checkbox)。设计见 pay v2 统一支付网关设计

+ +
+P1 目标:建 pay v2 的核心数据地基 —— Order + Attempt + Account + Refund 模型、金额 int64 最小单位 + 币种、状态机、:memory: 测试约定、配置驱动账户注册表。即"一单 N 渠道只一个成功 / 可取消 / 多账户路由"的数据层。不含 Provider/收款管线/渠道/退款执行(P2+)。 +
+ +

8 阶段路线图

+ + + + + + + + + + + + +
阶段内容状态
P1核心模型(Order/Attempt/Account/Refund)+ money + 状态机 + 测试约定 + 账户配置注册表本页
P2Provider 抽象 + 一次性收款管线 + webhook v2(event_type)+ 幂等/金额核对(fake provider 测)待细化
P3首批渠道 adapter:crypto 自托管 / 支付宝 / Stripe待细化
P4退款:POST /refunds + 三向(业务/crypto 人工/平台通知)+ refund 事件待细化
P5多账户路由策略(round_robin/weighted/limit_aware/crypto 地址池)待细化
P6对账 job(每 provider query,渠道流水 vs 本地订单)待细化
P7codes 共享库(A 嵌入各产品)待细化
P8later:订阅/recurring(4 类 kind)、拒付 chargeback待细化
+ +

P1 任务(4 个,TDD bite-sized)

+

Task 1 · money 包

金额 int64 最小单位 + 币种码(CNY/USD=分、USDT=micro);Parse/Format 往返,超精度报错。禁 float / string 元(v2 破坏性改动)。
+

Task 2 · v2 模型 + 状态机 + 测试约定

Order/Attempt/Account/Refund GORM 模型(内嵌 Base、金额 int64+币种、Attempt UNIQUE(channel,provider_ref))、三套状态枚举、接进 AutoMigrate;建 OpenTestDB:memory: sqlite 测试约定(pay 原无 DB 测试)。
+

Task 3 · OrderStore

建单/建尝试/幂等标付(条件 UPDATE + RowsAffected,守卫 order=pending → 一单只一个 attempt 成功)/取消(pending→canceled)/列表(全状态含 canceled)。沿用 pay 现有 applyPaid 幂等手法(sqlite 单写,无 FOR UPDATE)。
+

Task 4 · 账户配置注册表

config.accounts 声明账户(channel/weight/enabled/limit/region/subject),凭证只存 env 前缀、真值运行时从 env 取;EnabledFor(channel,region) 筛选(路由策略在 P5)。
+ +

关键约束

+ + + + + + + + + +
约束说明
金额int64 最小单位 + 币种码,禁 float / string 元
幂等条件 UPDATE + RowsAffected(sqlite 单写,无 FOR UPDATE)
迁移GORM AutoMigrate,无手写 migration;内嵌 Base
凭证json:"-" + env 注入,不入库/配置明文
重设计jiu 未上线、无生产数据,可重建表
+ +

相关:pay v2 设计 · 真相源 docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md

+
+ + diff --git a/docs/pay-v2-unified-gateway-design.html b/docs/pay-v2-unified-gateway-design.html index a1c3aba..2ebb620 100644 --- a/docs/pay-v2-unified-gateway-design.html +++ b/docs/pay-v2-unified-gateway-design.html @@ -47,6 +47,7 @@

pay · 多产品统一支付 + 履约网关设计

把 pay 做成 pangolin / jiu / dudu / 未来产品共用的支付层:国内外常见支付 + 加密货币,统一 Order+Attempt(一单 N 渠道只一个成功 / 可取消 / 可退款),render_type 多态(加渠道不改 client),多账户路由,配置驱动渠道开关,国内外双部署。2026-07-10

+

📋 实现计划:P1 核心数据模型 + 配置账户注册表(8 阶段之一;P2–P8 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。

一句话:pay 是中性在线收款中枢——只管"按被授权的金额收款/退款 + 记账 + 发事件",不碰业务语义。客户端只认 6 个 render_type(付款意图数据,非 UI);渠道差异全封死在服务端 Provider.verify_callback;开通/权益各产品自留,只共享"入账成功"事件契约 + 时长叠加算法。加同形态新渠道 = 服务端零改客户端。
diff --git a/docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md b/docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md new file mode 100644 index 0000000..bceffb2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md @@ -0,0 +1,809 @@ +# pay v2 · P1 核心数据模型 + 配置账户注册表 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. + +> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html`(架构/契约/取舍)。本计划是 pay v2 多阶段中的 **P1** 落地。 + +**Goal:** 为 pay v2 建立核心数据模型(Order + Attempt + Account + Refund)、金额/币种基础(int64 最小单位)、状态机、DB 测试约定,以及配置驱动的账户注册表——即"一单 N 渠道只一个成功 / 可取消 / 多账户路由"的数据地基。 + +**Architecture:** 干净 v2 重设计(pay ~1700 行、jiu 未上线、无生产数据,可重建表)。沿用现有惯例:GORM `AutoMigrate` + `glebarez/sqlite`(纯 Go 免 CGO)+ `Base{ID,CreatedAt,UpdatedAt}`;金额从 string 元改为 **int64 最小单位 + 币种码**;幂等沿用**条件 UPDATE + RowsAffected**(sqlite 单写,无 FOR UPDATE);账户从配置加载、凭证走 env。 + +**Tech Stack:** Go 1.26.1 · `github.com/wangjia/pay` · Gin · GORM v1.31 · glebarez/sqlite · viper。 + +## Global Constraints + +- 金额一律 **int64 最小单位 + 币种码**(CNY/USD=分 1e-2、USDT=micro 1e-6);**禁 float、禁 string 元**(v2 相对 v1 的破坏性改动)。crypto 的 asset/chain 作为渠道字段另存,金额仍走 int64 micro。 +- 幂等:**条件 UPDATE + 检查 `RowsAffected`**(sqlite 单写引擎,不用 `FOR UPDATE`);事务用 `db.Transaction(func(tx *gorm.DB) error{})`。 +- GORM `AutoMigrate`,无手写 migration;所有模型内嵌 `model.Base`(`ID uint64`/`CreatedAt`/`UpdatedAt`,无软删除)。 +- 密钥/凭证字段 `json:"-"`,绝不下发;账户凭证从 env 注入,不写死配置文件。 +- 表名走 GORM 默认复数(orders/attempts/accounts/refunds)。 +- 每步 `go build ./...` 通过;测试 `go test ./...`(新建 `:memory:` sqlite 测试约定,免 docker)。 +- 订单状态英文枚举 / 中文显示分离:created 初始化 · pending 等待付款 · paid 付款完成 · canceled 已取消 · expired 已过期 · refunding 退款中 · partially_refunded 部分退款 · refunded 已退款。 + +--- + +### Task 1: money 包 — int64 最小单位 + 币种码 + +**Files:** +- Create: `internal/money/money.go` +- Test: `internal/money/money_test.go` + +**Interfaces:** +- Produces: + - `func Exponent(currency string) (int, bool)` — 币种小数位(CNY/USD=2,USDT=6),未知返回 `false`。 + - `func Format(minor int64, currency string) (string, error)` — `12341700,"USDT"` → `"12.3417"`(去尾零)。 + - `func Parse(s, currency string) (int64, error)` — `"12.3417","USDT"` → `12341700`;非法/超精度报错。 + - `var ErrUnknownCurrency = errors.New("money: unknown currency")` + +- [ ] **Step 1: 写失败测试** + +`internal/money/money_test.go`: +```go +package money_test + +import ( + "testing" + + "github.com/wangjia/pay/internal/money" +) + +func TestParseFormatRoundTrip(t *testing.T) { + cases := []struct { + s, cur string + minor int64 + }{ + {"12.3417", "USDT", 12341700}, + {"0.01", "CNY", 1}, + {"29.99", "USD", 2999}, + {"199.99", "CNY", 19999}, + {"1", "USDT", 1000000}, + } + for _, c := range cases { + got, err := money.Parse(c.s, c.cur) + if err != nil || got != c.minor { + t.Fatalf("Parse(%q,%s)=%d,%v want %d", c.s, c.cur, got, err, c.minor) + } + back, err := money.Format(c.minor, c.cur) + if err != nil { + t.Fatalf("Format(%d,%s): %v", c.minor, c.cur, err) + } + // round-trip 回同一 minor(去尾零后可能字符串不同,再 Parse 校验) + again, _ := money.Parse(back, c.cur) + if again != c.minor { + t.Fatalf("round-trip %d → %q → %d", c.minor, back, again) + } + } +} + +func TestParseErrors(t *testing.T) { + if _, err := money.Parse("1.00", "JPYX"); err == nil { + t.Fatal("未知币种应报错") + } + if _, err := money.Parse("0.001", "CNY"); err == nil { + t.Fatal("超精度(CNY 3 位小数)应报错") + } + if _, err := money.Parse("abc", "CNY"); err == nil { + t.Fatal("非数字应报错") + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/money/ -v` +Expected: 编译失败 —— `money` 包不存在。 + +- [ ] **Step 3: 写实现** + +`internal/money/money.go`: +```go +// Package money represents amounts as int64 minor units + a currency code. +// CNY/USD = 分 (1e-2), USDT = micro (1e-6). No float, no "元 string" (v2). +package money + +import ( + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +var ErrUnknownCurrency = errors.New("money: unknown currency") + +var exponents = map[string]int{ + "CNY": 2, + "USD": 2, + "USDT": 6, +} + +// Exponent returns the number of decimal places for a currency. +func Exponent(currency string) (int, bool) { + e, ok := exponents[strings.ToUpper(currency)] + return e, ok +} + +// Format renders minor units as a decimal string, trailing zeros trimmed. +func Format(minor int64, currency string) (string, error) { + exp, ok := Exponent(currency) + if !ok { + return "", ErrUnknownCurrency + } + if exp == 0 { + return strconv.FormatInt(minor, 10), nil + } + neg := minor < 0 + if neg { + minor = -minor + } + div := int64(math.Pow10(exp)) + whole := minor / div + frac := minor % div + s := fmt.Sprintf("%d.%0*d", whole, exp, frac) + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + if neg { + s = "-" + s + } + return s, nil +} + +// Parse converts a decimal string to minor units for the currency. It rejects +// values with more fractional digits than the currency allows. +func Parse(s, currency string) (int64, error) { + exp, ok := Exponent(currency) + if !ok { + return 0, ErrUnknownCurrency + } + s = strings.TrimSpace(s) + neg := strings.HasPrefix(s, "-") + s = strings.TrimPrefix(s, "-") + intPart, fracPart := s, "" + if i := strings.IndexByte(s, '.'); i >= 0 { + intPart, fracPart = s[:i], s[i+1:] + } + if len(fracPart) > exp { + return 0, fmt.Errorf("money.Parse: %q exceeds %d dp for %s", s, exp, currency) + } + if intPart == "" && fracPart == "" { + return 0, fmt.Errorf("money.Parse: empty %q", s) + } + whole, err := strconv.ParseInt("0"+intPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("money.Parse int %q: %w", s, err) + } + fracPart += strings.Repeat("0", exp-len(fracPart)) + var frac int64 + if fracPart != "" { + frac, err = strconv.ParseInt(fracPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("money.Parse frac %q: %w", s, err) + } + } + minor := whole*int64(math.Pow10(exp)) + frac + if neg { + minor = -minor + } + return minor, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/money/ -v` +Expected: `TestParseFormatRoundTrip` 与 `TestParseErrors` 均 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/money/ +git commit -m "feat(v2): money 包 int64 最小单位 + 币种码(CNY/USD/USDT)" +``` + +--- + +### Task 2: v2 模型 + 状态枚举 + AutoMigrate + DB 测试约定 + +**Files:** +- Create: `internal/model/v2.go`(Order/Attempt/Account/Refund + 状态枚举) +- Create: `internal/model/testdb.go`(测试用 `:memory:` DB helper,`//go:build ...` 无需,普通导出供测试引用) +- Test: `internal/model/v2_test.go` +- Modify: `main.go:76-87`(`autoMigrate` 加入 v2 模型) + +**Interfaces:** +- Produces: + - 状态类型 `OrderStatusV2 string` + 常量 `OrderCreated/OrderPending/OrderPaid/OrderCanceled/OrderExpired/OrderRefunding/OrderPartRefunded/OrderRefunded`。 + - `AttemptStatus string` + `AttemptCreated/Pending/Paid/Failed/Expired`。 + - `RefundStatus string` + `RefundRequested/Processing/Succeeded/Failed`。 + - 结构 `OrderV2`、`Attempt`、`Account`、`Refund`(GORM 模型,内嵌 `Base`)。字段见实现。 + - `func OpenTestDB(t *testing.T) *gorm.DB` — 打开 `:memory:` sqlite 并 `AutoMigrate` v2 模型,供各包测试复用。 + +- [ ] **Step 1: 写失败测试(建表 + 写读一行)** + +`internal/model/v2_test.go`: +```go +package model_test + +import ( + "testing" + + "github.com/wangjia/pay/internal/model" +) + +func TestV2Migrate(t *testing.T) { + db := model.OpenTestDB(t) + + o := model.OrderV2{ + OutTradeNo: "PAY-1", MerchantID: 1, BizSystem: "pangolin", BizRef: "u-1", + ProductID: 3, Subject: "pro-year", AmountMinor: 29990000, Currency: "USDT", + Status: model.OrderPending, + } + if err := db.Create(&o).Error; err != nil { + t.Fatalf("create order: %v", err) + } + a := model.Attempt{ + OutTradeNo: "PAY-1", Channel: "crypto", AccountID: "usdt-a1", ProviderRef: "P-9", + RenderType: "crypto_address", AmountMinor: 29990000, Currency: "USDT", + Status: model.AttemptPending, + } + if err := db.Create(&a).Error; err != nil { + t.Fatalf("create attempt: %v", err) + } + var got model.OrderV2 + if err := db.Where("out_trade_no = ?", "PAY-1").First(&got).Error; err != nil { + t.Fatalf("read: %v", err) + } + if got.AmountMinor != 29990000 || got.Status != model.OrderPending { + t.Fatalf("got %+v", got) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/model/ -run TestV2Migrate -v` +Expected: 编译失败 —— `OrderV2`/`OpenTestDB` 未定义。 + +- [ ] **Step 3: 写模型** + +`internal/model/v2.go`: +```go +package model + +import "time" + +// ---- 状态枚举 ---- + +type OrderStatusV2 string + +const ( + OrderCreated OrderStatusV2 = "created" + OrderPending OrderStatusV2 = "pending" + OrderPaid OrderStatusV2 = "paid" + OrderCanceled OrderStatusV2 = "canceled" + OrderExpired OrderStatusV2 = "expired" + OrderRefunding OrderStatusV2 = "refunding" + OrderPartRefunded OrderStatusV2 = "partially_refunded" + OrderRefunded OrderStatusV2 = "refunded" +) + +type AttemptStatus string + +const ( + AttemptCreated AttemptStatus = "created" + AttemptPending AttemptStatus = "pending" + AttemptPaid AttemptStatus = "paid" + AttemptFailed AttemptStatus = "failed" + AttemptExpired AttemptStatus = "expired" +) + +type RefundStatus string + +const ( + RefundRequested RefundStatus = "requested" + RefundProcessing RefundStatus = "processing" + RefundSucceeded RefundStatus = "succeeded" + RefundFailed RefundStatus = "failed" +) + +// ---- 业务订单(购买账本)---- + +type OrderV2 struct { + Base + OutTradeNo string `gorm:"uniqueIndex;size:64;not null"` + MerchantID uint64 `gorm:"index"` + BizSystem string `gorm:"index;size:32"` // 接入方(pangolin/jiu),空=独立收款 + BizRef string `gorm:"size:128"` // 接入方业务单号,回调原样带回 + ProductID uint64 `gorm:"index"` + Subject string `gorm:"size:128"` + // 金额:int64 最小单位 + 币种(presentment/charge/settlement 三态在 charge 落地此处) + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + Status OrderStatusV2 `gorm:"index;size:24;not null"` + // 折扣(业务授权的折后价,记账留痕) + BaseAmountMinor int64 `gorm:"default:0"` + DiscountMinor int64 `gorm:"default:0"` + CampaignID string `gorm:"size:64"` + DiscountReason string `gorm:"size:128"` + PaidAt *time.Time + ExpiresAt *time.Time // 整体购买窗口 +} + +// ---- 支付尝试(收款账本)---- + +type Attempt struct { + Base + OutTradeNo string `gorm:"index;size:64;not null"` + Channel string `gorm:"index;size:32;not null"` // alipay/wechat/crypto/stripe/... + AccountID string `gorm:"index;size:64"` // 选中的收款账户 + Provider string `gorm:"size:32"` + ProviderRef string `gorm:"size:128"` // 渠道单号 + RenderType string `gorm:"size:24"` // redirect/qr/crypto_address/... + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + Status AttemptStatus `gorm:"index;size:16;not null"` + ExpiresAt *time.Time // 本次尝试超时 + PaidAt *time.Time +} + +// 复合唯一:(channel, provider_ref) —— 回调幂等命门。GORM 用相同 index 名做联合唯一。 +func (Attempt) uniqueProviderRef() {} + +// ---- 收款账户(同一渠道多账户)---- + +type Account struct { + Base + AccountID string `gorm:"uniqueIndex;size:64;not null"` // 逻辑账户标识(配置里声明) + Channel string `gorm:"index;size:32;not null"` + Weight int `gorm:"default:1"` + Enabled bool `gorm:"default:true"` + DailyLimit int64 `gorm:"default:0"` // 0=不限 + Region string `gorm:"size:16"` // cn/global/... + Subject string `gorm:"size:128"` // 收款主体 + // 凭证不落库明文:仅存 env 变量名引用,真值运行时从 env 取(见 Task 4) + CredentialEnvPrefix string `gorm:"size:64"` +} + +// ---- 退款子实体 ---- + +type Refund struct { + Base + RefundID string `gorm:"uniqueIndex;size:64;not null"` + OutTradeNo string `gorm:"index;size:64;not null"` + AttemptProviderRef string `gorm:"size:128"` // 针对哪笔支付,原路退 + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + Reason string `gorm:"size:256"` + Status RefundStatus `gorm:"index;size:16;not null"` + ProviderRefundRef string `gorm:"size:128"` + InitiatedBy string `gorm:"size:16"` // business/platform + CompletedAt *time.Time +} +``` + +- [ ] **Step 4: 写联合唯一索引(GORM tag)与测试 DB helper** + +把 `Attempt` 的 `Channel`/`ProviderRef` 的 tag 改为联合唯一(同名 `uniqueIndex`): +```go + Channel string `gorm:"index;size:32;not null;uniqueIndex:uq_attempt_provider"` + ... + ProviderRef string `gorm:"size:128;uniqueIndex:uq_attempt_provider"` +``` +并删除占位方法 `uniqueProviderRef`。 + +`internal/model/testdb.go`: +```go +package model + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// OpenTestDB opens an in-memory SQLite DB with the v2 schema migrated. +// Shared by all package tests (pay has no prior DB test harness — this is it). +func OpenTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), + &gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true}) + if err != nil { + t.Fatalf("open test db: %v", err) + } + if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}); err != nil { + t.Fatalf("migrate: %v", err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + return db +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/model/ -run TestV2Migrate -v` +Expected: PASS。 + +- [ ] **Step 6: 接进 main 的 AutoMigrate** + +`main.go` 的 `autoMigrate(db)`(约 `:76-87`)在现有 `AutoMigrate(...)` 调用里追加 v2 模型: +```go + if err := db.AutoMigrate( + &model.Merchant{}, &model.Product{}, &model.Order{}, + &model.NotifyLog{}, &model.BizNotifyLog{}, + &model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, // v2 + ); err != nil { +``` + +- [ ] **Step 7: 编译 + 全量测试** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/model/ ./internal/money/` +Expected: 编译通过,两包 `ok`。 + +- [ ] **Step 8: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/model/v2.go internal/model/v2_test.go internal/model/testdb.go main.go +git commit -m "feat(v2): Order/Attempt/Account/Refund 模型 + 状态枚举 + :memory: 测试约定" +``` + +--- + +### Task 3: OrderStore — 建单/建尝试/幂等标付/取消/列表 + +**Files:** +- Create: `internal/store/order.go` +- Test: `internal/store/order_test.go` + +**Interfaces:** +- Consumes: `model.OrderV2`/`model.Attempt` 及状态枚举(Task 2);`model.OpenTestDB`。 +- Produces: + - `type OrderStore struct{ db *gorm.DB }` · `func NewOrderStore(db *gorm.DB) *OrderStore` + - `CreateOrder(o *model.OrderV2) error` + - `CreateAttempt(a *model.Attempt) error` + - `MarkAttemptPaid(outTradeNo, channel, providerRef string, at time.Time) (bool, error)` —— 事务内幂等:仅当 order.status='pending' 时置 paid + 对应 attempt 置 paid;非 pending 返回 `false,nil`(「一单只一个 attempt 成功」守卫)。 + - `CancelOrder(outTradeNo string) (bool, error)` —— 仅 pending→canceled,返回是否生效。 + - `ListOrders(bizSystem, bizRef string, limit int) ([]model.OrderV2, error)` —— 返回全状态(含 canceled),供订单历史。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/order_test.go`: +```go +package store_test + +import ( + "testing" + "time" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/store" +) + +func seedOrder(t *testing.T, s *store.OrderStore, no string) { + t.Helper() + if err := s.CreateOrder(&model.OrderV2{ + OutTradeNo: no, BizSystem: "pangolin", BizRef: "u-1", ProductID: 3, + AmountMinor: 29990000, Currency: "USDT", Status: model.OrderPending, + }); err != nil { + t.Fatalf("create order: %v", err) + } + if err := s.CreateAttempt(&model.Attempt{ + OutTradeNo: no, Channel: "crypto", AccountID: "usdt-a1", ProviderRef: "P-" + no, + RenderType: "crypto_address", AmountMinor: 29990000, Currency: "USDT", + Status: model.AttemptPending, ExpiresAt: ptr(time.Now().Add(15 * time.Minute)), + }); err != nil { + t.Fatalf("create attempt: %v", err) + } +} +func ptr(t time.Time) *time.Time { return &t } + +func TestMarkPaidIdempotentAndCancel(t *testing.T) { + s := store.NewOrderStore(model.OpenTestDB(t)) + seedOrder(t, s, "PAY-1") + + ok, err := s.MarkAttemptPaid("PAY-1", "crypto", "P-PAY-1", time.Now()) + if err != nil || !ok { + t.Fatalf("mark#1 ok=%v err=%v", ok, err) + } + // 幂等:再标 → false + ok2, _ := s.MarkAttemptPaid("PAY-1", "crypto", "P-PAY-1", time.Now()) + if ok2 { + t.Fatalf("第二次 MarkAttemptPaid 应 false") + } + + // paid 后不能取消 + if okc, _ := s.CancelOrder("PAY-1"); okc { + t.Fatalf("paid 单不应可取消") + } + + // 另一单可取消,且列表可见 + seedOrder(t, s, "PAY-2") + if okc, err := s.CancelOrder("PAY-2"); err != nil || !okc { + t.Fatalf("cancel PAY-2 ok=%v err=%v", okc, err) + } + list, _ := s.ListOrders("pangolin", "u-1", 20) + var sawCanceled bool + for _, o := range list { + if o.OutTradeNo == "PAY-2" && o.Status == model.OrderCanceled { + sawCanceled = true + } + } + if !sawCanceled { + t.Fatalf("canceled 单应出现在列表") + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ -run TestMarkPaidIdempotentAndCancel -v` +Expected: 编译失败 —— `store` 包不存在。 + +- [ ] **Step 3: 写实现** + +`internal/store/order.go`: +```go +// Package store holds v2 data-access for orders/attempts. Idempotency uses +// conditional UPDATE + RowsAffected (SQLite single-writer; no FOR UPDATE), +// matching pay's existing applyPaid pattern. +package store + +import ( + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/model" +) + +type OrderStore struct{ db *gorm.DB } + +func NewOrderStore(db *gorm.DB) *OrderStore { return &OrderStore{db: db} } + +func (s *OrderStore) CreateOrder(o *model.OrderV2) error { + if err := s.db.Create(o).Error; err != nil { + return fmt.Errorf("store.CreateOrder: %w", err) + } + return nil +} + +func (s *OrderStore) CreateAttempt(a *model.Attempt) error { + if err := s.db.Create(a).Error; err != nil { + return fmt.Errorf("store.CreateAttempt: %w", err) + } + return nil +} + +// MarkAttemptPaid flips a pending order (and its attempt) to paid inside a tx. +// Returns false if the order was not pending (already handled / canceled / expired). +func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at time.Time) (bool, error) { + var flipped bool + err := s.db.Transaction(func(tx *gorm.DB) error { + res := tx.Model(&model.OrderV2{}). + Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPending). + Updates(map[string]any{"status": model.OrderPaid, "paid_at": at}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil // 非 pending → 幂等 no-op + } + if err := tx.Model(&model.Attempt{}). + Where("channel = ? AND provider_ref = ?", channel, providerRef). + Updates(map[string]any{"status": model.AttemptPaid, "paid_at": at}).Error; err != nil { + return err + } + flipped = true + return nil + }) + if err != nil { + return false, fmt.Errorf("store.MarkAttemptPaid: %w", err) + } + return flipped, nil +} + +func (s *OrderStore) CancelOrder(outTradeNo string) (bool, error) { + res := s.db.Model(&model.OrderV2{}). + Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPending). + Update("status", model.OrderCanceled) + if res.Error != nil { + return false, fmt.Errorf("store.CancelOrder: %w", res.Error) + } + return res.RowsAffected > 0, nil +} + +func (s *OrderStore) ListOrders(bizSystem, bizRef string, limit int) ([]model.OrderV2, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + var out []model.OrderV2 + if err := s.db.Where("biz_system = ? AND biz_ref = ?", bizSystem, bizRef). + Order("id DESC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListOrders: %w", err) + } + return out, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ -run TestMarkPaidIdempotentAndCancel -v` +Expected: PASS。 + +- [ ] **Step 5: 全量编译 + 测试** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/money/ ./internal/model/ ./internal/store/` +Expected: 编译通过,三包 `ok`。 + +- [ ] **Step 6: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/store/ +git commit -m "feat(v2): OrderStore 建单/建尝试/幂等标付/取消/列表(条件UPDATE+RowsAffected)" +``` + +--- + +### Task 4: Account 配置注册表 — 从配置加载账户,凭证走 env + +**Files:** +- Modify: `config/config.go`(加 `Accounts []AccountConfig` + env 凭证绑定) +- Create: `internal/accounts/registry.go` +- Test: `internal/accounts/registry_test.go` + +**Interfaces:** +- Consumes: `model.Account`(Task 2)。 +- Produces: + - `config.AccountConfig{ AccountID, Channel string; Weight int; Enabled bool; DailyLimit int64; Region, Subject, CredentialEnvPrefix string }` + - `type Registry struct{...}` · `func New(accts []config.AccountConfig) *Registry` + - `func (r *Registry) EnabledFor(channel, region string) []config.AccountConfig` — 返回该渠道+区域下 enabled 的账户(供路由;策略选择在 P5)。 + - `func (r *Registry) Credential(accountID, key string) string` — 从 env `_` 取凭证(如 `USDT_A1_XPUB`)。 + +- [ ] **Step 1: 写失败测试** + +`internal/accounts/registry_test.go`: +```go +package accounts_test + +import ( + "testing" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" +) + +func TestEnabledForAndCredential(t *testing.T) { + t.Setenv("USDT_A1_XPUB", "xpub-abc") + reg := accounts.New([]config.AccountConfig{ + {AccountID: "usdt-a1", Channel: "crypto", Region: "global", Enabled: true, Weight: 1, CredentialEnvPrefix: "USDT_A1"}, + {AccountID: "usdt-a2", Channel: "crypto", Region: "global", Enabled: false, Weight: 1, CredentialEnvPrefix: "USDT_A2"}, + {AccountID: "ali-cn1", Channel: "alipay", Region: "cn", Enabled: true, Weight: 1, CredentialEnvPrefix: "ALI_CN1"}, + }) + + got := reg.EnabledFor("crypto", "global") + if len(got) != 1 || got[0].AccountID != "usdt-a1" { + t.Fatalf("EnabledFor crypto/global = %+v", got) + } + if reg.Credential("usdt-a1", "XPUB") != "xpub-abc" { + t.Fatalf("Credential xpub 取值失败") + } + if len(reg.EnabledFor("alipay", "global")) != 0 { + t.Fatalf("alipay 在 global 应无账户") + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/accounts/ -v` +Expected: 编译失败 —— `accounts` 包 / `config.AccountConfig` 不存在。 + +- [ ] **Step 3: 加 config.AccountConfig** + +`config/config.go` 的 `Config` struct 加字段 `Accounts []AccountConfig \`mapstructure:"accounts"\`` 并新增类型: +```go +type AccountConfig struct { + AccountID string `mapstructure:"account_id"` + Channel string `mapstructure:"channel"` + Weight int `mapstructure:"weight"` + Enabled bool `mapstructure:"enabled"` + DailyLimit int64 `mapstructure:"daily_limit"` + Region string `mapstructure:"region"` + Subject string `mapstructure:"subject"` + CredentialEnvPrefix string `mapstructure:"credential_env_prefix"` +} +``` + +- [ ] **Step 4: 写 registry 实现** + +`internal/accounts/registry.go`: +```go +// Package accounts loads the payment-account registry from config. Credentials +// never live in the config file — only an env-var prefix is stored; the real +// secret is read from the environment at call time. +package accounts + +import ( + "os" + "strings" + + "github.com/wangjia/pay/config" +) + +type Registry struct{ accts []config.AccountConfig } + +func New(accts []config.AccountConfig) *Registry { return &Registry{accts: accts} } + +// EnabledFor returns enabled accounts for a channel (+region if non-empty). +func (r *Registry) EnabledFor(channel, region string) []config.AccountConfig { + var out []config.AccountConfig + for _, a := range r.accts { + if a.Channel != channel || !a.Enabled { + continue + } + if region != "" && a.Region != "" && a.Region != region { + continue + } + out = append(out, a) + } + return out +} + +// Credential reads _ from the environment. +func (r *Registry) Credential(accountID, key string) string { + for _, a := range r.accts { + if a.AccountID == accountID { + return os.Getenv(strings.ToUpper(a.CredentialEnvPrefix + "_" + key)) + } + } + return "" +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/accounts/ -v` +Expected: PASS。 + +- [ ] **Step 6: 全量编译 + 测试** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/...` +Expected: 编译通过,`money`/`model`/`store`/`accounts` 全 `ok`(其余包不受影响)。 + +- [ ] **Step 7: Commit** + +```bash +cd /Users/wangjia/code/pay +git add config/config.go internal/accounts/ +git commit -m "feat(v2): 账户配置注册表(config.accounts + env 取凭证 + 渠道/区域筛选)" +``` + +--- + +## Self-Review + +**Spec coverage(P1 范围):** money int64+币种(Task1)= 设计 §8 ✓;Order/Attempt/Account/Refund 模型 + 状态机(Task2)= §3 ✓;一单只一个 attempt 成功 + 取消 + 列表(Task3)= §3.1/3.2 ✓;多账户配置注册表 + 凭证 env(Task4)= §3.3/§8 配置驱动 ✓。P1 不含 Provider/收款管线/webhook/渠道 adapter/退款执行/路由策略(P2+)。 + +**Placeholder scan:** 无 TBD;所有 Go/测试完整。`Attempt` 联合唯一在 Task2 Step4 明确给出 tag 写法。 + +**Type consistency:** `model.OrderV2`/`Attempt`/`Account`/`Refund` 字段在 Task2 定义、Task3 store 与测试一致;`MarkAttemptPaid(outTradeNo,channel,providerRef,at)` 签名 Task3 定义/测试一致;`config.AccountConfig` 字段 Task4 定义/测试一致;金额一律 `AmountMinor int64 + Currency`。 + +--- + +## 后续阶段(各自独立成计划,落地前逐一细化) + +- **P2** Provider 抽象(capabilities/create/verify_callback/query)+ 一次性收款管线 + webhook v2(event_type)+ 幂等/金额核对(用 fake provider 测)。 +- **P3** 首批渠道 adapter:crypto 自托管(包裹 pangolin-pay 收款逻辑)/ 支付宝(迁移现有)/ Stripe。 +- **P4** 退款:`POST /refunds` + 三向(业务发起/crypto 人工/平台通知)+ refund 事件。 +- **P5** 多账户路由策略(round_robin/weighted/limit_aware/crypto 地址池)。 +- **P6** 对账 job(每 provider query,拉渠道流水 vs 本地订单)。 +- **P7** codes 共享库(A 嵌入各产品:码模型/状态机/生成/防双花兑换/批次/审计 + 叠加算法)。 +- **P8(later)** 订阅/recurring(4 类 kind)、拒付 chargeback。 + +> pay-contract 契约同步(多币种/render_type/channel 向后兼容加字段;webhook event_type 断代 v2.0.0)随 P2 落地。pangolin/jiu 收口(brain todo #5)在 pay 达到可用后再动。