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 <noreply@anthropic.com>
27 KiB
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:
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:
// 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
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 并AutoMigratev2 模型,供各包测试复用。
- 状态类型
-
Step 1: 写失败测试(建表 + 写读一行)
internal/model/v2_test.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:
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):
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:
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 模型:
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
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) *OrderStoreCreateOrder(o *model.OrderV2) errorCreateAttempt(a *model.Attempt) errorMarkAttemptPaid(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:
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:
// 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
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) *Registryfunc (r *Registry) EnabledFor(channel, region string) []config.AccountConfig— 返回该渠道+区域下 enabled 的账户(供路由;策略选择在 P5)。func (r *Registry) Credential(accountID, key string) string— 从 env<CredentialEnvPrefix>_<KEY>取凭证(如USDT_A1_XPUB)。
-
Step 1: 写失败测试
internal/accounts/registry_test.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"`` 并新增类型:
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:
// 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 <CredentialEnvPrefix>_<KEY> 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
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 达到可用后再动。