From 01887dd5716555f438e5228d1e8ac806beb86398 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 08:14:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2):=20Order/Attempt/Account/Refund=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20+=20=E7=8A=B6=E6=80=81=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=20+=20:memory:=20=E6=B5=8B=E8=AF=95=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 OrderStatus 三个值(Pending/Paid/Refunded)与 v1 model.OrderStatus 撞名, 统一加 V2 后缀(OrderPendingV2 等)规避包内重名;AttemptStatus/RefundStatus 无此冲突,原样保留。 --- internal/model/testdb.go | 26 +++++++++ internal/model/v2.go | 110 ++++++++++++++++++++++++++++++++++++++ internal/model/v2_test.go | 35 ++++++++++++ main.go | 1 + 4 files changed, 172 insertions(+) create mode 100644 internal/model/testdb.go create mode 100644 internal/model/v2.go create mode 100644 internal/model/v2_test.go diff --git a/internal/model/testdb.go b/internal/model/testdb.go new file mode 100644 index 0000000..8e2d96c --- /dev/null +++ b/internal/model/testdb.go @@ -0,0 +1,26 @@ +package model + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// OpenTestDB opens an in-memory SQLite DB with the v2 schema migrated. +// Shared by all package tests (pay has no prior DB test harness — this is it). +func OpenTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), + &gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true}) + if err != nil { + t.Fatalf("open test db: %v", err) + } + if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}); err != nil { + t.Fatalf("migrate: %v", err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + return db +} diff --git a/internal/model/v2.go b/internal/model/v2.go new file mode 100644 index 0000000..1aa6547 --- /dev/null +++ b/internal/model/v2.go @@ -0,0 +1,110 @@ +package model + +import "time" + +// ---- 状态枚举 ---- + +type OrderStatusV2 string + +// 注:v1 model.OrderStatus 已占用 OrderPending/OrderPaid/OrderRefunded 三个标识符, +// 为免包内重名冲突,v2 状态枚举统一加 V2 后缀(与 AttemptStatus/RefundStatus 的独立命名空间不冲突,故那两组不加后缀)。 +const ( + OrderCreatedV2 OrderStatusV2 = "created" + OrderPendingV2 OrderStatusV2 = "pending" + OrderPaidV2 OrderStatusV2 = "paid" + OrderCanceledV2 OrderStatusV2 = "canceled" + OrderExpiredV2 OrderStatusV2 = "expired" + OrderRefundingV2 OrderStatusV2 = "refunding" + OrderPartRefundedV2 OrderStatusV2 = "partially_refunded" + OrderRefundedV2 OrderStatusV2 = "refunded" +) + +type AttemptStatus string + +const ( + AttemptCreated AttemptStatus = "created" + AttemptPending AttemptStatus = "pending" + AttemptPaid AttemptStatus = "paid" + AttemptFailed AttemptStatus = "failed" + AttemptExpired AttemptStatus = "expired" +) + +type RefundStatus string + +const ( + RefundRequested RefundStatus = "requested" + RefundProcessing RefundStatus = "processing" + RefundSucceeded RefundStatus = "succeeded" + RefundFailed RefundStatus = "failed" +) + +// ---- 业务订单(购买账本)---- + +type OrderV2 struct { + Base + OutTradeNo string `gorm:"uniqueIndex;size:64;not null"` + MerchantID uint64 `gorm:"index"` + BizSystem string `gorm:"index;size:32"` // 接入方(pangolin/jiu),空=独立收款 + BizRef string `gorm:"size:128"` // 接入方业务单号,回调原样带回 + ProductID uint64 `gorm:"index"` + Subject string `gorm:"size:128"` + // 金额:int64 最小单位 + 币种(presentment/charge/settlement 三态在 charge 落地此处) + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + Status OrderStatusV2 `gorm:"index;size:24;not null"` + // 折扣(业务授权的折后价,记账留痕) + BaseAmountMinor int64 `gorm:"default:0"` + DiscountMinor int64 `gorm:"default:0"` + CampaignID string `gorm:"size:64"` + DiscountReason string `gorm:"size:128"` + PaidAt *time.Time + ExpiresAt *time.Time // 整体购买窗口 +} + +// ---- 支付尝试(收款账本)---- + +type Attempt struct { + Base + OutTradeNo string `gorm:"index;size:64;not null"` + Channel string `gorm:"index;size:32;not null;uniqueIndex:uq_attempt_provider"` // alipay/wechat/crypto/stripe/... + AccountID string `gorm:"index;size:64"` // 选中的收款账户 + Provider string `gorm:"size:32"` + ProviderRef string `gorm:"size:128;uniqueIndex:uq_attempt_provider"` // 渠道单号 + RenderType string `gorm:"size:24"` // redirect/qr/crypto_address/... + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + Status AttemptStatus `gorm:"index;size:16;not null"` + ExpiresAt *time.Time // 本次尝试超时 + PaidAt *time.Time +} + +// ---- 收款账户(同一渠道多账户)---- + +type Account struct { + Base + AccountID string `gorm:"uniqueIndex;size:64;not null"` // 逻辑账户标识(配置里声明) + Channel string `gorm:"index;size:32;not null"` + Weight int `gorm:"default:1"` + Enabled bool `gorm:"default:true"` + DailyLimit int64 `gorm:"default:0"` // 0=不限 + Region string `gorm:"size:16"` // cn/global/... + Subject string `gorm:"size:128"` // 收款主体 + // 凭证不落库明文:仅存 env 变量名引用,真值运行时从 env 取(见 Task 4) + CredentialEnvPrefix string `gorm:"size:64"` +} + +// ---- 退款子实体 ---- + +type Refund struct { + Base + RefundID string `gorm:"uniqueIndex;size:64;not null"` + OutTradeNo string `gorm:"index;size:64;not null"` + AttemptProviderRef string `gorm:"size:128"` // 针对哪笔支付,原路退 + AmountMinor int64 `gorm:"not null"` + Currency string `gorm:"size:16;not null"` + Reason string `gorm:"size:256"` + Status RefundStatus `gorm:"index;size:16;not null"` + ProviderRefundRef string `gorm:"size:128"` + InitiatedBy string `gorm:"size:16"` // business/platform + CompletedAt *time.Time +} diff --git a/internal/model/v2_test.go b/internal/model/v2_test.go new file mode 100644 index 0000000..f698420 --- /dev/null +++ b/internal/model/v2_test.go @@ -0,0 +1,35 @@ +package model_test + +import ( + "testing" + + "github.com/wangjia/pay/internal/model" +) + +func TestV2Migrate(t *testing.T) { + db := model.OpenTestDB(t) + + o := model.OrderV2{ + OutTradeNo: "PAY-1", MerchantID: 1, BizSystem: "pangolin", BizRef: "u-1", + ProductID: 3, Subject: "pro-year", AmountMinor: 29990000, Currency: "USDT", + Status: model.OrderPendingV2, + } + if err := db.Create(&o).Error; err != nil { + t.Fatalf("create order: %v", err) + } + a := model.Attempt{ + OutTradeNo: "PAY-1", Channel: "crypto", AccountID: "usdt-a1", ProviderRef: "P-9", + RenderType: "crypto_address", AmountMinor: 29990000, Currency: "USDT", + Status: model.AttemptPending, + } + if err := db.Create(&a).Error; err != nil { + t.Fatalf("create attempt: %v", err) + } + var got model.OrderV2 + if err := db.Where("out_trade_no = ?", "PAY-1").First(&got).Error; err != nil { + t.Fatalf("read: %v", err) + } + if got.AmountMinor != 29990000 || got.Status != model.OrderPendingV2 { + t.Fatalf("got %+v", got) + } +} diff --git a/main.go b/main.go index 24f6333..ca51627 100644 --- a/main.go +++ b/main.go @@ -80,6 +80,7 @@ func autoMigrate(db *gorm.DB) { &model.Order{}, &model.NotifyLog{}, &model.BizNotifyLog{}, + &model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, // v2 ); err != nil { log.Fatalf("自动迁移失败: %v", err) }