c5949a595a
从"每单唯一 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>
132 lines
4.0 KiB
Go
132 lines
4.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func openMem(t *testing.T) *Store {
|
|
t.Helper()
|
|
s, err := Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
|
|
s := openMem(t)
|
|
ctx := context.Background()
|
|
now := time.Unix(1_700_000_000, 0)
|
|
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.UserRef != "u1" || got.ExpectAmount != 5_000017 || got.Status != StatusPending {
|
|
t.Fatalf("roundtrip: %+v", got)
|
|
}
|
|
ok, err := s.MarkPaid(ctx, "PAY1", "tx-a")
|
|
if err != nil || !ok {
|
|
t.Fatalf("first MarkPaid ok=%v err=%v", ok, err)
|
|
}
|
|
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-b")
|
|
if err != nil || ok2 {
|
|
t.Fatalf("second MarkPaid ok=%v err=%v (want false)", ok2, err)
|
|
}
|
|
got, _ = s.GetOrder(ctx, "PAY1")
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestMarkExpired(t *testing.T) {
|
|
s := openMem(t)
|
|
ctx := context.Background()
|
|
base := time.Unix(1_700_000_000, 0)
|
|
_ = 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", n, err)
|
|
}
|
|
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)
|
|
}
|
|
}
|