From 8dde55ea6d44e33b1d9966e5d1fe3d3f7510f4b9 Mon Sep 17 00:00:00 2001 From: break Date: Thu, 23 Jul 2026 11:18:38 +0900 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20code=20review=20=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=9A=E6=8F=90=E5=8F=96=E9=87=8D=E5=A4=8D=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E3=80=81=E8=A1=A5=E5=85=A8=E7=BC=BA=E5=A4=B1=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取 ResolveTermsFromTx 到 service/package,消除 order 和 auto_purchase 中的重复实现 - 补全 domain 测试:新增 from_activation 覆盖用例,现覆盖两种覆盖值 - 补全激活集成测试:新增 from_purchase 立即激活场景、历史记录兼容回退计数器验证 - 补全自动购包集成测试:新增购买后修改套餐配置不影响已有快照的验证 Co-Authored-By: Claude Sonnet 4.6 --- internal/domain/package/terms_test.go | 20 +++--- internal/service/order/service.go | 15 +---- .../activation_terms_integration_test.go | 63 +++++++++++++++++++ internal/service/package/usage_terms.go | 20 ++++++ internal/task/auto_purchase.go | 15 +---- .../auto_purchase_terms_integration_test.go | 24 +++++++ 6 files changed, 122 insertions(+), 35 deletions(-) diff --git a/internal/domain/package/terms_test.go b/internal/domain/package/terms_test.go index 07b1606..e1ac16c 100644 --- a/internal/domain/package/terms_test.go +++ b/internal/domain/package/terms_test.go @@ -9,15 +9,18 @@ import ( // TestResolveTermsSnapshot 验证默认、覆盖及两种周期规则。 func TestResolveTermsSnapshot(t *testing.T) { - override := constants.PackageExpiryBaseFromPurchase + overridePurchase := constants.PackageExpiryBaseFromPurchase + overrideActivation := constants.PackageExpiryBaseFromActivation tests := []struct { - name string - pkg model.Package - allocation *model.ShopPackageAllocation - wantErr bool + name string + pkg model.Package + allocation *model.ShopPackageAllocation + wantErr bool + wantExpiryBase string }{ - {name: "自然月默认", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeNaturalMonth, DurationMonths: 12}}, - {name: "按天覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &override}}, + {name: "自然月默认", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeNaturalMonth, DurationMonths: 12}, wantExpiryBase: constants.PackageExpiryBaseFromActivation}, + {name: "from_purchase 覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &overridePurchase}, wantExpiryBase: constants.PackageExpiryBaseFromPurchase}, + {name: "from_activation 覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromPurchase, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 14}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &overrideActivation}, wantExpiryBase: constants.PackageExpiryBaseFromActivation}, {name: "非法生效条件", pkg: model.Package{ExpiryBase: "invalid", CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, wantErr: true}, {name: "非法按天时长", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromPurchase, CalendarType: constants.PackageCalendarTypeByDay}, wantErr: true}, } @@ -30,6 +33,9 @@ func TestResolveTermsSnapshot(t *testing.T) { if !test.wantErr && !got.IsValid() { t.Fatalf("快照应有效:%+v", got) } + if !test.wantErr && test.wantExpiryBase != "" && got.ExpiryBase != test.wantExpiryBase { + t.Fatalf("生效条件不符预期:want=%s got=%s", test.wantExpiryBase, got.ExpiryBase) + } }) } } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index b8c8057..2f4b750 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -2446,21 +2446,8 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * return nil } -// resolvePackageTerms 在给定事务内查询分配覆盖并解析计时条款快照。 -// 必须在事务内调用,确保分配读取与 PackageUsage 写入在同一连接,避免快照与提交值不一致。 func (s *Service) resolvePackageTerms(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) { - var allocation *model.ShopPackageAllocation - if sellerShopID != nil && *sellerShopID > 0 { - store := postgres.NewShopPackageAllocationStore(tx) - found, err := store.GetByShopAndPackageForSystem(ctx, *sellerShopID, pkg.ID) - if err != nil && err != gorm.ErrRecordNotFound { - return packagedomain.TermsSnapshot{}, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐分配失败") - } - if err == nil { - allocation = found - } - } - return packagedomain.ResolveTermsSnapshot(pkg, allocation) + return packagepkg.ResolveTermsFromTx(ctx, tx, pkg, sellerShopID) } func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) { diff --git a/internal/service/package/activation_terms_integration_test.go b/internal/service/package/activation_terms_integration_test.go index fc3b34d..dc73619 100644 --- a/internal/service/package/activation_terms_integration_test.go +++ b/internal/service/package/activation_terms_integration_test.go @@ -98,6 +98,69 @@ func TestRefundFollowUpActivatesNextPackageFromSnapshot(t *testing.T) { } } +// TestFromPurchaseActivatesImmediatelyAtPurchaseTime 验证 from_purchase 快照在购买时直接激活,不等实名。 +func TestFromPurchaseActivatesImmediatelyAtPurchaseTime(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + redisClient := testutil.NewRedisClient(t) + card := createActivationTermsCard(t, tx, constants.RealNameStatusNotVerified) + pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 7) + usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 7, false, time.Now()) + service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop()) + if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err != nil { + t.Fatalf("from_purchase 直接激活失败:%v", err) + } + var refreshed model.PackageUsage + if err := tx.First(&refreshed, usage.ID).Error; err != nil { + t.Fatalf("查询激活结果失败:%v", err) + } + if refreshed.Status != constants.PackageUsageStatusActive || refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil { + t.Fatalf("from_purchase 套餐应已激活:status=%d activatedAt=%v expiresAt=%v", refreshed.Status, refreshed.ActivatedAt, refreshed.ExpiresAt) + } + if refreshed.PendingRealnameActivation { + t.Fatal("from_purchase 不应标记待实名") + } + expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 7) + if !refreshed.ExpiresAt.Equal(expectedExpiry) { + t.Fatalf("to期时间不符:want=%v got=%v", expectedExpiry, refreshed.ExpiresAt) + } +} + +// TestHistoricalFallbackFiresWarningAndIncrementsCounter 验证历史记录缺少快照时回退套餐当前配置并递增计数器。 +func TestHistoricalFallbackFiresWarningAndIncrementsCounter(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + redisClient := testutil.NewRedisClient(t) + card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified) + pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21) + // 历史记录:四个快照字段全为空值/零值 + unique := uint(time.Now().UnixNano() & 0x7fffffff) + usage := &model.PackageUsage{ + OrderID: unique, OrderNo: "UR55-HIST-" + strconv.FormatUint(uint64(unique), 10), + PackageID: pkg.ID, PackageName: "UR55历史测试套餐", + UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: card.ID, + DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, Generation: 1, + // 快照字段均留空,模拟 UR#55 上线前的旧数据 + } + if err := tx.Omit("status").Create(usage).Error; err != nil { + t.Fatalf("创建历史测试使用记录失败:%v", err) + } + if err := tx.Model(usage).Update("status", constants.PackageUsageStatusPending).Error; err != nil { + t.Fatalf("设置历史测试状态失败:%v", err) + } + before := HistoricalTermsFallbackCount() + service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop()) + if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err != nil { + t.Fatalf("历史记录兼容激活失败:%v", err) + } + after := HistoricalTermsFallbackCount() + if after <= before { + t.Fatalf("历史回退计数器未递增:before=%d after=%d", before, after) + } + var refreshed model.PackageUsage + if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusActive { + t.Fatalf("历史记录应兼容激活成功:status=%d err=%v", refreshed.Status, err) + } +} + // TestActivateSpecificPackageRejectsPartialSnapshot 验证新记录非法快照不会静默回退当前套餐配置。 func TestActivateSpecificPackageRejectsPartialSnapshot(t *testing.T) { tx := testutil.NewPostgresTransaction(t) diff --git a/internal/service/package/usage_terms.go b/internal/service/package/usage_terms.go index 2657591..749a98e 100644 --- a/internal/service/package/usage_terms.go +++ b/internal/service/package/usage_terms.go @@ -1,12 +1,15 @@ package packagepkg import ( + "context" "sync/atomic" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/errors" "go.uber.org/zap" + "gorm.io/gorm" ) var historicalTermsFallbackCount atomic.Uint64 @@ -41,6 +44,23 @@ func HistoricalTermsFallbackCount() uint64 { return historicalTermsFallbackCount.Load() } +// ResolveTermsFromTx 在给定事务内查询分配覆盖并解析计时条款快照。 +// 必须在事务内调用,确保分配读取与 PackageUsage 写入在同一连接,避免快照与提交值不一致。 +func ResolveTermsFromTx(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) { + var allocation *model.ShopPackageAllocation + if sellerShopID != nil && *sellerShopID > 0 { + store := postgres.NewShopPackageAllocationStore(tx) + found, err := store.GetByShopAndPackageForSystem(ctx, *sellerShopID, pkg.ID) + if err != nil && err != gorm.ErrRecordNotFound { + return packagedomain.TermsSnapshot{}, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐分配失败") + } + if err == nil { + allocation = found + } + } + return packagedomain.ResolveTermsSnapshot(pkg, allocation) +} + func isEmptyHistoricalTerms(usage *model.PackageUsage) bool { return usage.ExpiryBaseSnapshot == "" && usage.CalendarTypeSnapshot == "" && usage.DurationMonthsSnapshot == 0 && usage.DurationDaysSnapshot == 0 diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index ef7e993..6ab3c9a 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -703,21 +703,8 @@ func (h *AutoPurchaseHandler) activateAddonPackage( return tx.Create(usage).Error } -// resolvePackageTerms 在给定事务内查询分配覆盖并解析计时条款快照。 -// 必须在事务内调用,确保分配读取与 PackageUsage 写入在同一连接,避免快照与提交值不一致。 func (h *AutoPurchaseHandler) resolvePackageTerms(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) { - var allocation *model.ShopPackageAllocation - if sellerShopID != nil && *sellerShopID > 0 { - store := postgres.NewShopPackageAllocationStore(tx) - found, err := store.GetByShopAndPackageForSystem(ctx, *sellerShopID, pkg.ID) - if err != nil && err != gorm.ErrRecordNotFound { - return packagedomain.TermsSnapshot{}, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询套餐分配失败") - } - if err == nil { - allocation = found - } - } - return packagedomain.ResolveTermsSnapshot(pkg, allocation) + return packagepkg.ResolveTermsFromTx(ctx, tx, pkg, sellerShopID) } func parseLinkedPackageIDs(raw []byte) ([]uint, error) { diff --git a/internal/task/auto_purchase_terms_integration_test.go b/internal/task/auto_purchase_terms_integration_test.go index de41672..d13bdda 100644 --- a/internal/task/auto_purchase_terms_integration_test.go +++ b/internal/task/auto_purchase_terms_integration_test.go @@ -136,6 +136,30 @@ func TestAutoPurchaseTransactionRollbackOnInvalidAddonTerms(t *testing.T) { } } +// TestAutoPurchaseConfigChangeDoesNotModifySnapshot 验证自动购包后修改套餐配置不改变已有使用记录的计时快照。 +func TestAutoPurchaseConfigChangeDoesNotModifySnapshot(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified) + pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30) + handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + order := newAutoPurchaseOrder(card.ID, nil) + if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil { + t.Fatalf("自动购包创建主套餐失败:%v", err) + } + // 购买后修改套餐的生效条件和时长 + if err := tx.Model(pkg).Updates(map[string]any{"expiry_base": constants.PackageExpiryBaseFromActivation, "duration_days": 99}).Error; err != nil { + t.Fatalf("修改套餐配置失败:%v", err) + } + var usage model.PackageUsage + if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil { + t.Fatalf("查询自动购包记录失败:%v", err) + } + // 快照应保留购买时的值,不随套餐修改而变化 + if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 30 { + t.Fatalf("套餐配置变更不应影响已购买快照:expiryBase=%s days=%d", usage.ExpiryBaseSnapshot, usage.DurationDaysSnapshot) + } +} + func createAutoPurchaseCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard { t.Helper() suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)