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:
+69
-36
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user