feat(server/pay): 订单列表端点 + 分币种展示价 + 富字段详情
- GET /v1/pay/orders 订单列表(Store.ListByUser,本地台账倒序) - catalog 增 PriceUsdtMicro(USDT 一口价展示) + SettlementCurrency/DisplayAmountMinor - 下单按支付方式落展示金额/结算币种(crypto→USDT / 法币→CNY),webhook 覆盖实际 - GetOrder 详情回带 sku/plan/method/amount/currency/created_at/paid_at - 新增 List 越权/倒序/富字段 + 分币种展示金额 + 详情富字段测试 - 修 3 个 pre-existing 迁移测试(renumber 后 21→23 遗留) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -452,6 +452,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
protected.Get("/notices", accountAPI.ListNotices)
|
||||
if payHandler != nil {
|
||||
protected.Get("/pay/catalog", payHandler.Catalog)
|
||||
protected.Get("/pay/orders", payHandler.List)
|
||||
protected.Post("/pay/orders", payHandler.CreateOrder)
|
||||
protected.Get("/pay/orders/{orderNo}", payHandler.GetOrder)
|
||||
protected.Post("/pay/orders/{orderNo}/retry", payHandler.Retry)
|
||||
|
||||
@@ -3,21 +3,25 @@ package pay
|
||||
import "github.com/wangjia/pangolin/server/internal/codes"
|
||||
|
||||
// CatalogItem 是可购档位。SKU 与 pay 侧 products.biz_code 一一对应
|
||||
// (webhook product_biz_code 原样回带);PriceMinor 仅展示(CNY 分),
|
||||
// 实际扣款以 pay 侧 ProductPrice/price 为准——一致性列入联调 checklist。
|
||||
// (webhook product_biz_code 原样回带);价格字段**仅展示**(实际扣款以 pay 侧
|
||||
// per-currency ProductPrice 为准——一致性列入联调 checklist):
|
||||
// - PriceMinor:CNY 分(支付宝/哪吒等法币渠道)
|
||||
// - PriceUsdtMicro:USDT 微元(6 位精度,加密货币渠道)
|
||||
type CatalogItem struct {
|
||||
SKU string `json:"sku"`
|
||||
Plan string `json:"plan"`
|
||||
Days int `json:"days"`
|
||||
PriceMinor int64 `json:"price_minor"`
|
||||
Currency string `json:"currency"`
|
||||
SKU string `json:"sku"`
|
||||
Plan string `json:"plan"`
|
||||
Days int `json:"days"`
|
||||
PriceMinor int64 `json:"price_minor"` // CNY 分(展示)
|
||||
Currency string `json:"currency"` // CNY
|
||||
PriceUsdtMicro int64 `json:"price_usdt_micro"` // USDT 微元(展示;各币种一口价、非汇率换算)
|
||||
}
|
||||
|
||||
// Catalog 三档单源。时长取宽松口径(31/92/366 覆盖大月与最长季)。
|
||||
// USDT 展示价与 pay 侧 products 一口价对齐($4.99/$12.99/$34.99),不做实时汇率换算。
|
||||
var Catalog = []CatalogItem{
|
||||
{SKU: "pro_month", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 2999, Currency: "CNY"},
|
||||
{SKU: "pro_quarter", Plan: string(codes.PlanPro), Days: 92, PriceMinor: 6888, Currency: "CNY"},
|
||||
{SKU: "pro_year", Plan: string(codes.PlanPro), Days: 366, PriceMinor: 19999, Currency: "CNY"},
|
||||
{SKU: "pro_month", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 2999, Currency: "CNY", PriceUsdtMicro: 4990000},
|
||||
{SKU: "pro_quarter", Plan: string(codes.PlanPro), Days: 92, PriceMinor: 6888, Currency: "CNY", PriceUsdtMicro: 12990000},
|
||||
{SKU: "pro_year", Plan: string(codes.PlanPro), Days: 366, PriceMinor: 19999, Currency: "CNY", PriceUsdtMicro: 34990000},
|
||||
}
|
||||
|
||||
func CatalogBySKU(sku string) (CatalogItem, bool) {
|
||||
@@ -28,3 +32,21 @@ func CatalogBySKU(sku string) (CatalogItem, bool) {
|
||||
}
|
||||
return CatalogItem{}, false
|
||||
}
|
||||
|
||||
// SettlementCurrency 按支付方式给出结算币种(展示/落台账用;实际以 pay 侧为准)。
|
||||
// crypto→USDT;其余法币渠道(alipay/nezha/…)→CNY。
|
||||
func SettlementCurrency(method string) string {
|
||||
if method == "crypto" {
|
||||
return "USDT"
|
||||
}
|
||||
return "CNY"
|
||||
}
|
||||
|
||||
// DisplayAmountMinor 按支付方式给出该档「展示金额」(下单时预估、落台账,
|
||||
// webhook 到账后由实际结算金额覆盖)。crypto→USDT 微元;法币→CNY 分。
|
||||
func DisplayAmountMinor(it CatalogItem, method string) int64 {
|
||||
if method == "crypto" {
|
||||
return it.PriceUsdtMicro
|
||||
}
|
||||
return it.PriceMinor
|
||||
}
|
||||
|
||||
@@ -109,7 +109,8 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := CatalogBySKU(req.SKU); !ok || req.Method == "" {
|
||||
item, ok := CatalogBySKU(req.SKU)
|
||||
if !ok || req.Method == "" {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -129,17 +130,73 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
|
||||
writePayErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method); err != nil {
|
||||
// 下单即落展示金额/结算币种(按支付方式);webhook 到账后由实际结算金额覆盖。
|
||||
amount := DisplayAmountMinor(item, req.Method)
|
||||
currency := SettlementCurrency(req.Method)
|
||||
if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method, amount, currency); err != nil {
|
||||
// 不吞单:webhook 会按 biz_ref 兜底补台账,这里记日志便于追查。
|
||||
slog.Error("pay: 台账写入失败(webhook 将按 biz_ref 兜底)", "order_no", res.OrderNo, "err", err)
|
||||
}
|
||||
writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session})
|
||||
}
|
||||
|
||||
// ─── GET /v1/pay/orders (列表) ──────────────────────────────────────────────
|
||||
|
||||
// orderView 是订单的台账视图(列表/详情共用富字段;金额为结算币种 minor:CNY 分 / USDT 微元)。
|
||||
type orderView struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
SKU string `json:"sku"`
|
||||
Plan string `json:"plan"`
|
||||
Days int `json:"days"`
|
||||
Method string `json:"method"`
|
||||
Status string `json:"status"` // 本地台账:created | paid | canceled
|
||||
AmountMinor int64 `json:"amount_minor"`
|
||||
Currency string `json:"currency"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
CreatedAt string `json:"created_at"` // RFC3339
|
||||
PaidAt *string `json:"paid_at,omitempty"`
|
||||
}
|
||||
|
||||
func orderViewFromRow(row *PurchaseRow) orderView {
|
||||
v := orderView{
|
||||
OrderNo: row.OutTradeNo, SKU: row.SKU, Method: row.Method, Status: row.Status,
|
||||
AmountMinor: row.AmountMinor, Currency: row.Currency, Channel: row.Channel,
|
||||
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if it, ok := CatalogBySKU(row.SKU); ok {
|
||||
v.Plan, v.Days = it.Plan, it.Days
|
||||
}
|
||||
if row.PaidAt.Valid {
|
||||
s := row.PaidAt.Time.UTC().Format(time.RFC3339)
|
||||
v.PaidAt = &s
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// List 列出当前用户历史订单(倒序;纯本地台账,不逐单回查 pay 上游)。
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
uid, ok := auth.UserIDFromContext(ctx)
|
||||
if !ok {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
rows, err := h.store.ListByUser(ctx, uid, 100)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
out := make([]orderView, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, orderViewFromRow(&rows[i]))
|
||||
}
|
||||
writeJSON(w, map[string]any{"orders": out})
|
||||
}
|
||||
|
||||
// ─── GET /v1/pay/orders/{orderNo} ───────────────────────────────────────────
|
||||
|
||||
type orderStatusResponse struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
orderView
|
||||
PayStatus string `json:"pay_status"` // pay 侧状态词汇原样透传
|
||||
Activated bool `json:"activated"` // 本地台账已消费(权益已开通)——客户端轮询以此为成功判据
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
@@ -166,7 +223,7 @@ func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
|
||||
writePayErr(w, err)
|
||||
return
|
||||
}
|
||||
resp := orderStatusResponse{OrderNo: orderNo, PayStatus: st.Status, Activated: row.Status == "paid"}
|
||||
resp := orderStatusResponse{orderView: orderViewFromRow(row), PayStatus: st.Status, Activated: row.Status == "paid"}
|
||||
if row.SubID.Valid {
|
||||
if exp, err := h.store.SubscriptionExpiry(ctx, row.SubID.Int64); err == nil {
|
||||
s := exp.UTC().Format(time.RFC3339)
|
||||
|
||||
@@ -27,6 +27,7 @@ func newHandlerRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store) {
|
||||
h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/pay/catalog", h.Catalog)
|
||||
r.Get("/v1/pay/orders", h.List)
|
||||
r.Post("/v1/pay/orders", h.CreateOrder)
|
||||
r.Get("/v1/pay/orders/{orderNo}", h.GetOrder)
|
||||
r.Post("/v1/pay/orders/{orderNo}/retry", h.Retry)
|
||||
@@ -86,7 +87,7 @@ func TestGetOrder_OwnershipEnforced(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending",
|
||||
"subject":"s","amount_minor":2999,"currency":"CNY"}}`))
|
||||
})
|
||||
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay"); err != nil {
|
||||
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 属主可查
|
||||
@@ -116,7 +117,7 @@ func TestRetry_CurrencyMismatchMapped409(t *testing.T) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`))
|
||||
})
|
||||
_ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto")
|
||||
_ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT")
|
||||
body := []byte(`{"method":"alipay"}`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders/pay001/retry", bytes.NewReader(body)), 1))
|
||||
@@ -132,6 +133,118 @@ func TestRetry_CurrencyMismatchMapped409(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 下单时按支付方式落「展示金额 + 结算币种」(crypto→USDT 微元 / 法币→CNY 分)。
|
||||
// 单 rig(rig 内 DB 复用),mock 按转发来的 method 回不同 order_no。
|
||||
func TestCreateOrder_StoresDisplayAmountByMethod(t *testing.T) {
|
||||
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
var fwd struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&fwd)
|
||||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay-` + fwd.Method + `","session":{
|
||||
"render_type":"redirect","payload":{"url":"https://x"}}}}`))
|
||||
})
|
||||
cases := []struct {
|
||||
method string
|
||||
wantAmt int64
|
||||
wantCurr string
|
||||
}{
|
||||
{"crypto", 4990000, "USDT"}, // pro_month USDT 微元
|
||||
{"alipay", 2999, "CNY"}, // pro_month CNY 分
|
||||
}
|
||||
for _, tc := range cases {
|
||||
body := []byte(`{"sku":"pro_month","method":"` + tc.method + `"}`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s: code = %d, body = %s", tc.method, w.Code, w.Body)
|
||||
}
|
||||
row, err := st.GetForUser(context.Background(), 1, "pay-"+tc.method)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 台账未落: %v", tc.method, err)
|
||||
}
|
||||
if row.AmountMinor != tc.wantAmt || row.Currency != tc.wantCurr {
|
||||
t.Fatalf("%s: amount=%d curr=%q, want %d/%q", tc.method, row.AmountMinor, row.Currency, tc.wantAmt, tc.wantCurr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 订单列表:只列本人、倒序、含台账富字段。
|
||||
func TestList_OwnershipAndFields(t *testing.T) {
|
||||
router, st := newHandlerRig(t, nil)
|
||||
ctx := context.Background()
|
||||
// user 1 两单(先月后年),user 2 一单(不该出现在 user 1 列表)
|
||||
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "o1", "alipay", 2999, "CNY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Insert(ctx, 1, "uuid-1", "pro_year", "o2", "crypto", 34990000, "USDT"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Insert(ctx, 2, "uuid-2", "pro_month", "o3", "alipay", 2999, "CNY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, body = %s", w.Code, w.Body)
|
||||
}
|
||||
var resp struct {
|
||||
Orders []orderView `json:"orders"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Orders) != 2 {
|
||||
t.Fatalf("want 2 orders for user 1, got %d: %+v", len(resp.Orders), resp.Orders)
|
||||
}
|
||||
for _, o := range resp.Orders {
|
||||
if o.OrderNo == "o3" {
|
||||
t.Fatalf("越权:看到了他人订单 o3")
|
||||
}
|
||||
}
|
||||
// 倒序:o2(pro_year, 后插)在前,且富字段完整
|
||||
first := resp.Orders[0]
|
||||
if first.OrderNo != "o2" || first.SKU != "pro_year" || first.Plan != "pro" ||
|
||||
first.Method != "crypto" || first.AmountMinor != 34990000 || first.Currency != "USDT" ||
|
||||
first.Days != 366 || first.Status != "created" || first.CreatedAt == "" {
|
||||
t.Fatalf("首单富字段不符: %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// 订单详情:回带台账富字段 + pay_status/activated。
|
||||
func TestGetOrder_EnrichedFields(t *testing.T) {
|
||||
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending","currency":"USDT"}}`))
|
||||
})
|
||||
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_year", "pay001", "crypto", 34990000, "USDT"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 1))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
SKU string `json:"sku"`
|
||||
Plan string `json:"plan"`
|
||||
Method string `json:"method"`
|
||||
AmountMinor int64 `json:"amount_minor"`
|
||||
Currency string `json:"currency"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PayStatus string `json:"pay_status"`
|
||||
Activated bool `json:"activated"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.SKU != "pro_year" || resp.Plan != "pro" || resp.Method != "crypto" ||
|
||||
resp.AmountMinor != 34990000 || resp.Currency != "USDT" || resp.CreatedAt == "" ||
|
||||
resp.PayStatus != "pending" || resp.Activated {
|
||||
t.Fatalf("详情富字段不符: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalog(t *testing.T) {
|
||||
router, _ := newHandlerRig(t, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -23,6 +23,7 @@ type PurchaseRow struct {
|
||||
Channel string
|
||||
SubID sql.NullInt64
|
||||
PaidAt sql.NullTime
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -38,13 +39,15 @@ func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) {
|
||||
return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
}
|
||||
|
||||
// Insert 下单成功后落台账(status=created)。
|
||||
func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeNo, method string) error {
|
||||
// Insert 下单成功后落台账(status=created)。amountMinor/currency 是下单时的
|
||||
// **展示预估**(按支付方式的结算币种,见 DisplayAmountMinor),webhook 到账后由
|
||||
// MarkPaidTx 用实际结算金额覆盖——保证订单列表/详情在支付前也有金额可显。
|
||||
func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeNo, method string, amountMinor int64, currency string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'created', ?, ?)`,
|
||||
userID, bizRef, sku, outTradeNo, method, now, now)
|
||||
`INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?)`,
|
||||
userID, bizRef, sku, outTradeNo, method, amountMinor, currency, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pay.Store.Insert: %w", err)
|
||||
}
|
||||
@@ -52,17 +55,42 @@ func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeN
|
||||
}
|
||||
|
||||
const purchaseCols = `id, user_id, biz_ref, sku, out_trade_no, method, status,
|
||||
amount_minor, currency, channel, sub_id, paid_at`
|
||||
amount_minor, currency, channel, sub_id, paid_at, created_at`
|
||||
|
||||
func scanPurchase(row *sql.Row) (*PurchaseRow, error) {
|
||||
var p PurchaseRow
|
||||
if err := row.Scan(&p.ID, &p.UserID, &p.BizRef, &p.SKU, &p.OutTradeNo, &p.Method,
|
||||
&p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt); err != nil {
|
||||
&p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListByUser 按用户列历史订单(created_at 倒序,复用 idx_pay_user 索引)。
|
||||
// 供「订单列表」页;limit 上限保护。
|
||||
func (s *Store) ListByUser(ctx context.Context, userID int64, limit int) ([]PurchaseRow, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+purchaseCols+` FROM pay_purchases WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`,
|
||||
userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pay.Store.ListByUser: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PurchaseRow
|
||||
for rows.Next() {
|
||||
var p PurchaseRow
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.BizRef, &p.SKU, &p.OutTradeNo, &p.Method,
|
||||
&p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt, &p.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("pay.Store.ListByUser scan: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetForUser 按 (userID, outTradeNo) 取行——所有权校验由查询本身完成。
|
||||
func (s *Store) GetForUser(ctx context.Context, userID int64, outTradeNo string) (*PurchaseRow, error) {
|
||||
return scanPurchase(s.db.QueryRowContext(ctx,
|
||||
|
||||
@@ -60,7 +60,7 @@ func succeededPayload(orderNo, sku string) map[string]any {
|
||||
func TestWebhook_HappyPath(t *testing.T) {
|
||||
h, db, st := newWebhookRig(t)
|
||||
ctx := context.Background()
|
||||
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto"); err != nil {
|
||||
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := deliver(t, h, succeededPayload("pay001", "pro_month"))
|
||||
@@ -92,7 +92,7 @@ func TestWebhook_HappyPath(t *testing.T) {
|
||||
func TestWebhook_RedeliveryIdempotent(t *testing.T) {
|
||||
h, db, st := newWebhookRig(t)
|
||||
ctx := context.Background()
|
||||
_ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto")
|
||||
_ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT")
|
||||
deliver(t, h, succeededPayload("pay001", "pro_month"))
|
||||
var exp1 time.Time
|
||||
_ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp1)
|
||||
@@ -117,8 +117,8 @@ func TestWebhook_RedeliveryIdempotent(t *testing.T) {
|
||||
func TestWebhook_TwoOrdersStack(t *testing.T) {
|
||||
h, db, st := newWebhookRig(t)
|
||||
ctx := context.Background()
|
||||
_ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto")
|
||||
_ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto")
|
||||
_ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT")
|
||||
_ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto", 12990000, "USDT")
|
||||
deliver(t, h, succeededPayload("pay001", "pro_month"))
|
||||
deliver(t, h, succeededPayload("pay002", "pro_quarter"))
|
||||
var n int
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
// migratorAt builds a golang-migrate instance against a file-backed SQLite DB
|
||||
// and steps it to exactly `version` (unlike store.MigrateUp/Down, which always
|
||||
// target head/0). This is Task 8 Step 2's upgrade rehearsal: a real file DB
|
||||
// (not :memory:) simulating a production sqlite store carrying pre-000021 rows
|
||||
// through the 000021 subscriptions-table rebuild.
|
||||
// (not :memory:) simulating a production sqlite store carrying pre-000023 rows
|
||||
// through the 000023 subscriptions-table rebuild.
|
||||
func migratorAt(t *testing.T, database *sql.DB, version uint) {
|
||||
t.Helper()
|
||||
src, err := iofs.New(migrations.SQLiteFS, "sqlite")
|
||||
@@ -44,7 +44,7 @@ func migratorAt(t *testing.T, database *sql.DB, version uint) {
|
||||
}
|
||||
}
|
||||
|
||||
// insertLegacySubRow inserts one subscriptions row with a pre-000021 source
|
||||
// insertLegacySubRow inserts one subscriptions row with a pre-000023 source
|
||||
// value ('trial' or 'code') for a fresh user, returning the assigned user_id
|
||||
// and subscription id.
|
||||
func insertLegacySubRow(t *testing.T, database *sql.DB, uuidSuffix, source string) (userID, subID int64) {
|
||||
@@ -82,7 +82,7 @@ func insertLegacySubRow(t *testing.T, database *sql.DB, uuidSuffix, source strin
|
||||
}
|
||||
|
||||
// TestSQLitePayMigrationRehearsal_UpgradeWithData is Task 8 Step 2: rehearse
|
||||
// the 000021 upgrade (subscriptions table rebuild for source='pay') against a
|
||||
// the 000023 upgrade (subscriptions table rebuild for source='pay') against a
|
||||
// file-backed SQLite DB pre-loaded with real 'trial'/'code' rows, and assert
|
||||
// no rows are lost and the id/AUTOINCREMENT sequence is preserved.
|
||||
func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) {
|
||||
@@ -101,15 +101,15 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) {
|
||||
_ = trialUserID
|
||||
_ = codeUserID
|
||||
|
||||
// 2. Up to 000021 — subscriptions_new rebuild + pay_purchases creation.
|
||||
migratorAt(t, database, 21)
|
||||
// 2. Up to 000023 — subscriptions_new rebuild + pay_purchases creation.
|
||||
migratorAt(t, database, 23)
|
||||
|
||||
v, dirty, err := store.MigrateVersion(database, "sqlite")
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateVersion: %v", err)
|
||||
}
|
||||
if dirty || v != 21 {
|
||||
t.Fatalf("after up to 21: version=%d dirty=%v, want 21/false", v, dirty)
|
||||
if dirty || v != 23 {
|
||||
t.Fatalf("after up to 23: version=%d dirty=%v, want 23/false", v, dirty)
|
||||
}
|
||||
|
||||
// 3. Row count and ids preserved across the rebuild.
|
||||
@@ -118,7 +118,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) {
|
||||
t.Fatalf("count subscriptions: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("subscriptions count after 000021 = %d, want 2 (rows lost in rebuild)", count)
|
||||
t.Errorf("subscriptions count after 000023 = %d, want 2 (rows lost in rebuild)", count)
|
||||
}
|
||||
|
||||
for _, want := range []struct {
|
||||
@@ -127,7 +127,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) {
|
||||
}{{trialSubID, "trial"}, {codeSubID, "code"}} {
|
||||
var gotSource string
|
||||
if err := database.QueryRow(`SELECT source FROM subscriptions WHERE id = ?`, want.id).Scan(&gotSource); err != nil {
|
||||
t.Errorf("subscription id=%d missing after 000021: %v", want.id, err)
|
||||
t.Errorf("subscription id=%d missing after 000023: %v", want.id, err)
|
||||
continue
|
||||
}
|
||||
if gotSource != want.source {
|
||||
@@ -146,7 +146,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) {
|
||||
trialUserID, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert source='pay' subscription after 000021: %v", err)
|
||||
t.Fatalf("insert source='pay' subscription after 000023: %v", err)
|
||||
}
|
||||
paySubID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
@@ -156,10 +156,10 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) {
|
||||
t.Errorf("pay subscription id=%d did not continue AUTOINCREMENT sequence (prior max id=%d)", paySubID, codeSubID)
|
||||
}
|
||||
|
||||
// 5. pay_purchases table exists and is empty (fresh table from 000021).
|
||||
// 5. pay_purchases table exists and is empty (fresh table from 000023).
|
||||
var payCount int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM pay_purchases`).Scan(&payCount); err != nil {
|
||||
t.Fatalf("count pay_purchases (table should exist post-000021): %v", err)
|
||||
t.Fatalf("count pay_purchases (table should exist post-000023): %v", err)
|
||||
}
|
||||
if payCount != 0 {
|
||||
t.Errorf("pay_purchases count = %d, want 0 (fresh table)", payCount)
|
||||
@@ -167,11 +167,11 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSQLitePayMigrationRehearsal_DownUpIdempotent covers the second half of
|
||||
// Task 8 Step 2: with only pre-000021 ('trial'/'code') data present, down
|
||||
// (rollback 000021) then up (re-apply) must be idempotent and lossless.
|
||||
// Task 8 Step 2: with only pre-000023 ('trial'/'code') data present, down
|
||||
// (rollback 000023) then up (re-apply) must be idempotent and lossless.
|
||||
//
|
||||
// It also documents an intentional safety property: once a source='pay' row
|
||||
// exists, 000021's down.sql (which rebuilds subscriptions with the stricter
|
||||
// exists, 000023's down.sql (which rebuilds subscriptions with the stricter
|
||||
// CHECK (source IN ('trial','code'))) correctly REFUSES to downgrade rather
|
||||
// than silently dropping paid-subscription rows — see the trailing assertion.
|
||||
func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) {
|
||||
@@ -186,9 +186,9 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) {
|
||||
_, trialSubID := insertLegacySubRow(t, database, "trial2", "trial")
|
||||
_, codeSubID := insertLegacySubRow(t, database, "code2", "code")
|
||||
|
||||
migratorAt(t, database, 21)
|
||||
migratorAt(t, database, 23)
|
||||
|
||||
// down: 000021 -> 000020. No 'pay' rows exist yet, so this must succeed
|
||||
// down: 000023 -> 000020. No 'pay' rows exist yet, so this must succeed
|
||||
// and preserve the trial/code rows with their original ids.
|
||||
migratorAt(t, database, 20)
|
||||
|
||||
@@ -219,14 +219,14 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) {
|
||||
t.Errorf("pay_purchases table still present after down to 000020 (err=%v)", err)
|
||||
}
|
||||
|
||||
// up: 000020 -> 000021 again — idempotent re-apply, same rows/ids.
|
||||
migratorAt(t, database, 21)
|
||||
// up: 000020 -> 000023 again — idempotent re-apply, same rows/ids.
|
||||
migratorAt(t, database, 23)
|
||||
v, dirty, err = store.MigrateVersion(database, "sqlite")
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateVersion after re-up: %v", err)
|
||||
}
|
||||
if dirty || v != 21 {
|
||||
t.Fatalf("after re-up to 21: version=%d dirty=%v, want 21/false", v, dirty)
|
||||
if dirty || v != 23 {
|
||||
t.Fatalf("after re-up to 23: version=%d dirty=%v, want 23/false", v, dirty)
|
||||
}
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil {
|
||||
t.Fatalf("count subscriptions after re-up: %v", err)
|
||||
@@ -237,7 +237,7 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) {
|
||||
|
||||
// Safety-net documentation: once a source='pay' row exists, down must be
|
||||
// refused (CHECK (source IN ('trial','code')) on the down-rebuilt table),
|
||||
// not silently drop it. This is NOT a bug — see 000021.down.sql.
|
||||
// not silently drop it. This is NOT a bug — see 000023.down.sql.
|
||||
var planID int64
|
||||
if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil {
|
||||
t.Fatalf("lookup pro plan id: %v", err)
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
||||
if dirty {
|
||||
t.Fatalf("schema dirty after MigrateUp")
|
||||
}
|
||||
if v != 21 {
|
||||
t.Errorf("version = %d, want 21", v)
|
||||
if v != 23 {
|
||||
t.Errorf("version = %d, want 23", v)
|
||||
}
|
||||
|
||||
// 2. Core tables exist.
|
||||
|
||||
Reference in New Issue
Block a user