补齐UR46后端验收测试
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m55s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m55s
This commit is contained in:
@@ -2,213 +2,415 @@ package packageexpiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
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,
|
||||
const (
|
||||
// testIndexCardID 是索引执行计划测试使用的卡资产 ID。
|
||||
testIndexCardID = 970001
|
||||
// testIndexDeviceID 是索引执行计划测试使用的设备资产 ID。
|
||||
testIndexDeviceID = 970002
|
||||
// testQueryPerformanceLimitMS 是项目数据库查询耗时上限。
|
||||
testQueryPerformanceLimitMS = 50
|
||||
)
|
||||
|
||||
func TestCalculateBasicStatuses(t *testing.T) {
|
||||
location, now, currentExpiry := calculationTimes()
|
||||
assertCalculation(t, nil, nil, now, constants.PackageExpiryEstimateStatusNone, nil, nil)
|
||||
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
assertCalculation(t, []*model.PackageUsage{current}, nil, now, constants.PackageExpiryEstimateStatusExact, ¤tExpiry, intPointer(8))
|
||||
waiting := newCalculationUsage(2, constants.PackageUsageStatusPending, 1)
|
||||
waiting.PendingRealnameActivation = true
|
||||
assertCalculation(t, []*model.PackageUsage{waiting}, nil, now, constants.PackageExpiryEstimateStatusWaitingActivation, nil, nil)
|
||||
expiredAt := time.Date(2026, 7, 20, 23, 59, 59, 0, location)
|
||||
expiredCurrent := newCalculationUsage(3, constants.PackageUsageStatusActive, 1)
|
||||
expiredCurrent.ExpiresAt = &expiredAt
|
||||
assertCalculation(t, []*model.PackageUsage{expiredCurrent}, nil, now, constants.PackageExpiryEstimateStatusExact, &expiredAt, intPointer(-3))
|
||||
duplicateCurrent := newCalculationUsage(4, constants.PackageUsageStatusDepleted, 2)
|
||||
duplicateCurrent.ExpiresAt = ¤tExpiry
|
||||
assertCalculation(t, []*model.PackageUsage{current, duplicateCurrent}, nil, now, constants.PackageExpiryEstimateStatusInvalidData, nil, nil)
|
||||
}
|
||||
|
||||
func TestCalculateQueueAndCalendarBoundaries(t *testing.T) {
|
||||
location, now, currentExpiry := calculationTimes()
|
||||
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
firstQueued := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
secondQueued := newCalculationUsage(3, constants.PackageUsageStatusPending, 3)
|
||||
assertCalculation(t, []*model.PackageUsage{current, firstQueued, secondQueued}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 8, 8, 23, 59, 59, 0, location)), intPointer(16))
|
||||
naturalMonth := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
naturalMonth.CalendarTypeSnapshot, naturalMonth.DurationDaysSnapshot, naturalMonth.DurationMonthsSnapshot = constants.PackageCalendarTypeNaturalMonth, 0, 1
|
||||
assertCalculation(t, []*model.PackageUsage{current, naturalMonth}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), intPointer(69))
|
||||
crossYearExpiry := time.Date(2026, 12, 31, 23, 59, 59, 0, location)
|
||||
crossYearCurrent := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
crossYearCurrent.ExpiresAt = &crossYearExpiry
|
||||
oneDay := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
oneDay.DurationDaysSnapshot = 1
|
||||
assertCalculation(t, []*model.PackageUsage{crossYearCurrent, oneDay}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2027, 1, 2, 23, 59, 59, 0, location)), nil)
|
||||
assertDuplicatePriorityOrder(t, location, now)
|
||||
}
|
||||
|
||||
func TestCalculateFallbackAndExclusions(t *testing.T) {
|
||||
location, now, currentExpiry := calculationTimes()
|
||||
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
historical := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
historical.ExpiryBaseSnapshot, historical.CalendarTypeSnapshot, historical.DurationDaysSnapshot = "", "", 0
|
||||
packages := map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}}
|
||||
assertCalculation(t, []*model.PackageUsage{current, historical}, packages, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), intPointer(12))
|
||||
invalid := newCalculationUsage(3, constants.PackageUsageStatusPending, 2)
|
||||
invalid.CalendarTypeSnapshot = "bad"
|
||||
assertCalculation(t, []*model.PackageUsage{current, invalid}, nil, now, constants.PackageExpiryEstimateStatusInvalidData, nil, nil)
|
||||
assertExcludedUsages(t, now)
|
||||
}
|
||||
|
||||
// TestCalculateExpiringBoundaries 验证临期标记包含 0 和 15 天,并排除 16 天及已过期结果。
|
||||
func TestCalculateExpiringBoundaries(t *testing.T) {
|
||||
location, now, _ := calculationTimes()
|
||||
for _, testCase := range []struct {
|
||||
days int
|
||||
want bool
|
||||
}{{days: -1, want: false}, {days: 0, want: true}, {days: 15, want: true}, {days: 16, want: false}} {
|
||||
usage := newCalculationUsage(uint(testCase.days+2), constants.PackageUsageStatusActive, 1)
|
||||
expiresAt := now.AddDate(0, 0, testCase.days).In(location)
|
||||
usage.ExpiresAt = &expiresAt
|
||||
result := Calculate([]*model.PackageUsage{usage}, nil, now)
|
||||
if result.IsExpiring != testCase.want {
|
||||
t.Fatalf("临期边界错误:days=%d want=%v result=%+v", testCase.days, testCase.want, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
func calculationTimes() (*time.Location, time.Time, time.Time) {
|
||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
return location, time.Date(2026, 7, 23, 12, 0, 0, 0, location), time.Date(2026, 7, 31, 23, 59, 59, 0, location)
|
||||
}
|
||||
|
||||
func newCalculationUsage(id uint, status, priority int) *model.PackageUsage {
|
||||
createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
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}
|
||||
}
|
||||
|
||||
func assertCalculation(t *testing.T, usages []*model.PackageUsage, packages map[uint]*model.Package, now time.Time, wantStatus string, wantDate *time.Time, wantDays *int) {
|
||||
t.Helper()
|
||||
got := Calculate(usages, packages, now)
|
||||
if got.ExpiryEstimateStatus != wantStatus || wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*wantDate)) || wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *wantDays) {
|
||||
t.Fatalf("推算结果错误:status=%s date=%v days=%v result=%+v", wantStatus, wantDate, wantDays, got)
|
||||
}
|
||||
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 wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
|
||||
t.Fatalf("非精确状态必须返回 null:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDuplicatePriorityOrder(t *testing.T, location *time.Location, now time.Time) {
|
||||
t.Helper()
|
||||
expiresAt := time.Date(2026, 1, 30, 23, 59, 59, 0, location)
|
||||
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = &expiresAt
|
||||
naturalMonth := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
naturalMonth.CalendarTypeSnapshot, naturalMonth.DurationDaysSnapshot, naturalMonth.DurationMonthsSnapshot = constants.PackageCalendarTypeNaturalMonth, 0, 1
|
||||
byDay := newCalculationUsage(3, constants.PackageUsageStatusPending, 2)
|
||||
byDay.DurationDaysSnapshot = 5
|
||||
assertCalculation(t, []*model.PackageUsage{byDay, current, naturalMonth}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 3, 6, 23, 59, 59, 0, location)), nil)
|
||||
}
|
||||
|
||||
func assertExcludedUsages(t *testing.T, now time.Time) {
|
||||
t.Helper()
|
||||
expired := newCalculationUsage(1, constants.PackageUsageStatusExpired, 1)
|
||||
invalidated := newCalculationUsage(2, constants.PackageUsageStatusInvalidated, 2)
|
||||
refundID, masterID := uint(3), uint(1)
|
||||
refunded := newCalculationUsage(3, constants.PackageUsageStatusActive, 3)
|
||||
refunded.RefundID = &refundID
|
||||
deleted := newCalculationUsage(4, constants.PackageUsageStatusActive, 4)
|
||||
deleted.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
|
||||
addon := newCalculationUsage(5, constants.PackageUsageStatusActive, 5)
|
||||
addon.MasterUsageID = &masterID
|
||||
assertCalculation(t, []*model.PackageUsage{expired, invalidated, refunded, deleted, addon}, nil, now, constants.PackageExpiryEstimateStatusNone, nil, nil)
|
||||
}
|
||||
|
||||
// TestCalculateUsesShanghaiCalendarDays 验证剩余天数按上海自然日而非不足 24 小时取整。
|
||||
func TestCalculateUsesShanghaiCalendarDays(t *testing.T) {
|
||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
now := time.Date(2026, 7, 23, 23, 30, 0, 0, location)
|
||||
expiresAt := time.Date(2026, 7, 24, 0, 30, 0, 0, location)
|
||||
usage := &model.PackageUsage{Model: modelBase(1, now), Status: constants.PackageUsageStatusActive, Priority: 1, ExpiresAt: &expiresAt}
|
||||
result := Calculate([]*model.PackageUsage{usage}, nil, now)
|
||||
if result.DaysUntilFinalExpiry == nil || *result.DaysUntilFinalExpiry != 1 {
|
||||
t.Fatalf("上海自然日差错误:want=1 got=%v", result.DaysUntilFinalExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryResolveBatchUsesOneBatchLoad 验证卡和设备各 100 个资产只执行一次套餐读取。
|
||||
func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
|
||||
for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||
t.Run(assetType, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
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 := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusActive, 1, now)
|
||||
usage.ExpiresAt = &expiresAt
|
||||
usage.OrderNo = "UR46-BATCH"
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
}
|
||||
if tt.wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*tt.wantDate)) {
|
||||
t.Fatalf("到期时间错误:want=%v got=%v", tt.wantDate, got.EstimatedFinalExpiresAt)
|
||||
counter := &queryCounterLogger{Interface: tx.Logger}
|
||||
query := NewQuery(tx.Session(&gorm.Session{Logger: counter}))
|
||||
query.now = func() time.Time { return now }
|
||||
results, err := query.ResolveBatch(context.Background(), assetType, assetIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("批量查询失败:%v", err)
|
||||
}
|
||||
if tt.wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *tt.wantDays) {
|
||||
t.Fatalf("剩余天数错误:want=%v got=%v", *tt.wantDays, got.DaysUntilFinalExpiry)
|
||||
if len(results) != len(assetIDs) || counter.count != 1 {
|
||||
t.Fatalf("100 个资产批量读取错误:results=%d queries=%d", len(results), counter.count)
|
||||
}
|
||||
if tt.wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
|
||||
t.Fatalf("非精确状态必须返回 null:%+v", got)
|
||||
for _, assetID := range assetIDs {
|
||||
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
|
||||
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
type queryCounterLogger struct {
|
||||
logger.Interface
|
||||
count int
|
||||
}
|
||||
|
||||
func (l *queryCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
l.count++
|
||||
l.Interface.Trace(ctx, begin, fc, err)
|
||||
}
|
||||
|
||||
// TestQueryResolveAndResolveBatchReturnSameResult 验证单资产与批量入口共享同一结果口径。
|
||||
func TestQueryResolveAndResolveBatchReturnSameResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
assetType string
|
||||
scenario string
|
||||
}{
|
||||
{name: "卡历史回退", assetType: constants.AssetTypeIotCard, scenario: "historical"},
|
||||
{name: "卡等待激活", assetType: constants.AssetTypeIotCard, scenario: "waiting"},
|
||||
{name: "卡异常数据", assetType: constants.AssetTypeIotCard, scenario: "invalid"},
|
||||
{name: "卡负数天数", assetType: constants.AssetTypeIotCard, scenario: "expired"},
|
||||
{name: "设备历史回退", assetType: constants.AssetTypeDevice, scenario: "historical"},
|
||||
{name: "设备等待激活", assetType: constants.AssetTypeDevice, scenario: "waiting"},
|
||||
{name: "设备异常数据", assetType: constants.AssetTypeDevice, scenario: "invalid"},
|
||||
{name: "设备负数天数", assetType: constants.AssetTypeDevice, scenario: "expired"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
assetID := uint(980001)
|
||||
createQueryConsistencyFixture(t, tx, tt.assetType, assetID, tt.scenario, now)
|
||||
query := NewQuery(tx)
|
||||
query.now = func() time.Time { return now }
|
||||
single, err := query.Resolve(context.Background(), tt.assetType, assetID)
|
||||
if err != nil {
|
||||
t.Fatalf("单资产查询失败:%v", err)
|
||||
}
|
||||
batch, err := query.ResolveBatch(context.Background(), tt.assetType, []uint{assetID})
|
||||
if err != nil {
|
||||
t.Fatalf("批量查询失败:%v", err)
|
||||
}
|
||||
assertEstimateEqual(t, single, batch[assetID])
|
||||
assertScenarioEstimate(t, single, tt.scenario, now)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertScenarioEstimate 验证一致性夹具不仅两入口相同,也符合各场景业务结果。
|
||||
func assertScenarioEstimate(t *testing.T, estimate dto.PackageExpiryEstimate, scenario string, now time.Time) {
|
||||
t.Helper()
|
||||
switch scenario {
|
||||
case "historical":
|
||||
currentExpiry := now.AddDate(0, 0, 5)
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.EstimatedFinalExpiresAt == nil || !estimate.EstimatedFinalExpiresAt.After(currentExpiry) {
|
||||
t.Fatalf("历史快照回退未参与套餐接续:%+v", estimate)
|
||||
}
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建批量套餐使用记录失败:%v", err)
|
||||
case "waiting":
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusWaitingActivation {
|
||||
t.Fatalf("等待激活场景错误:%+v", estimate)
|
||||
}
|
||||
}
|
||||
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])
|
||||
case "invalid":
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusInvalidData {
|
||||
t.Fatalf("异常数据场景错误:%+v", estimate)
|
||||
}
|
||||
case "expired":
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || *estimate.DaysUntilFinalExpiry != -3 {
|
||||
t.Fatalf("负数天数场景错误:%+v", estimate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryIndexesExplainAnalyze 验证开发 PostgreSQL 为两类资产队列查询选择 UR46 部分索引。
|
||||
// createQueryConsistencyFixture 按场景创建单资产与批量入口共享的数据库夹具。
|
||||
func createQueryConsistencyFixture(t *testing.T, tx *gorm.DB, assetType string, assetID uint, scenario string, now time.Time) {
|
||||
t.Helper()
|
||||
usage := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusActive, 1, now)
|
||||
switch scenario {
|
||||
case "waiting":
|
||||
usage.Status = constants.PackageUsageStatusPending
|
||||
usage.PendingRealnameActivation = true
|
||||
usage.ExpiresAt = nil
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
case "invalid":
|
||||
usage.ExpiresAt = nil
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
case "expired":
|
||||
expiresAt := now.AddDate(0, 0, -3)
|
||||
usage.ExpiresAt = &expiresAt
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
case "historical":
|
||||
expiresAt := now.AddDate(0, 0, 5)
|
||||
usage.ExpiresAt = &expiresAt
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
pkg := &model.Package{PackageCode: "UR46-FALLBACK-" + assetType, PackageName: "UR46历史回退套餐", PackageType: constants.PackageTypeFormal, DurationDays: 3, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: constants.PackageExpiryBaseFromActivation, Status: constants.StatusEnabled, ShelfStatus: 1}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建历史回退套餐失败:%v", err)
|
||||
}
|
||||
queued := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusPending, 2, now)
|
||||
queued.PackageID = pkg.ID
|
||||
queued.OrderID++
|
||||
queued.OrderNo = "UR46-CONSISTENCY-Q"
|
||||
queued.ExpiryBaseSnapshot = ""
|
||||
queued.CalendarTypeSnapshot = ""
|
||||
queued.DurationDaysSnapshot = 0
|
||||
// 历史套餐允许缺少快照;仅在测试事务内临时关闭新数据校验触发器,事务结束后状态自动恢复。
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用历史快照触发器失败:%v", err)
|
||||
}
|
||||
createQueryConsistencyUsage(t, tx, queued)
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("恢复历史快照触发器失败:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newQueryConsistencyUsage(assetType string, assetID uint, status, priority int, now time.Time) *model.PackageUsage {
|
||||
usage := &model.PackageUsage{OrderID: assetID, OrderNo: "UR46-CONSISTENCY", PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: priority, ActivatedAt: &now, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
usage.DeviceID = assetID
|
||||
} else {
|
||||
usage.IotCardID = assetID
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func createQueryConsistencyUsage(t *testing.T, tx *gorm.DB, usage *model.PackageUsage) {
|
||||
t.Helper()
|
||||
desiredStatus := usage.Status
|
||||
pendingRealname := usage.PendingRealnameActivation
|
||||
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": desiredStatus, "pending_realname_activation": pendingRealname}).Error; err != nil {
|
||||
t.Fatalf("更新一致性套餐状态失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEstimateEqual(t *testing.T, left, right dto.PackageExpiryEstimate) {
|
||||
t.Helper()
|
||||
if left.ExpiryEstimateStatus != right.ExpiryEstimateStatus || left.ExpiryEstimateStatusName != right.ExpiryEstimateStatusName || left.IsExpiring != right.IsExpiring || !timePointersEqual(left.EstimatedFinalExpiresAt, right.EstimatedFinalExpiresAt) || !intPointersEqual(left.DaysUntilFinalExpiry, right.DaysUntilFinalExpiry) {
|
||||
t.Fatalf("单资产与批量结果不一致:single=%+v batch=%+v", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func timePointersEqual(left, right *time.Time) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
|
||||
}
|
||||
|
||||
func intPointersEqual(left, right *int) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
// TestQueryIndexesExplainAnalyze 验证 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",
|
||||
createIndexPlanFixtures(t, tx)
|
||||
var indexDefinitions []string
|
||||
if err := tx.Raw("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN (?, ?) ORDER BY indexname", "idx_package_usage_expiry_iot_card_queue", "idx_package_usage_expiry_device_queue").Scan(&indexDefinitions).Error; err != nil {
|
||||
t.Fatalf("查询 UR46 索引定义失败:%v", err)
|
||||
}
|
||||
if len(indexDefinitions) != 2 {
|
||||
t.Fatalf("UR46 部分索引数量错误:want=2 got=%d", len(indexDefinitions))
|
||||
}
|
||||
for _, definition := range indexDefinitions {
|
||||
if !strings.Contains(definition, "master_usage_id IS NULL") || !strings.Contains(definition, "refund_id IS NULL") || !strings.Contains(definition, "status = ANY") {
|
||||
t.Fatalf("UR46 部分索引条件不完整:%s", definition)
|
||||
}
|
||||
}
|
||||
if err := tx.Exec("ANALYZE tb_package_usage").Error; err != nil {
|
||||
t.Fatalf("更新代表性数据统计信息失败:%v", err)
|
||||
}
|
||||
queries := []struct {
|
||||
sql string
|
||||
indexName string
|
||||
}{
|
||||
{sql: "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE deleted_at IS NULL AND 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", indexName: "idx_package_usage_expiry_iot_card_queue"},
|
||||
{sql: "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE deleted_at IS NULL AND device_id = 970002 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", indexName: "idx_package_usage_expiry_device_queue"},
|
||||
}
|
||||
for _, query := range queries {
|
||||
var lines []string
|
||||
if err := tx.Raw(query).Scan(&lines).Error; err != nil {
|
||||
if err := tx.Raw(query.sql).Scan(&lines).Error; err != nil {
|
||||
t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
t.Fatal("EXPLAIN ANALYZE 未返回执行计划")
|
||||
}
|
||||
plan := strings.Join(lines, "\n")
|
||||
if !strings.Contains(plan, query.indexName) {
|
||||
t.Fatalf("代表性查询未命中 UR46 部分索引 %s:%s", query.indexName, plan)
|
||||
}
|
||||
match := regexp.MustCompile(`Execution Time: ([0-9.]+) ms`).FindStringSubmatch(plan)
|
||||
if len(match) != 2 {
|
||||
t.Fatalf("执行计划缺少执行时间:%v", lines)
|
||||
}
|
||||
executionMS, err := strconv.ParseFloat(match[1], 64)
|
||||
if err != nil || executionMS >= testQueryPerformanceLimitMS {
|
||||
t.Fatalf("代表性套餐队列查询超过 50ms:execution_ms=%v err=%v", executionMS, err)
|
||||
}
|
||||
t.Logf("%s", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// createIndexPlanFixtures 创建高基数背景数据和目标套餐队列,供正常规划器评估索引选择。
|
||||
func createIndexPlanFixtures(t *testing.T, tx *gorm.DB) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
expiresAt := now.AddDate(0, 0, 30)
|
||||
usages := make([]model.PackageUsage, 0, 2020)
|
||||
for index := 0; index < 1010; index++ {
|
||||
cardID, deviceID := uint(971000+index), uint(972500+index)
|
||||
if index < 10 {
|
||||
cardID, deviceID = testIndexCardID, testIndexDeviceID
|
||||
}
|
||||
card := newQueryConsistencyUsage(constants.AssetTypeIotCard, cardID, constants.PackageUsageStatusActive, index+1, now)
|
||||
card.OrderID, card.PackageID, card.OrderNo, card.ExpiresAt = uint(800000+index), uint(800000+index), "UR46-INDEX-CARD-"+strconv.Itoa(index), &expiresAt
|
||||
device := newQueryConsistencyUsage(constants.AssetTypeDevice, deviceID, constants.PackageUsageStatusActive, index+1, now)
|
||||
device.OrderID, device.PackageID, device.OrderNo, device.ExpiresAt = uint(900000+index), uint(900000+index), "UR46-INDEX-DEVICE-"+strconv.Itoa(index), &expiresAt
|
||||
usages = append(usages, *card, *device)
|
||||
}
|
||||
if err := tx.CreateInBatches(usages, 200).Error; err != nil {
|
||||
t.Fatalf("创建索引代表性数据失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func modelBase(id uint, createdAt time.Time) gorm.Model {
|
||||
return gorm.Model{ID: id, CreatedAt: createdAt}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user