docs(pay): attempt 带 expires_at(单次超时不关 order)+ 订单取消接口

- 超时挂 attempt(哪吒/crypto 各自),超时只弃本 attempt、order 仍 pending 可换渠道;
  order.expires_at 是整体购买窗口。一单只一个 attempt 成功(MarkOrderPaid 守卫 pending)。
- 加 POST /orders/{no}/cancel(pending→canceled),canceled 单在订单列表可见。
- P1 计划同步:pay_attempts 加 expires_at 列 + CancelOrder 方法 + 取消/列表测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-09 23:26:39 +08:00
parent bf058077cd
commit 127f09faaa
3 changed files with 77 additions and 21 deletions
@@ -68,6 +68,7 @@ CREATE TABLE pay_attempts (
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('created','pending','paid','failed','expired')),
expires_at DATETIME NOT NULL, -- 本次尝试超时(哪吒 payurl / crypto 15min);超时只弃本 attempt
created_at DATETIME NOT NULL,
paid_at DATETIME NULL,
UNIQUE (provider, provider_ref),
@@ -151,6 +152,7 @@ CREATE TABLE pay_attempts (
amount_minor BIGINT NOT NULL,
currency VARCHAR(16) NOT NULL,
status ENUM('created','pending','paid','failed','expired') NOT NULL,
expires_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL,
paid_at DATETIME(6) NULL,
UNIQUE KEY uq_provider_ref (provider, provider_ref),
@@ -476,11 +478,12 @@ git commit -m "refactor(pay): codes 开通委托 subscription.Grant(行为不变
- Consumes: 表 `pay_orders`/`pay_attempts`(Task 1)。
- Produces:
- `type Order struct{ OrderNo string; UserID int64; UserUUID, SKU, PlanCode string; DurationDays int; AmountMinor int64; Currency, Status string; CreatedAt, ExpiresAt time.Time; PaidAt sql.NullTime; SubscriptionID sql.NullInt64 }`
- `type Attempt struct{ ID int64; OrderNo, Method, Provider, ProviderRef, RenderType string; AmountMinor int64; Currency, Status string; CreatedAt time.Time; PaidAt sql.NullTime }`
- `type Attempt struct{ ID int64; OrderNo, Method, Provider, ProviderRef, RenderType string; AmountMinor int64; Currency, Status string; ExpiresAt, CreatedAt time.Time; PaidAt sql.NullTime }`
- `type Store struct{ db *sql.DB }` · `NewStore(db) *Store` · `func (s *Store) DB() *sql.DB`
- `CreateOrder(ctx, Order) error` · `GetOrder(ctx, orderNo) (*Order, error)`(`ErrNotFound``ListOrders(ctx, userID int64, limit int, beforeID int64) ([]Order, error)`
- `CreateOrder(ctx, Order) error` · `GetOrder(ctx, orderNo) (*Order, error)`(`ErrNotFound``ListOrders(ctx, userID int64, limit int, beforeID int64) ([]Order, error)` —— 返回**全部状态**(含 canceled),供订单历史页。
- `CreateAttempt(ctx, Attempt) (int64, error)` · `AttemptByProviderRef(ctx, provider, ref string) (*Attempt, error)`
- `MarkOrderPaid(ctx, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error)` —— 事务内幂等:仅当 order.status='pending' 时置 paid + 回填 subscription_id + 对应 attempt 置 paid;已 paid 返回 `false,nil`
- `MarkOrderPaid(ctx, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error)` —— 事务内幂等:仅当 order.status='pending' 时置 paid + 回填 subscription_id + 对应 attempt 置 paid;非 pending(已 paid/取消/过期)返回 `false,nil`。**这是「一单只一个 attempt 成功」的守卫**
- `CancelOrder(ctx, orderNo string) (bool, error)` —— 仅当 order.status='pending' 时置 canceled,返回 true;非 pending 返回 `false,nil`
- `var ErrNotFound = errors.New("payorders: not found")`
- [ ] **Step 1: 写失败测试(sqlite 实库,建最小 schema)**
@@ -510,7 +513,7 @@ func openPay(t *testing.T) *sql.DB {
status TEXT, subscription_id INTEGER NULL, created_at DATETIME, paid_at DATETIME NULL, expires_at DATETIME)`,
`CREATE TABLE pay_attempts (id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT, method TEXT, provider TEXT,
provider_ref TEXT, render_type TEXT, amount_minor INTEGER, currency TEXT, status TEXT,
created_at DATETIME, paid_at DATETIME NULL, UNIQUE(provider, provider_ref))`,
expires_at DATETIME, created_at DATETIME, paid_at DATETIME NULL, UNIQUE(provider, provider_ref))`,
}
for _, s := range ddl {
if _, err := db.Exec(s); err != nil { t.Fatalf("ddl: %v", err) }
@@ -539,7 +542,7 @@ func TestCreateGetMarkPaidIdempotent(t *testing.T) {
if _, err := st.CreateAttempt(ctx, payorders.Attempt{
OrderNo: "PAY-1", Method: "usdt_trc20", Provider: "crypto", ProviderRef: "P-9",
RenderType: "display_details", AmountMinor: 29990000, Currency: "USDT",
Status: "pending", CreatedAt: now,
Status: "pending", ExpiresAt: now.Add(15 * time.Minute), CreatedAt: now,
}); err != nil { t.Fatalf("attempt: %v", err) }
tx, _ := db.Begin()
@@ -558,6 +561,38 @@ func TestCreateGetMarkPaidIdempotent(t *testing.T) {
t.Fatalf("final: %+v", final)
}
}
func TestCancelOrder(t *testing.T) {
db := openPay(t)
st := payorders.NewStore(db)
ctx := context.Background()
now := time.Now().UTC()
must := payorders.Order{
OrderNo: "PAY-C", UserID: 1, UserUUID: "u-1", SKU: "pro-month", PlanCode: "pro",
DurationDays: 30, AmountMinor: 3990000, Currency: "USDT", Status: "pending",
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}
if err := st.CreateOrder(ctx, must); err != nil { t.Fatalf("create: %v", err) }
ok, err := st.CancelOrder(ctx, "PAY-C")
if err != nil || !ok { t.Fatalf("cancel#1 ok=%v err=%v", ok, err) }
// 再取消 → false(非 pending)。
ok2, _ := st.CancelOrder(ctx, "PAY-C")
if ok2 { t.Fatalf("第二次 CancelOrder 应 false") }
got, _ := st.GetOrder(ctx, "PAY-C")
if got.Status != "canceled" { t.Fatalf("status = %s, 应 canceled", got.Status) }
// canceled 单仍在 ListOrders 出现。
list, _ := st.ListOrders(ctx, 1, 20, 0)
var seen bool
for _, o := range list {
if o.OrderNo == "PAY-C" { seen = true }
}
if !seen { t.Fatalf("canceled 单应出现在订单列表") }
}
```
- [ ] **Step 2: 跑测试确认失败**
@@ -609,6 +644,7 @@ type Attempt struct {
AmountMinor int64
Currency string
Status string
ExpiresAt time.Time
CreatedAt time.Time
PaidAt sql.NullTime
}
@@ -687,10 +723,10 @@ func (s *Store) ListOrders(ctx context.Context, userID int64, limit int, beforeI
func (s *Store) CreateAttempt(ctx context.Context, a Attempt) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO pay_attempts (order_no, method, provider, provider_ref, render_type,
amount_minor, currency, status, created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
amount_minor, currency, status, expires_at, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
a.OrderNo, a.Method, a.Provider, a.ProviderRef, a.RenderType,
a.AmountMinor, a.Currency, a.Status, a.CreatedAt.UTC())
a.AmountMinor, a.Currency, a.Status, a.ExpiresAt.UTC(), a.CreatedAt.UTC())
if err != nil {
return 0, fmt.Errorf("payorders.CreateAttempt: %w", err)
}
@@ -702,10 +738,10 @@ func (s *Store) AttemptByProviderRef(ctx context.Context, provider, ref string)
var a Attempt
err := s.db.QueryRowContext(ctx,
`SELECT id, order_no, method, provider, provider_ref, render_type, amount_minor,
currency, status, created_at, paid_at
currency, status, expires_at, created_at, paid_at
FROM pay_attempts WHERE provider=? AND provider_ref=?`, provider, ref).
Scan(&a.ID, &a.OrderNo, &a.Method, &a.Provider, &a.ProviderRef, &a.RenderType,
&a.AmountMinor, &a.Currency, &a.Status, &a.CreatedAt, &a.PaidAt)
&a.AmountMinor, &a.Currency, &a.Status, &a.ExpiresAt, &a.CreatedAt, &a.PaidAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
@@ -715,6 +751,18 @@ func (s *Store) AttemptByProviderRef(ctx context.Context, provider, ref string)
return &a, nil
}
// CancelOrder flips a pending order to canceled. Returns false if the order was
// not in 'pending' (already paid / canceled / expired).
func (s *Store) CancelOrder(ctx context.Context, orderNo string) (bool, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE pay_orders SET status='canceled' WHERE order_no=? AND status='pending'`, orderNo)
if err != nil {
return false, fmt.Errorf("payorders.CancelOrder: %w", err)
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// MarkOrderPaid flips a pending order to paid inside tx (idempotent). Returns
// false if the order was not in 'pending' (already handled / expired).
func (s *Store) MarkOrderPaid(ctx context.Context, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error) {
@@ -737,10 +785,10 @@ func (s *Store) MarkOrderPaid(ctx context.Context, tx *sql.Tx, orderNo, provider
}
```
- [ ] **Step 4: 跑测试确认通过**
- [ ] **Step 4: 跑测试确认通过(含取消/列表)**
Run: `cd server && go test ./internal/payorders/ -run TestCreateGetMarkPaidIdempotent -v`
Expected: PASS。
Run: `cd server && go test ./internal/payorders/ -v`
Expected: `TestCreateGetMarkPaidIdempotent``TestCancelOrder` PASS。
- [ ] **Step 5: 全量编译 + 测试**