package packageexpiry import ( "context" "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" "gorm.io/gorm" ) func TestCalculate(t *testing.T) { location := time.FixedZone("Asia/Shanghai", 8*60*60) now := time.Date(2026, 7, 23, 12, 0, 0, 0, location) currentExpiry := time.Date(2026, 7, 31, 23, 59, 59, 0, location) createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, location) newUsage := func(id uint, status int, priority int) *model.PackageUsage { return &model.PackageUsage{ Model: modelBase(id, createdAt.Add(time.Duration(id)*time.Second)), PackageID: id, Status: status, Priority: priority, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 3, } } tests := []struct { name string usages []*model.PackageUsage packages map[uint]*model.Package wantStatus string wantDate *time.Time wantDays *int }{ {name: "无套餐", wantStatus: constants.PackageExpiryEstimateStatusNone}, { name: "当前套餐", usages: func() []*model.PackageUsage { u := newUsage(1, constants.PackageUsageStatusActive, 1) u.ExpiresAt = ¤tExpiry return []*model.PackageUsage{u} }(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDate: ¤tExpiry, wantDays: intPointer(8), }, { name: "多段队列按下一时刻接续", usages: func() []*model.PackageUsage { current := newUsage(1, constants.PackageUsageStatusActive, 1) current.ExpiresAt = ¤tExpiry queued := newUsage(2, constants.PackageUsageStatusPending, 2) queued.DurationDaysSnapshot = 3 return []*model.PackageUsage{current, queued} }(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDate: timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), wantDays: intPointer(12), }, { name: "自然月月末", usages: func() []*model.PackageUsage { current := newUsage(1, constants.PackageUsageStatusActive, 1) current.ExpiresAt = ¤tExpiry queued := newUsage(2, constants.PackageUsageStatusPending, 2) queued.CalendarTypeSnapshot = constants.PackageCalendarTypeNaturalMonth queued.DurationDaysSnapshot = 0 queued.DurationMonthsSnapshot = 1 return []*model.PackageUsage{current, queued} }(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDate: timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), wantDays: intPointer(69), }, { name: "等待实名激活", usages: func() []*model.PackageUsage { u := newUsage(1, constants.PackageUsageStatusPending, 1) u.PendingRealnameActivation = true return []*model.PackageUsage{u} }(), wantStatus: constants.PackageExpiryEstimateStatusWaitingActivation, }, { name: "历史空快照回退", usages: func() []*model.PackageUsage { current := newUsage(1, constants.PackageUsageStatusActive, 1) current.ExpiresAt = ¤tExpiry queued := newUsage(2, constants.PackageUsageStatusPending, 2) queued.ExpiryBaseSnapshot, queued.CalendarTypeSnapshot, queued.DurationDaysSnapshot = "", "", 0 return []*model.PackageUsage{current, queued} }(), packages: map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}}, wantStatus: constants.PackageExpiryEstimateStatusExact, }, { name: "非法快照", usages: func() []*model.PackageUsage { current := newUsage(1, constants.PackageUsageStatusActive, 1) current.ExpiresAt = ¤tExpiry queued := newUsage(2, constants.PackageUsageStatusPending, 2) queued.CalendarTypeSnapshot = "bad" return []*model.PackageUsage{current, queued} }(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData, }, { name: "已过期退款软删除加油包均排除", usages: func() []*model.PackageUsage { expired := newUsage(1, constants.PackageUsageStatusExpired, 1) invalidated := newUsage(2, constants.PackageUsageStatusInvalidated, 2) refundID := uint(3) refunded := newUsage(3, constants.PackageUsageStatusActive, 3) refunded.RefundID = &refundID deleted := newUsage(4, constants.PackageUsageStatusActive, 4) deleted.DeletedAt = gorm.DeletedAt{Time: now, Valid: true} masterID := uint(1) addon := newUsage(5, constants.PackageUsageStatusActive, 5) addon.MasterUsageID = &masterID return []*model.PackageUsage{expired, invalidated, refunded, deleted, addon} }(), wantStatus: constants.PackageExpiryEstimateStatusNone, }, { name: "多个当前套餐为异常数据", usages: func() []*model.PackageUsage { first := newUsage(1, constants.PackageUsageStatusActive, 1) first.ExpiresAt = ¤tExpiry second := newUsage(2, constants.PackageUsageStatusDepleted, 2) second.ExpiresAt = ¤tExpiry return []*model.PackageUsage{first, second} }(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData, }, { name: "已过期当前套餐返回负数自然日", usages: func() []*model.PackageUsage { expiry := time.Date(2026, 7, 20, 23, 59, 59, 0, location) u := newUsage(1, constants.PackageUsageStatusActive, 1) u.ExpiresAt = &expiry return []*model.PackageUsage{u} }(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDays: intPointer(-3), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := Calculate(tt.usages, tt.packages, now) if got.ExpiryEstimateStatus != tt.wantStatus { t.Fatalf("状态错误:want=%s got=%s", tt.wantStatus, got.ExpiryEstimateStatus) } if tt.wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*tt.wantDate)) { t.Fatalf("到期时间错误:want=%v got=%v", tt.wantDate, got.EstimatedFinalExpiresAt) } if tt.wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *tt.wantDays) { t.Fatalf("剩余天数错误:want=%v got=%v", *tt.wantDays, got.DaysUntilFinalExpiry) } if tt.wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) { t.Fatalf("非精确状态必须返回 null:%+v", got) } }) } } // TestQueryResolveBatchUsesOneBatchLoad 验证一页资产只通过批量读取计算,不逐卡查询套餐。 func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) { tx := testutil.NewPostgresTransaction(t) location := time.FixedZone("Asia/Shanghai", 8*60*60) now := time.Date(2026, 7, 23, 12, 0, 0, 0, location) assetIDs := make([]uint, 0, 100) for i := 1; i <= 100; i++ { assetID := uint(970000 + i) assetIDs = append(assetIDs, assetID) expiresAt := now.AddDate(0, 0, i%16) usage := &model.PackageUsage{ OrderID: assetID, OrderNo: "UR46-BATCH", PackageID: assetID, UsageType: constants.AssetTypeIotCard, IotCardID: assetID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30, DurationMonthsSnapshot: 0, } if err := tx.Create(usage).Error; err != nil { t.Fatalf("创建批量套餐使用记录失败:%v", err) } } query := NewQuery(tx) query.now = func() time.Time { return now } results, err := query.ResolveBatch(context.Background(), constants.AssetTypeIotCard, assetIDs) if err != nil { t.Fatalf("批量查询失败:%v", err) } if len(results) != len(assetIDs) { t.Fatalf("批量结果数量错误:want=%d got=%d", len(assetIDs), len(results)) } for _, assetID := range assetIDs { if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact { t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID]) } } } // TestQueryIndexesExplainAnalyze 验证开发 PostgreSQL 为两类资产队列查询选择 UR46 部分索引。 func TestQueryIndexesExplainAnalyze(t *testing.T) { tx := testutil.NewPostgresTransaction(t) queries := []string{ "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE iot_card_id = 970001 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC", "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE device_id = 970001 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC", } for _, query := range queries { var lines []string if err := tx.Raw(query).Scan(&lines).Error; err != nil { t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err) } if len(lines) == 0 { t.Fatal("EXPLAIN ANALYZE 未返回执行计划") } t.Logf("%s", lines) } } func modelBase(id uint, createdAt time.Time) gorm.Model { return gorm.Model{ID: id, CreatedAt: createdAt} } func intPointer(value int) *int { return &value } func timePointer(value time.Time) *time.Time { return &value }