# 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 达到可用后再动。