From bc183ab7dfa0b3f24557a9b6f92efb6ca7e8c0ff Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 20:26:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20license=5Fpurchases=20?= =?UTF-8?q?=E5=A2=9E=20amount=5Fminor/currency/pay=5Furl/renewed=5Fto=20?= =?UTF-8?q?=E5=88=97=20+=20=E5=AD=98=E9=87=8F=E5=9B=9E=E5=A1=AB=EF=BC=88pa?= =?UTF-8?q?y=20v2=20=E9=87=91=E9=A2=9D=E5=8F=A3=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/internal/model/license_purchase.go | 6 ++++- backend/internal/service/pay.go | 15 ++++++++++++ backend/internal/service/pay_test.go | 27 ++++++++++++++++++++++ backend/main.go | 4 ++++ backend/schema/schema.sql | 6 ++++- backend/testutil/setup.go | 4 ++++ 6 files changed, 60 insertions(+), 2 deletions(-) diff --git a/backend/internal/model/license_purchase.go b/backend/internal/model/license_purchase.go index 46ccb2d..1bd47d0 100644 --- a/backend/internal/model/license_purchase.go +++ b/backend/internal/model/license_purchase.go @@ -10,7 +10,11 @@ type LicensePurchase struct { ShopID uint64 `gorm:"not null;index" json:"shop_id"` UserID uint64 `gorm:"not null" json:"user_id"` ProductBizCode string `gorm:"size:64;not null" json:"product_biz_code"` - Amount string `gorm:"size:16" json:"amount"` + AmountMinor int64 `gorm:"not null;default:0" json:"amount_minor"` // v2 口径:最小单位(CNY=分) + Currency string `gorm:"size:8;not null;default:''" json:"currency"` + PayURL string `gorm:"size:512" json:"pay_url,omitempty"` // v2 session payload.url,订单管理「继续支付」复用 + RenewedTo *time.Time `json:"renewed_to,omitempty"` // settle 续期后的授权到期日(订单流水「授权续期至」展示) + Amount string `gorm:"size:16" json:"amount"` // Deprecated: v1 元字符串,只读兜底输出,观察一版后 DROP OutTradeNo string `gorm:"size:64;uniqueIndex:uk_out_trade_no" json:"out_trade_no"` Status string `gorm:"type:enum('pending','paid','failed');default:'pending';index" json:"status"` TradeNo string `gorm:"size:64" json:"trade_no,omitempty"` // 渠道交易号(支付宝/微信) diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 38a866d..279f47f 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -368,6 +368,21 @@ func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error { }).Error } +// BackfillPurchaseAmountMinor 启动回填:v1 存量购买单 amount("2999.00") → amount_minor(299900)+CNY。 +// 幂等:只处理 amount_minor=0 且 amount 非空的行(参照 backfillPinyin 先例)。 +func BackfillPurchaseAmountMinor(db *gorm.DB) { + var rows []model.LicensePurchase + if err := db.Where("amount_minor = 0 AND amount <> ''").Find(&rows).Error; err != nil { + return + } + for _, p := range rows { + if c, err := toCents(p.Amount); err == nil && c > 0 { + db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID). + Updates(map[string]any{"amount_minor": c, "currency": "CNY"}) + } + } +} + // amountEqual 金额按分归一比较("2999.00" == "2999" == "2999.0")。 func amountEqual(a, b string) bool { ca, ea := toCents(a) diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index 87f5555..9cf711a 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -370,3 +370,30 @@ func TestStatus_ScopedToShop(t *testing.T) { _, err = svc.Status(other.ID, "yanmei-otn-7") assert.ErrorIs(t, err, ErrPurchaseNotFound, "跨店不可见") } + +// ---------- v2 金额回填 ---------- + +func TestBackfillPurchaseAmountMinor(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PAY012") + + p1 := createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-backfill-1") + p2 := createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-backfill-2") + // 已有 amount_minor>0 的行不应被覆盖 + p3 := createPendingPurchase(t, db, shop.ID, "monthly_pro", "599.00", "yanmei-backfill-3") + require.NoError(t, db.Model(p3).Updates(map[string]any{"amount_minor": 123, "currency": "USD"}).Error) + + BackfillPurchaseAmountMinor(db) + + var got1, got2, got3 model.LicensePurchase + require.NoError(t, db.First(&got1, p1.ID).Error) + require.NoError(t, db.First(&got2, p2.ID).Error) + require.NoError(t, db.First(&got3, p3.ID).Error) + + assert.Equal(t, int64(299900), got1.AmountMinor) + assert.Equal(t, "CNY", got1.Currency) + assert.Equal(t, int64(100), got2.AmountMinor) + assert.Equal(t, "CNY", got2.Currency) + assert.Equal(t, int64(123), got3.AmountMinor, "已有 amount_minor>0 的行不被覆盖") + assert.Equal(t, "USD", got3.Currency, "已有 amount_minor>0 的行不被覆盖") +} diff --git a/backend/main.go b/backend/main.go index 14f157f..d414237 100644 --- a/backend/main.go +++ b/backend/main.go @@ -54,6 +54,10 @@ func main() { // 幂等(新列为 0 才拷),旧列保留不再读写、观察一版后手动 DROP service.BackfillPricingColumns(db) + // pay v2 金额口径迁移:存量购买单 amount(元字符串) → amount_minor(分)+currency, + // 幂等(amount_minor=0 才回填),旧列保留不再读写、观察一版后手动 DROP + service.BackfillPurchaseAmountMinor(db) + // 启动会话/失败登录保留期清理任务(后台 goroutine) service.StartSessionCleanup(db, config.C.Session.RetentionDays) diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index bae2d98..f73cdb9 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -167,7 +167,11 @@ CREATE TABLE IF NOT EXISTS `license_purchases` ( `shop_id` BIGINT UNSIGNED NOT NULL, `user_id` BIGINT UNSIGNED NOT NULL COMMENT '下单管理员', `product_biz_code` VARCHAR(64) NOT NULL COMMENT '套餐稳定码(monthly_standard/annual_standard/monthly_pro/annual_pro)', - `amount` VARCHAR(16) DEFAULT NULL COMMENT 'pay 下单回传金额,如 2999.00', + `amount_minor` BIGINT NOT NULL DEFAULT 0 COMMENT 'v2 口径:最小单位金额(CNY=分)', + `currency` VARCHAR(8) NOT NULL DEFAULT '' COMMENT 'v2 口径:币种,如 CNY', + `pay_url` VARCHAR(512) DEFAULT NULL COMMENT 'v2 下单 session payload.url,订单管理「继续支付」复用', + `renewed_to` DATETIME DEFAULT NULL COMMENT 'settle 续期后的授权到期日(订单流水「授权续期至」展示)', + `amount` VARCHAR(16) DEFAULT NULL COMMENT 'Deprecated: v1 元字符串,如 2999.00,观察一版后 DROP', `out_trade_no` VARCHAR(64) DEFAULT NULL COMMENT 'pay 订单号', `status` ENUM('pending','paid','failed') NOT NULL DEFAULT 'pending', `trade_no` VARCHAR(64) DEFAULT NULL COMMENT '渠道交易号(支付宝/微信)', diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 4b3bf93..6e58454 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -168,6 +168,10 @@ func SetupTestDB() *gorm.DB { shop_id INTEGER NOT NULL, user_id INTEGER NOT NULL, product_biz_code TEXT NOT NULL, + amount_minor INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT '', + pay_url TEXT, + renewed_to DATETIME, amount TEXT, out_trade_no TEXT UNIQUE, status TEXT NOT NULL DEFAULT 'pending',