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:
+6
-1
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 达到可用后再动。
|
||||
Reference in New Issue
Block a user