修复 code review 问题:DTO 内部字段、错误消息、重复代码与测试覆盖
- 删除 ExpiryBaseOverrideSet 字段的 description 标签(json:"-" 内部字段不进文档) - ValidateExpiryBaseOverride 两处 CodeInvalidParam 补充中文错误消息 - 提取 initPackageExpiryBaseFields 消除 toResponse/toResponseWithAllocation 中的重复初始化 - 删除 order/service.go 中"行业卡永远直接激活"过时注释 - 补充 T01 系列授权 HTTP 集成测试(Create + ManagePackages 两个入口) - 补充 T03 C 端购买和平台代购(无分配)快照集成测试 - 补充 T04 from_purchase 默认值、卡未实名仍立即激活的自动购包测试 - 新增 testutil.NewRedisClient 供激活接续测试使用 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
173
internal/service/order/package_terms_integration_test.go
Normal file
173
internal/service/order/package_terms_integration_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestSynchronousPurchasePersistsImmutableTermsSnapshots 验证同步主套餐和加油包固化购买时计时条款。
|
||||
func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
formal := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 45)
|
||||
addon := createOrderTermsPackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 10)
|
||||
shopID := uint(time.Now().UnixNano() & 0x7fffffff)
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
for _, packageID := range []uint{formal.ID, addon.ID} {
|
||||
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: packageID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: &override}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
t.Fatalf("创建套餐覆盖配置失败:%v", err)
|
||||
}
|
||||
}
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.Local)
|
||||
formalOrder := newOrderTermsOrder(formal.ID, card.ID, &shopID)
|
||||
if err := service.activateMainPackage(context.Background(), tx, formalOrder, formal, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
|
||||
t.Fatalf("同步创建主套餐失败:%v", err)
|
||||
}
|
||||
addonOrder := newOrderTermsOrder(addon.ID, card.ID, &shopID)
|
||||
if err := service.activateAddonPackage(context.Background(), tx, addonOrder, addon, constants.AssetWalletResourceTypeIotCard, card.ID, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("同步创建加油包失败:%v", err)
|
||||
}
|
||||
|
||||
for _, item := range []struct {
|
||||
orderID uint
|
||||
packageID uint
|
||||
durationDay int
|
||||
}{
|
||||
{formalOrder.ID, formal.ID, 45},
|
||||
{addonOrder.ID, addon.ID, 10},
|
||||
} {
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", item.orderID, item.packageID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询套餐使用记录失败:%v", err)
|
||||
}
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != item.durationDay {
|
||||
t.Fatalf("同步购买快照错误:%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Package{}).Where("id IN ?", []uint{formal.ID, addon.ID}).Updates(map[string]any{"expiry_base": constants.PackageExpiryBaseFromActivation, "duration_days": 99}).Error; err != nil {
|
||||
t.Fatalf("修改套餐当前配置失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", shopID).Update("expiry_base_override", nil).Error; err != nil {
|
||||
t.Fatalf("修改分配当前配置失败:%v", err)
|
||||
}
|
||||
var usages []model.PackageUsage
|
||||
if err := tx.Where("order_id IN ?", []uint{formalOrder.ID, addonOrder.ID}).Order("order_id").Find(&usages).Error; err != nil {
|
||||
t.Fatalf("重新查询购买快照失败:%v", err)
|
||||
}
|
||||
if len(usages) != 2 || usages[0].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[1].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[0].DurationDaysSnapshot == 99 || usages[1].DurationDaysSnapshot == 99 {
|
||||
t.Fatalf("购买后配置变化不应修改快照:%+v", usages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage 验证非法配置不会留下空快照使用记录。
|
||||
func TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, "invalid", 30)
|
||||
order := newOrderTermsOrder(pkg.ID, card.ID, nil)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err == nil {
|
||||
t.Fatal("非法计时条款必须拒绝同步创建")
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("失败路径不应留下使用记录:count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCEndPurchasePersistsSnapshotAndActivatesImmediately 验证 C 端购买无论 expiry_base 如何均写入快照并立即激活。
|
||||
func TestCEndPurchasePersistsSnapshotAndActivatesImmediately(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
// C 端购买前已做实名前置检查;测试用未实名卡以区分"C 端即时激活"与"实名才激活"
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 20)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
now := time.Now()
|
||||
order := &model.Order{
|
||||
Model: gorm.Model{ID: uint(time.Now().UnixNano() & 0x7fffffff)},
|
||||
OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
IotCardID: &card.ID,
|
||||
TotalAmount: 100,
|
||||
Generation: 1,
|
||||
}
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
|
||||
t.Fatalf("C 端购买创建主套餐失败:%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("查询 C 端购买使用记录失败:%v", err)
|
||||
}
|
||||
// 快照必须完整
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromActivation || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != 20 {
|
||||
t.Fatalf("C 端购买快照错误:%+v", usage)
|
||||
}
|
||||
// C 端购买不等实名,必须立即激活
|
||||
if usage.Status != constants.PackageUsageStatusActive || usage.PendingRealnameActivation {
|
||||
t.Fatalf("C 端购买必须立即激活:status=%d pendingRealname=%v", usage.Status, usage.PendingRealnameActivation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot 验证无分配配置时使用套餐默认值写入快照。
|
||||
func TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 15)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
// sellerShopID=nil 模拟平台后台代购(无代理分配)
|
||||
order := newOrderTermsOrder(pkg.ID, card.ID, nil)
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); 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)
|
||||
}
|
||||
// 无分配时快照应取套餐默认值 from_purchase
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 15 {
|
||||
t.Fatalf("平台代购快照应使用套餐默认值:%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func createOrderTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "89" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createOrderTermsPackage(t *testing.T, tx *gorm.DB, packageType, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-ORDER-" + suffix, PackageName: "UR55同步购买测试套餐", PackageType: packageType, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func newOrderTermsOrder(packageID, cardID uint, sellerShopID *uint) *model.Order {
|
||||
orderID := uint(time.Now().UnixNano() & 0x7fffffff)
|
||||
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-ORDER-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
||||
}
|
||||
@@ -2262,9 +2262,8 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
// 计算下次重置时间(基于套餐周期类型)
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
|
||||
|
||||
// REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态)
|
||||
// 查询卡类型(行业卡永远直接激活,不等实名)
|
||||
// 同时查询载体当前实名状态:已实名则跳过 pending,直接激活
|
||||
// REALNAME-02: 后台囤货场景三维决策(购买路径 + expiry_base + 实名状态)
|
||||
// 查询载体当前实名状态:已实名则跳过 pending,直接激活
|
||||
var currentlyRealnamed bool
|
||||
if carrierType == "iot_card" {
|
||||
var card model.IotCard
|
||||
|
||||
@@ -658,14 +658,20 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}
|
||||
|
||||
baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{})
|
||||
switch assetType {
|
||||
case "iot_card":
|
||||
baseQuery = baseQuery.Where("iot_card_id = ?", assetID)
|
||||
case "device":
|
||||
baseQuery = baseQuery.Where("device_id = ?", assetID)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
newAssetQuery := func() (*gorm.DB, error) {
|
||||
query := s.db.WithContext(ctx).Model(&model.PackageUsage{})
|
||||
switch assetType {
|
||||
case "iot_card":
|
||||
return query.Where("iot_card_id = ?", assetID), nil
|
||||
case "device":
|
||||
return query.Where("device_id = ?", assetID), nil
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
}
|
||||
}
|
||||
baseQuery, err := newAssetQuery()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var targets []model.PackageUsage
|
||||
@@ -716,7 +722,11 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
|
||||
|
||||
if len(mainUsageIDs) > 0 {
|
||||
var addons []model.PackageUsage
|
||||
if err := baseQuery.
|
||||
addonQuery, queryErr := newAssetQuery()
|
||||
if queryErr != nil {
|
||||
return queryErr
|
||||
}
|
||||
if err := addonQuery.
|
||||
Where("master_usage_id IN ?", mainUsageIDs).
|
||||
Where("status IN ?", validStatuses).
|
||||
Find(&addons).Error; err != nil {
|
||||
|
||||
163
internal/service/package/activation_terms_integration_test.go
Normal file
163
internal/service/package/activation_terms_integration_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestActivateByRealnameUsesPurchasedSnapshot 验证实名激活只使用购买快照计算起止时间。
|
||||
func TestActivateByRealnameUsesPurchasedSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 99)
|
||||
purchasedAt := time.Now().Add(-48 * time.Hour).Truncate(time.Second)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 10, true, purchasedAt)
|
||||
service := NewActivationService(tx, nil, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateByRealname(context.Background(), constants.AssetTypeIotCard, card.ID); err != nil {
|
||||
t.Fatalf("实名激活失败:%v", err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询激活结果失败:%v", err)
|
||||
}
|
||||
expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, refreshed.CreatedAt, 0, 10)
|
||||
if refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil || refreshed.ActivatedAt.Unix() != refreshed.CreatedAt.Unix() || refreshed.ExpiresAt.Unix() != expectedExpiry.Unix() {
|
||||
t.Fatalf("实名激活未使用购买快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent 验证连续排队按优先级和各自快照接续。
|
||||
func TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
firstPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90)
|
||||
secondPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90)
|
||||
first := createActivationTermsUsage(t, tx, firstPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromActivation, 3, false, time.Now().Add(-time.Hour))
|
||||
second := createActivationTermsUsage(t, tx, secondPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 4, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("激活队首套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || activated {
|
||||
t.Fatalf("已有生效主套餐时重复接续应幂等:activated=%v err=%v", activated, err)
|
||||
}
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("id = ?", first.ID).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
t.Fatalf("结束队首套餐失败:%v", err)
|
||||
}
|
||||
activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("激活第二个排队套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
var refreshed []model.PackageUsage
|
||||
if err := tx.Where("id IN ?", []uint{first.ID, second.ID}).Order("priority").Find(&refreshed).Error; err != nil {
|
||||
t.Fatalf("查询队列接续结果失败:%v", err)
|
||||
}
|
||||
if len(refreshed) != 2 || refreshed[1].ExpiresAt == nil || refreshed[1].ActivatedAt == nil || !refreshed[1].ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed[1].ActivatedAt, 0, 4)) {
|
||||
t.Fatalf("第二个排队套餐未使用自身快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundFollowUpActivatesNextPackageFromSnapshot 验证退款失效后下一套餐按购买快照接续。
|
||||
func TestRefundFollowUpActivatesNextPackageFromSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
currentPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60)
|
||||
nextPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60)
|
||||
current := createActivationTermsUsage(t, tx, currentPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now().Add(-time.Hour))
|
||||
now := time.Now()
|
||||
if err := tx.Model(current).Updates(map[string]any{"status": constants.PackageUsageStatusActive, "activated_at": now, "expires_at": now.AddDate(0, 0, 30)}).Error; err != nil {
|
||||
t.Fatalf("设置当前生效套餐失败:%v", err)
|
||||
}
|
||||
next := createActivationTermsUsage(t, tx, nextPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 6, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.InvalidatePackagesForRefund(context.Background(), constants.AssetTypeIotCard, card.ID, current.OrderID, 77, "UR55-REFUND", ¤t.ID); err != nil {
|
||||
t.Fatalf("退款失效当前套餐失败:%v", err)
|
||||
}
|
||||
activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("退款后接续下一套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, next.ID).Error; err != nil {
|
||||
t.Fatalf("查询退款接续结果失败:%v", err)
|
||||
}
|
||||
if refreshed.ExpiresAt == nil || refreshed.ActivatedAt == nil || !refreshed.ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 6)) {
|
||||
t.Fatalf("退款接续未使用下一套餐快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateSpecificPackageRejectsPartialSnapshot 验证新记录非法快照不会静默回退当前套餐配置。
|
||||
func TestActivateSpecificPackageRejectsPartialSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now())
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Update("calendar_type_snapshot", "invalid").Error; err != nil {
|
||||
t.Fatalf("构造非法快照失败:%v", err)
|
||||
}
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("恢复快照校验触发器失败:%v", err)
|
||||
}
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err == nil {
|
||||
t.Fatal("非法部分快照必须拒绝激活")
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusPending {
|
||||
t.Fatalf("非法快照失败后状态必须保持待生效:status=%d err=%v", refreshed.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createActivationTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "87" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建激活测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createActivationTermsPackage(t *testing.T, tx *gorm.DB, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-ACTIVATE-" + suffix, PackageName: "UR55激活测试套餐", PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建激活测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func createActivationTermsUsage(t *testing.T, tx *gorm.DB, packageID, cardID uint, priority int, expiryBase string, durationDays int, pendingRealname bool, createdAt time.Time) *model.PackageUsage {
|
||||
t.Helper()
|
||||
unique := uint(time.Now().UnixNano() & 0x7fffffff)
|
||||
usage := &model.PackageUsage{Model: gorm.Model{CreatedAt: createdAt}, OrderID: unique, OrderNo: "UR55-ACTIVATE-" + strconv.FormatUint(uint64(unique), 10), PackageID: packageID, PackageName: "UR55激活测试套餐", UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: cardID, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: priority, PendingRealnameActivation: pendingRealname, Generation: 1, ExpiryBaseSnapshot: expiryBase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: durationDays}
|
||||
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建激活测试使用记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Updates(map[string]any{"status": constants.PackageUsageStatusPending, "pending_realname_activation": pendingRealname}).Error; err != nil {
|
||||
t.Fatalf("设置激活测试状态失败:%v", err)
|
||||
}
|
||||
return usage
|
||||
}
|
||||
@@ -625,16 +625,13 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
DefaultExpiryBase: pkg.ExpiryBase,
|
||||
DefaultExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase),
|
||||
EffectiveExpiryBase: pkg.ExpiryBase,
|
||||
EffectiveExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase),
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
initPackageExpiryBaseFields(resp, pkg)
|
||||
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
@@ -707,16 +704,13 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
DefaultExpiryBase: pkg.ExpiryBase,
|
||||
DefaultExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase),
|
||||
EffectiveExpiryBase: pkg.ExpiryBase,
|
||||
EffectiveExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase),
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
initPackageExpiryBaseFields(resp, pkg)
|
||||
|
||||
if allocationMap != nil {
|
||||
if allocation, ok := allocationMap[pkg.ID]; ok {
|
||||
@@ -744,6 +738,14 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
return resp
|
||||
}
|
||||
|
||||
// initPackageExpiryBaseFields 初始化响应中生效条件默认字段(无分配覆盖时跟随套餐默认)。
|
||||
func initPackageExpiryBaseFields(resp *dto.PackageResponse, pkg *model.Package) {
|
||||
resp.DefaultExpiryBase = pkg.ExpiryBase
|
||||
resp.DefaultExpiryBaseName = ExpiryBaseName(pkg.ExpiryBase)
|
||||
resp.EffectiveExpiryBase = pkg.ExpiryBase
|
||||
resp.EffectiveExpiryBaseName = ExpiryBaseName(pkg.ExpiryBase)
|
||||
}
|
||||
|
||||
func applyAllocationExpiryBase(resp *dto.PackageResponse, pkg *model.Package, allocation *model.ShopPackageAllocation) {
|
||||
resp.ExpiryBaseOverride = allocation.ExpiryBaseOverride
|
||||
resp.ExpiryBaseOverrideName = ExpiryBaseOverrideName(allocation.ExpiryBaseOverride)
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
// ValidateExpiryBaseOverride 校验显式提交的分配生效条件覆盖。
|
||||
func ValidateExpiryBaseOverride(value *string, submitted bool) (*string, error) {
|
||||
if !submitted {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
return nil, errors.New(errors.CodeInvalidParam, "expiry_base_override 字段未提交,须显式传值(null 或合法枚举)")
|
||||
}
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if *value != constants.PackageExpiryBaseFromActivation && *value != constants.PackageExpiryBaseFromPurchase {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
return nil, errors.New(errors.CodeInvalidParam, "expiry_base_override 枚举值非法,合法值:from_activation, from_purchase")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user