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>
This commit is contained in:
wangjia
2026-07-10 07:56:26 +08:00
parent 61a049d977
commit 2383b9d593
4 changed files with 894 additions and 1 deletions
+6 -1
View File
@@ -52,7 +52,12 @@
</a>
<h2>📋 实现计划</h2>
<p class="empty">见仓库根 README.md(运行与联调步骤)</p>
<a class="doc" href="./pay-v2-p1-plan.html">
<div class="title">pay v2 · P1 核心数据模型 + 配置账户注册表(阅读版)⭐</div>
<div class="desc">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md</code>(含 checkbox)。<a href="./pay-v2-unified-gateway-design.html">pay v2 设计</a>的首个落地阶段(8 阶段之 P1)。4 个 TDD 任务:money 包(int64 最小单位+币种)→ Order/Attempt/Account/Refund 模型+状态机+<code>:memory:</code> 测试约定 → OrderStore(幂等标付/取消/列表,条件UPDATE+RowsAffected)→ 账户配置注册表(凭证走 env)。沿用 GORM AutoMigrate/glebarez sqlite 惯例;金额从 string 元改 int64。P2-P8(Provider/收款管线/渠道 adapter/退款/路由/对账/codes 库/订阅)各自成计划、落地前细化。</div>
<div class="meta">P1 · 2026-07-10 · Go(Gin+GORM+sqlite) · 待执行</div>
</a>
<p class="empty">运行与联调步骤见仓库根 README.md</p>
<h2>🔧 排障 Runbook</h2>
<a class="doc" href="./上线状态与部署Runbook.html">
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pay v2 · P1 核心数据模型(阅读版)</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--radius:14px;--mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:960px;margin:0 auto;padding:44px 24px 96px}
.back{display:inline-block;margin-bottom:18px;font-size:13px}
a{color:var(--accent);text-decoration:none}
h1{font-size:27px;margin:6px 0 8px}
.lead{color:var(--fg-soft);margin:0 0 22px}
h2{font-size:18px;margin:34px 0 10px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:8px}
code{font-family:var(--mono);font-size:.85em;background:var(--card-2);padding:1px 6px;border-radius:5px;color:#cdd9e5}
b{color:#fff}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg-soft);font-weight:600;font-size:13px}
table.fields td:first-child{font-family:var(--mono);font-size:12px;color:#cdd9e5;white-space:nowrap;width:1%}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 18px;margin:12px 0}
.card h3{margin:0 0 6px;color:var(--accent);font-size:15px}
.lead-box{background:linear-gradient(180deg,rgba(88,166,255,.10),transparent);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin:0 0 8px}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px}
.tag.ok{background:rgba(63,185,80,.16);color:var(--ok)}
.tag.info{background:rgba(88,166,255,.16);color:var(--accent)}
.path{color:var(--muted);font-size:12px;font-family:var(--mono);margin-top:6px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 返回文档索引</a>
<h1>pay v2 · P1 核心数据模型 + 配置账户注册表<span style="font-size:14px"> (阅读版)</span></h1>
<p class="lead">执行真相源 <code>docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md</code>(含 <code>- [ ]</code> checkbox)。设计见 <a href="pay-v2-unified-gateway-design.html">pay v2 统一支付网关设计</a></p>
<div class="lead-box">
<b>P1 目标</b>:建 pay v2 的<b>核心数据地基</b> —— Order + Attempt + Account + Refund 模型、金额 int64 最小单位 + 币种、状态机、<code>:memory:</code> 测试约定、配置驱动账户注册表。即"一单 N 渠道只一个成功 / 可取消 / 多账户路由"的数据层。不含 Provider/收款管线/渠道/退款执行(P2+)。
</div>
<h2>8 阶段路线图</h2>
<table>
<thead><tr><th>阶段</th><th>内容</th><th>状态</th></tr></thead>
<tbody>
<tr><td><b>P1</b></td><td>核心模型(Order/Attempt/Account/Refund)+ money + 状态机 + 测试约定 + 账户配置注册表</td><td><span class="tag ok">本页</span></td></tr>
<tr><td>P2</td><td>Provider 抽象 + 一次性收款管线 + webhook v2(event_type)+ 幂等/金额核对(fake provider 测)</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P3</td><td>首批渠道 adapter:crypto 自托管 / 支付宝 / Stripe</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P4</td><td>退款:POST /refunds + 三向(业务/crypto 人工/平台通知)+ refund 事件</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P5</td><td>多账户路由策略(round_robin/weighted/limit_aware/crypto 地址池)</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P6</td><td>对账 job(每 provider query,渠道流水 vs 本地订单)</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P7</td><td>codes 共享库(A 嵌入各产品)</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P8</td><td>later:订阅/recurring(4 类 kind)、拒付 chargeback</td><td><span class="tag info">待细化</span></td></tr>
</tbody>
</table>
<h2>P1 任务(4 个,TDD bite-sized)</h2>
<div class="card"><h3>Task 1 · money 包</h3>金额 <b>int64 最小单位 + 币种码</b>(CNY/USD=分、USDT=micro);<code>Parse</code>/<code>Format</code> 往返,超精度报错。禁 float / string 元(v2 破坏性改动)。</div>
<div class="card"><h3>Task 2 · v2 模型 + 状态机 + 测试约定</h3>Order/Attempt/Account/Refund GORM 模型(内嵌 Base、金额 int64+币种、Attempt <code>UNIQUE(channel,provider_ref)</code>)、三套状态枚举、接进 AutoMigrate;建 <code>OpenTestDB</code><code>:memory:</code> sqlite 测试约定(pay 原无 DB 测试)。</div>
<div class="card"><h3>Task 3 · OrderStore</h3>建单/建尝试/<b>幂等标付</b>(条件 UPDATE + RowsAffected,守卫 order=pending → 一单只一个 attempt 成功)/取消(pending→canceled)/列表(全状态含 canceled)。沿用 pay 现有 applyPaid 幂等手法(sqlite 单写,无 FOR UPDATE)。</div>
<div class="card"><h3>Task 4 · 账户配置注册表</h3><code>config.accounts</code> 声明账户(channel/weight/enabled/limit/region/subject),<b>凭证只存 env 前缀、真值运行时从 env 取</b>;<code>EnabledFor(channel,region)</code> 筛选(路由策略在 P5)。</div>
<h2>关键约束</h2>
<table class="fields">
<thead><tr><th>约束</th><th>说明</th></tr></thead>
<tbody>
<tr><td>金额</td><td>int64 最小单位 + 币种码,禁 float / string 元</td></tr>
<tr><td>幂等</td><td>条件 UPDATE + RowsAffected(sqlite 单写,无 FOR UPDATE)</td></tr>
<tr><td>迁移</td><td>GORM AutoMigrate,无手写 migration;内嵌 Base</td></tr>
<tr><td>凭证</td><td>json:"-" + env 注入,不入库/配置明文</td></tr>
<tr><td>重设计</td><td>jiu 未上线、无生产数据,可重建表</td></tr>
</tbody>
</table>
<p class="path">相关:<a href="pay-v2-unified-gateway-design.html">pay v2 设计</a> · 真相源 docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md</p>
</div>
</body>
</html>
+1
View File
@@ -47,6 +47,7 @@
<h1>pay · 多产品统一支付 + 履约网关设计</h1>
<p class="lead">把 pay 做成 pangolin / jiu / dudu / 未来产品共用的支付层:国内外常见支付 + 加密货币,统一 Order+Attempt(一单 N 渠道只一个成功 / 可取消 / 可退款),render_type 多态(加渠道不改 client),多账户路由,配置驱动渠道开关,国内外双部署。2026-07-10</p>
<p class="small">📋 <b>实现计划</b>:<a href="pay-v2-p1-plan.html">P1 核心数据模型 + 配置账户注册表</a>(8 阶段之一;P2–P8 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。</p>
<div class="lead-box">
<b>一句话</b>:pay 是<b>中性在线收款中枢</b>——只管"按被授权的金额收款/退款 + 记账 + 发事件",不碰业务语义。客户端只认 <b>6 个 render_type</b>(付款意图数据,非 UI);渠道差异全封死在服务端 <code>Provider.verify_callback</code>;开通/权益各产品自留,只共享"入账成功"事件契约 + 时长叠加算法。<b>加同形态新渠道 = 服务端零改客户端。</b>
</div>
@@ -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 `<CredentialEnvPrefix>_<KEY>` 取凭证(如 `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 <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**
```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 达到可用后再动。