Files
pay/docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md
T
wangjia 2383b9d593 docs: pay v2 P1 实现计划(核心数据模型+账户注册表)
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>
2026-07-10 07:56:26 +08:00

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: TestParseFormatRoundTripTestParseErrors 均 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
    • 结构 OrderV2AttemptAccountRefund(GORM 模型,内嵌 Base)。字段见实现。
    • func OpenTestDB(t *testing.T) *gorm.DB — 打开 :memory: sqlite 并 AutoMigrate v2 模型,供各包测试复用。
  • 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

AttemptChannel/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.goautoMigrate(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) *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:

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) *Registry
    • func (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.goConfig 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/accountsok(其余包不受影响)。

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