feat(pay): 收款模型改为单地址+唯一金额(#34/34A Phase A-C)

从"每单唯一 HD 地址"改为"单个固定收款地址 + 每单唯一金额",归集成本 O(订单数)→O(1)。
- store: pay_orders 加 user_ref/expect_amount(唯一金额)/matched_tx_id;新 orphan_payments 表;
  ActiveOrderByUser(同用户单订单)、AmountRecentlyUsed(迟到窗口内金额不复用)、TxHandled(幂等)、
  RecordOrphan。去掉每单派生游标。
- pay: CreateOrder(userRef,sku,priceMicro)——同用户单订单校验 + 分配唯一金额(base+随机微尾数[1,9999]、
  cooldown 内不复用),address 恒为收款地址。
- tron: Transfer 加 BlockTs(区块时间秒),取 block_timestamp。
- watcher: 单地址取到账,按"金额==expect && block_ts>建单"匹配 → paid;不匹配的到账 → orphan;幂等。
- httpapi: POST /order 加 user_ref,同用户重复 → 409;main 收款地址=PAY_RECEIVE_ADDRESS 或 xpub index0。
- 测试:唯一金额/同地址、同用户单订单、精确匹配、付错成孤儿、迟到不误配新单、付款早于建单不匹配、
  超时、幂等、409,全绿。README 更新为单地址模型+API(user_ref/精确金额/orphan)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-09 15:15:38 +08:00
parent 543a54c606
commit c5949a595a
11 changed files with 535 additions and 240 deletions
+69 -36
View File
@@ -1,5 +1,10 @@
// Package store persists pay orders + the HD address-derivation cursor in
// SQLite (pure-Go modernc driver, no CGO — same choice as the control plane).
// Package store persists pay orders + orphan payments in SQLite (pure-Go
// modernc driver, no CGO — same choice as the control plane).
//
// Model: single fixed receiving address + a unique amount per order. Orders are
// matched by (amount == expect_amount) and (payment block time > order created),
// so a payment can never be misattributed to a later order that happens to share
// the same address.
package store
import (
@@ -19,13 +24,14 @@ const (
StatusExpired Status = "expired"
)
// Order is one payment request. Amounts are in micro-USDT (1e-6), matching the
// raw integer value of a TRC20 USDT transfer (USDT has 6 decimals).
// Order is one payment request. Amounts are micro-USDT (1e-6), matching the raw
// integer value of a TRC20 USDT transfer (USDT has 6 decimals). ExpectAmount is
// the *unique* amount (base price + a small unique tail).
type Order struct {
OrderNo string
UserRef string
SKU string
ExpectAmount int64
AddrIndex uint32
Address string
Status Status
TxID string
@@ -42,7 +48,7 @@ func Open(dsn string) (*Store, error) {
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // SQLite: serialize writers, avoid "database is locked"
db.SetMaxOpenConns(1) // SQLite: serialize writers
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
@@ -57,9 +63,9 @@ func (s *Store) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS pay_orders(
order_no TEXT PRIMARY KEY,
user_ref TEXT NOT NULL,
sku TEXT NOT NULL,
expect_amount INTEGER NOT NULL,
addr_index INTEGER NOT NULL,
address TEXT NOT NULL,
status TEXT NOT NULL,
tx_id TEXT NOT NULL DEFAULT '',
@@ -67,8 +73,16 @@ func (s *Store) migrate() error {
expires_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_orders_status ON pay_orders(status)`,
`CREATE TABLE IF NOT EXISTS addr_cursor(id INTEGER PRIMARY KEY CHECK(id=1), next_index INTEGER NOT NULL)`,
`INSERT OR IGNORE INTO addr_cursor(id, next_index) VALUES(1, 0)`,
`CREATE INDEX IF NOT EXISTS idx_orders_amount_created ON pay_orders(expect_amount, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_orders_user_status ON pay_orders(user_ref, status)`,
`CREATE TABLE IF NOT EXISTS orphan_payments(
tx_id TEXT PRIMARY KEY,
address TEXT NOT NULL,
value INTEGER NOT NULL,
block_ts INTEGER NOT NULL,
created_at INTEGER NOT NULL,
handled INTEGER NOT NULL DEFAULT 0
)`,
}
for _, q := range stmts {
if _, err := s.db.Exec(q); err != nil {
@@ -78,41 +92,20 @@ func (s *Store) migrate() error {
return nil
}
// NextAddrIndex atomically returns the current HD index and advances the cursor.
// Addresses are never reused (avoids an old payment landing on a recycled slot).
func (s *Store) NextAddrIndex(ctx context.Context) (uint32, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
var idx uint32
if err := tx.QueryRowContext(ctx, `SELECT next_index FROM addr_cursor WHERE id=1`).Scan(&idx); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `UPDATE addr_cursor SET next_index=? WHERE id=1`, idx+1); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return idx, nil
}
func (s *Store) CreateOrder(ctx context.Context, o *Order) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO pay_orders(order_no,sku,expect_amount,addr_index,address,status,created_at,expires_at)
`INSERT INTO pay_orders(order_no,user_ref,sku,expect_amount,address,status,created_at,expires_at)
VALUES(?,?,?,?,?,?,?,?)`,
o.OrderNo, o.SKU, o.ExpectAmount, o.AddrIndex, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
o.OrderNo, o.UserRef, o.SKU, o.ExpectAmount, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
return err
}
const cols = `order_no,sku,expect_amount,addr_index,address,status,tx_id,created_at,expires_at`
const cols = `order_no,user_ref,sku,expect_amount,address,status,tx_id,created_at,expires_at`
func scanOrder(sc interface{ Scan(...any) error }) (*Order, error) {
o := &Order{}
var created, expires int64
if err := sc.Scan(&o.OrderNo, &o.SKU, &o.ExpectAmount, &o.AddrIndex, &o.Address, &o.Status, &o.TxID, &created, &expires); err != nil {
if err := sc.Scan(&o.OrderNo, &o.UserRef, &o.SKU, &o.ExpectAmount, &o.Address, &o.Status, &o.TxID, &created, &expires); err != nil {
return nil, err
}
o.CreatedAt = time.Unix(created, 0)
@@ -146,8 +139,29 @@ func (s *Store) ListPending(ctx context.Context) ([]*Order, error) {
return out, rows.Err()
}
// MarkPaid transitions pending->paid, idempotently (only affects a still-pending
// row). Returns true if this call was the one that flipped it.
// ActiveOrderByUser returns the user's pending order, or (nil, ErrNotFound) if
// none — used to enforce "one active order per user".
func (s *Store) ActiveOrderByUser(ctx context.Context, userRef string) (*Order, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE user_ref=? AND status=? LIMIT 1`, userRef, StatusPending)
o, err := scanOrder(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return o, err
}
// AmountRecentlyUsed reports whether any order with this expect_amount was
// created at/after sinceUnix — used to keep the unique amount collision-free
// within the late-payment window (so a stale payment can't match a new order).
func (s *Store) AmountRecentlyUsed(ctx context.Context, amount, sinceUnix int64) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pay_orders WHERE expect_amount=? AND created_at>=?`, amount, sinceUnix).Scan(&n)
return n > 0, err
}
// MarkPaid transitions pending->paid idempotently (only affects a still-pending
// row). Returns true if this call flipped it.
func (s *Store) MarkPaid(ctx context.Context, orderNo, txID string) (bool, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE pay_orders SET status=?, tx_id=? WHERE order_no=? AND status=?`,
@@ -170,3 +184,22 @@ func (s *Store) MarkExpired(ctx context.Context, now time.Time) (int64, error) {
n, _ := res.RowsAffected()
return n, nil
}
// TxHandled reports whether a tx id has already been consumed — either matched
// to an order (pay_orders.tx_id) or recorded as an orphan. Guards idempotency.
func (s *Store) TxHandled(ctx context.Context, txID string) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT (SELECT COUNT(*) FROM pay_orders WHERE tx_id=?) + (SELECT COUNT(*) FROM orphan_payments WHERE tx_id=?)`,
txID, txID).Scan(&n)
return n > 0, err
}
// RecordOrphan stores a payment that matched no active order (wrong amount / late
// after the address was reused). Idempotent on tx_id. Needs manual reconciliation.
func (s *Store) RecordOrphan(ctx context.Context, txID, address string, value, blockTs int64, now time.Time) error {
_, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO orphan_payments(tx_id,address,value,block_ts,created_at) VALUES(?,?,?,?,?)`,
txID, address, value, blockTs, now.Unix())
return err
}
+81 -38
View File
@@ -16,17 +16,10 @@ func openMem(t *testing.T) *Store {
return s
}
func TestNextAddrIndexMonotonic(t *testing.T) {
s := openMem(t)
ctx := context.Background()
for want := uint32(0); want < 5; want++ {
got, err := s.NextAddrIndex(ctx)
if err != nil {
t.Fatalf("next: %v", err)
}
if got != want {
t.Fatalf("index got %d want %d", got, want)
}
func mkOrder(no, user string, amount int64, addr string, now time.Time) *Order {
return &Order{
OrderNo: no, UserRef: user, SKU: "pro", ExpectAmount: amount, Address: addr,
Status: StatusPending, CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
}
}
@@ -34,33 +27,88 @@ func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
o := &Order{
OrderNo: "PAY1", SKU: "pro-year", ExpectAmount: 5_000000, AddrIndex: 0,
Address: "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", Status: StatusPending,
CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
}
if err := s.CreateOrder(ctx, o); err != nil {
if err := s.CreateOrder(ctx, mkOrder("PAY1", "u1", 5_000017, "TADDR", now)); err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.GetOrder(ctx, "PAY1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.SKU != "pro-year" || got.ExpectAmount != 5_000000 || got.Status != StatusPending {
t.Fatalf("roundtrip mismatch: %+v", got)
if got.UserRef != "u1" || got.ExpectAmount != 5_000017 || got.Status != StatusPending {
t.Fatalf("roundtrip: %+v", got)
}
ok, err := s.MarkPaid(ctx, "PAY1", "tx-abc")
ok, err := s.MarkPaid(ctx, "PAY1", "tx-a")
if err != nil || !ok {
t.Fatalf("first MarkPaid ok=%v err=%v (want true,nil)", ok, err)
t.Fatalf("first MarkPaid ok=%v err=%v", ok, err)
}
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-dup")
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-b")
if err != nil || ok2 {
t.Fatalf("second MarkPaid ok=%v err=%v (want false,nil — idempotent)", ok2, err)
t.Fatalf("second MarkPaid ok=%v err=%v (want false)", ok2, err)
}
got, _ = s.GetOrder(ctx, "PAY1")
if got.Status != StatusPaid || got.TxID != "tx-abc" {
t.Fatalf("after paid: status=%s tx=%s (want paid,tx-abc)", got.Status, got.TxID)
if got.Status != StatusPaid || got.TxID != "tx-a" {
t.Fatalf("after paid: %s / %s", got.Status, got.TxID)
}
}
func TestActiveOrderByUser(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 100, "T", now))
o, err := s.ActiveOrderByUser(ctx, "u1")
if err != nil || o.OrderNo != "PAY1" {
t.Fatalf("u1 active: %v %v", o, err)
}
if _, err := s.ActiveOrderByUser(ctx, "u2"); err != ErrNotFound {
t.Fatalf("u2 want ErrNotFound, got %v", err)
}
_, _ = s.MarkPaid(ctx, "PAY1", "tx")
if _, err := s.ActiveOrderByUser(ctx, "u1"); err != ErrNotFound {
t.Fatalf("paid should not be active: %v", err)
}
}
func TestAmountRecentlyUsed(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 5_000017, "T", now))
since := now.Add(-30 * time.Minute).Unix()
if used, _ := s.AmountRecentlyUsed(ctx, 5_000017, since); !used {
t.Fatal("5_000017 should be recently used")
}
if used, _ := s.AmountRecentlyUsed(ctx, 5_000018, since); used {
t.Fatal("5_000018 not used")
}
if used, _ := s.AmountRecentlyUsed(ctx, 5_000017, now.Add(time.Minute).Unix()); used {
t.Fatal("outside window should be false")
}
}
func TestTxHandledAndOrphan(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 100, "T", now))
if h, _ := s.TxHandled(ctx, "tx-x"); h {
t.Fatal("tx-x should be unhandled")
}
_, _ = s.MarkPaid(ctx, "PAY1", "tx-x")
if h, _ := s.TxHandled(ctx, "tx-x"); !h {
t.Fatal("matched tx should be handled")
}
if err := s.RecordOrphan(ctx, "tx-o", "T", 999, now.Unix(), now); err != nil {
t.Fatalf("orphan: %v", err)
}
if h, _ := s.TxHandled(ctx, "tx-o"); !h {
t.Fatal("orphan tx should be handled")
}
if err := s.RecordOrphan(ctx, "tx-o", "T", 999, now.Unix(), now); err != nil {
t.Fatalf("orphan idempotent: %v", err)
}
}
@@ -68,21 +116,16 @@ func TestMarkExpired(t *testing.T) {
s := openMem(t)
ctx := context.Background()
base := time.Unix(1_700_000_000, 0)
past := &Order{OrderNo: "old", SKU: "x", ExpectAmount: 1, Address: "T1", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Minute)}
future := &Order{OrderNo: "new", SKU: "x", ExpectAmount: 1, Address: "T2", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Hour)}
_ = s.CreateOrder(ctx, past)
_ = s.CreateOrder(ctx, future)
_ = s.CreateOrder(ctx, &Order{OrderNo: "old", UserRef: "u1", SKU: "x", ExpectAmount: 1, Address: "T", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(time.Minute)})
_ = s.CreateOrder(ctx, &Order{OrderNo: "new", UserRef: "u2", SKU: "x", ExpectAmount: 2, Address: "T", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(time.Hour)})
n, err := s.MarkExpired(ctx, base.Add(10*time.Minute))
if err != nil || n != 1 {
t.Fatalf("MarkExpired n=%d err=%v (want 1)", n, err)
t.Fatalf("MarkExpired n=%d err=%v", n, err)
}
oldO, _ := s.GetOrder(ctx, "old")
newO, _ := s.GetOrder(ctx, "new")
if oldO.Status != StatusExpired {
t.Fatalf("old should be expired, got %s", oldO.Status)
}
if newO.Status != StatusPending {
t.Fatalf("new should still be pending, got %s", newO.Status)
o1, _ := s.GetOrder(ctx, "old")
o2, _ := s.GetOrder(ctx, "new")
if o1.Status != StatusExpired || o2.Status != StatusPending {
t.Fatalf("old=%s new=%s", o1.Status, o2.Status)
}
}