重置项目上下文与规范文档
This commit is contained in:
@@ -1,435 +0,0 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestExchangeTraceQueryProjectsPreviousAsset 验证卡和设备前代均使用换货快照,并始终返回稳定对象。
|
||||
func TestExchangeTraceQueryProjectsPreviousAsset(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewExchangeTraceQuery(tx, zap.NewNop())
|
||||
|
||||
card := createTraceCard(t, tx, 1, nil)
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, card.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询无换货关系资产失败:%v", err)
|
||||
}
|
||||
if trace == nil || trace.PreviousAsset != nil || trace.NextAsset != nil {
|
||||
t.Fatalf("无关系时应返回稳定空对象:%+v", trace)
|
||||
}
|
||||
|
||||
oldCard := createTraceCard(t, tx, 2, nil)
|
||||
createTraceOrder(t, tx, "UR86-PREV-CARD", constants.ExchangeAssetTypeIotCard, oldCard.ID, "历史卡快照", constants.ExchangeAssetTypeIotCard, card.ID, "新卡快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, card.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询卡前代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, oldCard.ID, "历史卡快照", "UR86-PREV-CARD", true)
|
||||
|
||||
oldDevice := createTraceDevice(t, tx, 3, nil)
|
||||
newDevice := createTraceDevice(t, tx, 4, nil)
|
||||
createTraceOrder(t, tx, "UR86-PREV-DEVICE", constants.ExchangeAssetTypeDevice, oldDevice.ID, "历史设备快照", constants.ExchangeAssetTypeDevice, newDevice.ID, "新设备快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, newDevice.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询设备前代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeDevice, oldDevice.ID, "历史设备快照", "UR86-PREV-DEVICE", true)
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryHidesInvisiblePreviousAsset 验证关系查询不受权限过滤,但关联资产 ID 按当前权限隐藏。
|
||||
func TestExchangeTraceQueryHidesInvisiblePreviousAsset(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
visibleShop, hiddenShop := uint(86101), uint(86102)
|
||||
oldCard := createTraceCard(t, tx, 11, &hiddenShop)
|
||||
newCard := createTraceCard(t, tx, 12, &visibleShop)
|
||||
createTraceOrder(t, tx, "UR86-PREV-HIDDEN", constants.ExchangeAssetTypeIotCard, oldCard.ID, "不可见历史快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "当前资产快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
|
||||
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{visibleShop})
|
||||
trace, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(ctx, constants.ExchangeAssetTypeIotCard, newCard.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询不可见前代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, 0, "不可见历史快照", "UR86-PREV-HIDDEN", false)
|
||||
|
||||
emptyScope := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||
trace, err = NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(emptyScope, constants.ExchangeAssetTypeIotCard, newCard.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询空权限范围前代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, 0, "不可见历史快照", "UR86-PREV-HIDDEN", false)
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryIgnoresIncompleteAndDeletedPreviousRelations 验证仅未软删除的已完成换货形成前代。
|
||||
func TestExchangeTraceQueryIgnoresIncompleteAndDeletedPreviousRelations(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewExchangeTraceQuery(tx, zap.NewNop())
|
||||
oldCard := createTraceCard(t, tx, 21, nil)
|
||||
statuses := []int{
|
||||
constants.ExchangeStatusPendingInfo,
|
||||
constants.ExchangeStatusPendingShip,
|
||||
constants.ExchangeStatusShipped,
|
||||
constants.ExchangeStatusCancelled,
|
||||
}
|
||||
for index, status := range statuses {
|
||||
newCard := createTraceCard(t, tx, 22+index, nil)
|
||||
createTraceOrder(t, tx, fmt.Sprintf("UR86-PREV-STATUS-%d", status), constants.ExchangeAssetTypeIotCard, oldCard.ID, "旧快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", status, time.Now())
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, newCard.ID)
|
||||
if err != nil || trace.PreviousAsset != nil {
|
||||
t.Fatalf("状态 %d 不应形成前代:trace=%+v err=%v", status, trace, err)
|
||||
}
|
||||
}
|
||||
|
||||
deletedNew := createTraceCard(t, tx, 30, nil)
|
||||
deletedOrder := createTraceOrder(t, tx, "UR86-PREV-DELETED", constants.ExchangeAssetTypeIotCard, oldCard.ID, "旧快照", constants.ExchangeAssetTypeIotCard, deletedNew.ID, "新快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
if err := tx.Delete(deletedOrder).Error; err != nil {
|
||||
t.Fatalf("软删除换货单失败:%v", err)
|
||||
}
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, deletedNew.ID)
|
||||
if err != nil || trace.PreviousAsset != nil {
|
||||
t.Fatalf("软删除换货单不应形成前代:trace=%+v err=%v", trace, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQuerySelectsLatestPreviousRelationAndLogsAnomaly 验证重复完成记录按完成时间和主键确定性选择。
|
||||
func TestExchangeTraceQuerySelectsLatestPreviousRelationAndLogsAnomaly(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
query := NewExchangeTraceQuery(tx, zap.New(core))
|
||||
newCard := createTraceCard(t, tx, 41, nil)
|
||||
oldOne := createTraceCard(t, tx, 42, nil)
|
||||
oldTwo := createTraceCard(t, tx, 43, nil)
|
||||
completedAt := time.Now().Truncate(time.Second)
|
||||
createTraceOrder(t, tx, "UR86-PREV-OLDER", constants.ExchangeAssetTypeIotCard, oldOne.ID, "较旧快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", constants.ExchangeStatusCompleted, completedAt)
|
||||
createTraceOrder(t, tx, "UR86-PREV-LATEST", constants.ExchangeAssetTypeIotCard, oldTwo.ID, "最新快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", constants.ExchangeStatusCompleted, completedAt)
|
||||
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, newCard.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询重复完成记录失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, oldTwo.ID, "最新快照", "UR86-PREV-LATEST", true)
|
||||
if logs.FilterMessage("检测到同方向多条已完成换货记录").Len() != 1 {
|
||||
t.Fatalf("应记录重复换货异常日志:%v", logs.All())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryWrapsDatabaseErrors 验证查询故障不会降级为空关系。
|
||||
func TestExchangeTraceQueryWrapsDatabaseErrors(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
callbackName := "ur86:force_trace_query_error"
|
||||
if err := tx.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||
db.AddError(fmt.Errorf("UR86 模拟数据库故障"))
|
||||
}); err != nil {
|
||||
t.Fatalf("注册数据库故障回调失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tx.Callback().Query().Remove(callbackName) })
|
||||
|
||||
_, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, 1)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
if !ok || appErr.Code != errors.CodeDatabaseError {
|
||||
t.Fatalf("数据库故障应转换为统一错误,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryProjectsBidirectionalChain 验证 A→B→C 中间资产同时返回前代和后代。
|
||||
func TestExchangeTraceQueryProjectsBidirectionalChain(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewExchangeTraceQuery(tx, zap.NewNop())
|
||||
a := createTraceCard(t, tx, 51, nil)
|
||||
b := createTraceCard(t, tx, 52, nil)
|
||||
c := createTraceCard(t, tx, 53, nil)
|
||||
createTraceOrder(t, tx, "UR86-CHAIN-AB", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now().Add(-time.Minute))
|
||||
createTraceOrder(t, tx, "UR86-CHAIN-BC", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeAssetTypeIotCard, c.ID, "快照C", constants.ExchangeStatusCompleted, time.Now())
|
||||
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询双向换货链失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, a.ID, "快照A", "UR86-CHAIN-AB", true)
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, c.ID, "快照C", "UR86-CHAIN-BC", true)
|
||||
|
||||
trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, a.ID)
|
||||
if err != nil || trace.PreviousAsset != nil {
|
||||
t.Fatalf("链首不应有前代:trace=%+v err=%v", trace, err)
|
||||
}
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, b.ID, "快照B", "UR86-CHAIN-AB", true)
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryHidesInvisibleNextAsset 验证后代不可见时保留快照但隐藏 ID。
|
||||
func TestExchangeTraceQueryHidesInvisibleNextAsset(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
visibleShop, hiddenShop := uint(86201), uint(86202)
|
||||
oldDevice := createTraceDevice(t, tx, 61, &visibleShop)
|
||||
newDevice := createTraceDevice(t, tx, 62, &hiddenShop)
|
||||
createTraceOrder(t, tx, "UR86-NEXT-HIDDEN", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧设备快照", constants.ExchangeAssetTypeDevice, newDevice.ID, "不可见新设备快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{visibleShop})
|
||||
|
||||
trace, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(ctx, constants.ExchangeAssetTypeDevice, oldDevice.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询不可见后代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeDevice, 0, "不可见新设备快照", "UR86-NEXT-HIDDEN", false)
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryKeepsNextSnapshotWhenAssetIDMissing 验证历史完成单缺少新资产 ID 时仍保留后代文本。
|
||||
func TestExchangeTraceQueryKeepsNextSnapshotWhenAssetIDMissing(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
oldCard := createTraceCard(t, tx, 63, nil)
|
||||
completedAt := time.Now()
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: "UR86-NEXT-MISSING-ID", FlowType: constants.ExchangeFlowTypeDirect,
|
||||
OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: "旧快照",
|
||||
NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetIdentifier: "缺失 ID 的后代快照",
|
||||
ExchangeReason: "UR86 历史异常测试", Status: constants.ExchangeStatusCompleted, CompletedAt: &completedAt,
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
t.Fatalf("创建缺少新资产 ID 的历史换货单失败:%v", err)
|
||||
}
|
||||
|
||||
trace, err := NewExchangeTraceQuery(tx, zap.New(core)).Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, oldCard.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询缺少新资产 ID 的后代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, 0, "缺失 ID 的后代快照", "UR86-NEXT-MISSING-ID", false)
|
||||
if logs.FilterMessage("已完成换货记录缺少新资产 ID").Len() != 1 {
|
||||
t.Fatalf("应记录历史换货单缺少新资产 ID 的异常:%v", logs.All())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryProvidesBatchRelationshipQueries 验证前代和后代关系支持批量查询。
|
||||
func TestExchangeTraceQueryProvidesBatchRelationshipQueries(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
a := createTraceCard(t, tx, 64, nil)
|
||||
b := createTraceCard(t, tx, 65, nil)
|
||||
c := createTraceDevice(t, tx, 66, nil)
|
||||
d := createTraceDevice(t, tx, 67, nil)
|
||||
createTraceOrder(t, tx, "UR86-BATCH-CARD", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now())
|
||||
createTraceOrder(t, tx, "UR86-BATCH-DEVICE", constants.ExchangeAssetTypeDevice, c.ID, "快照C", constants.ExchangeAssetTypeDevice, d.ID, "快照D", constants.ExchangeStatusCompleted, time.Now())
|
||||
query := NewExchangeTraceQuery(tx, zap.NewNop())
|
||||
|
||||
previousRefs := []ExchangeTraceAssetRef{{AssetType: constants.AssetResolveTypeCard, AssetID: b.ID}, {AssetType: constants.ExchangeAssetTypeDevice, AssetID: d.ID}}
|
||||
previous, err := query.FindPreviousCompleted(context.Background(), previousRefs)
|
||||
if err != nil || len(previous[ExchangeTraceAssetRef{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: b.ID}]) != 1 || len(previous[previousRefs[1]]) != 1 {
|
||||
t.Fatalf("批量前代查询错误:result=%+v err=%v", previous, err)
|
||||
}
|
||||
nextRefs := []ExchangeTraceAssetRef{{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: a.ID}, {AssetType: constants.ExchangeAssetTypeDevice, AssetID: c.ID}}
|
||||
next, err := query.FindNextCompleted(context.Background(), nextRefs)
|
||||
if err != nil || len(next[nextRefs[0]]) != 1 || len(next[nextRefs[1]]) != 1 {
|
||||
t.Fatalf("批量后代查询错误:result=%+v err=%v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryIgnoresIncompleteDeletedAndSelectsLatestNextRelation 验证后代过滤、确定性选择和异常日志。
|
||||
func TestExchangeTraceQueryIgnoresIncompleteDeletedAndSelectsLatestNextRelation(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
query := NewExchangeTraceQuery(tx, zap.New(core))
|
||||
oldDevice := createTraceDevice(t, tx, 71, nil)
|
||||
statuses := []int{constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped, constants.ExchangeStatusCancelled}
|
||||
for index, status := range statuses {
|
||||
candidate := createTraceDevice(t, tx, 72+index, nil)
|
||||
createTraceOrder(t, tx, fmt.Sprintf("UR86-NEXT-STATUS-%d", status), constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, candidate.ID, "候选快照", status, time.Now())
|
||||
}
|
||||
deletedCandidate := createTraceDevice(t, tx, 80, nil)
|
||||
deletedOrder := createTraceOrder(t, tx, "UR86-NEXT-DELETED", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, deletedCandidate.ID, "软删除快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
if err := tx.Delete(deletedOrder).Error; err != nil {
|
||||
t.Fatalf("软删除后代换货单失败:%v", err)
|
||||
}
|
||||
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, oldDevice.ID)
|
||||
if err != nil || trace.NextAsset != nil {
|
||||
t.Fatalf("非完成和软删除记录不应形成后代:trace=%+v err=%v", trace, err)
|
||||
}
|
||||
|
||||
newOne := createTraceDevice(t, tx, 81, nil)
|
||||
newTwo := createTraceDevice(t, tx, 82, nil)
|
||||
completedAt := time.Now().Truncate(time.Second)
|
||||
createTraceOrder(t, tx, "UR86-NEXT-OLDER", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, newOne.ID, "较旧后代快照", constants.ExchangeStatusCompleted, completedAt)
|
||||
createTraceOrder(t, tx, "UR86-NEXT-LATEST", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, newTwo.ID, "最新后代快照", constants.ExchangeStatusCompleted, completedAt)
|
||||
trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, oldDevice.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询重复后代记录失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeDevice, newTwo.ID, "最新后代快照", "UR86-NEXT-LATEST", true)
|
||||
if logs.FilterMessage("检测到同方向多条已完成换货记录").Len() != 1 {
|
||||
t.Fatalf("应记录后代重复换货异常日志:%v", logs.All())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryUsesFixedQueriesAndMeetsPerformanceTargets 验证 SQL 次数固定且读取性能满足项目目标。
|
||||
func TestExchangeTraceQueryUsesFixedQueriesAndMeetsPerformanceTargets(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
a := createTraceCard(t, tx, 91, nil)
|
||||
b := createTraceCard(t, tx, 92, nil)
|
||||
c := createTraceCard(t, tx, 93, nil)
|
||||
createTraceOrder(t, tx, "UR86-PERF-AB", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now().Add(-time.Minute))
|
||||
createTraceOrder(t, tx, "UR86-PERF-BC", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeAssetTypeIotCard, c.ID, "快照C", constants.ExchangeStatusCompleted, time.Now())
|
||||
counter := &traceQueryCounter{Interface: tx.Logger}
|
||||
query := NewExchangeTraceQuery(tx.Session(&gorm.Session{Logger: counter}), zap.NewNop())
|
||||
|
||||
durations := make([]time.Duration, 30)
|
||||
for index := range durations {
|
||||
startedAt := time.Now()
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, b.ID)
|
||||
durations[index] = time.Since(startedAt)
|
||||
if err != nil || trace.PreviousAsset == nil || trace.NextAsset == nil {
|
||||
t.Fatalf("第 %d 次性能查询失败:trace=%+v err=%v", index+1, trace, err)
|
||||
}
|
||||
}
|
||||
if counter.count.Load() != 90 {
|
||||
t.Fatalf("卡链每次应固定执行 3 条 SQL,实际总数:%d", counter.count.Load())
|
||||
}
|
||||
sort.Slice(durations, func(left, right int) bool { return durations[left] < durations[right] })
|
||||
p95, p99 := durations[28], durations[29]
|
||||
if p95 >= 200*time.Millisecond || p99 >= 500*time.Millisecond {
|
||||
t.Fatalf("换货链 Query 超过 API 性能目标:p95=%s p99=%s", p95, p99)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceIndexDefinitionAndPlans 验证双向部分索引定义及代表性查询计划。
|
||||
func TestExchangeTraceIndexDefinitionAndPlans(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
indexNames := []string{"idx_exchange_trace_new_asset", "idx_exchange_trace_old_asset"}
|
||||
for _, indexName := range indexNames {
|
||||
var count int64
|
||||
if err := tx.Raw("SELECT COUNT(*) FROM pg_class WHERE relname = ?", indexName).Scan(&count).Error; err != nil {
|
||||
t.Fatalf("查询换货链索引数量失败:%v", err)
|
||||
}
|
||||
if os.Getenv("UR86_EXPECT_INDEX_ABSENT") == "1" {
|
||||
if count != 0 {
|
||||
t.Fatalf("回滚后索引 %s 仍然存在", indexName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("换货链索引 %s 数量异常:%d", indexName, count)
|
||||
}
|
||||
|
||||
var index struct {
|
||||
AccessMethod string `gorm:"column:access_method"`
|
||||
IsUnique bool `gorm:"column:is_unique"`
|
||||
Definition string `gorm:"column:definition"`
|
||||
Predicate string `gorm:"column:predicate"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT am.amname AS access_method,
|
||||
ix.indisunique AS is_unique,
|
||||
pg_get_indexdef(ix.indexrelid) AS definition,
|
||||
pg_get_expr(ix.indpred, ix.indrelid) AS predicate
|
||||
FROM pg_index ix
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_am am ON am.oid = i.relam
|
||||
WHERE i.relname = ?
|
||||
`, indexName).Scan(&index).Error
|
||||
if err != nil {
|
||||
t.Fatalf("查询换货链索引定义失败:%v", err)
|
||||
}
|
||||
if index.AccessMethod != "btree" || index.IsUnique || !strings.Contains(index.Definition, "completed_at DESC NULLS LAST") || !strings.Contains(index.Definition, "id DESC") || !strings.Contains(index.Predicate, "status = 4") || !strings.Contains(index.Predicate, "deleted_at IS NULL") {
|
||||
t.Fatalf("换货链索引定义不符合契约:%+v", index)
|
||||
}
|
||||
}
|
||||
if os.Getenv("UR86_EXPECT_INDEX_ABSENT") == "1" {
|
||||
return
|
||||
}
|
||||
|
||||
for direction, sql := range map[string]string{
|
||||
"previous": "SELECT * FROM tb_exchange_order WHERE new_asset_type = 'iot_card' AND new_asset_id = 1 AND status = 4 AND deleted_at IS NULL ORDER BY completed_at DESC NULLS LAST, id DESC LIMIT 1",
|
||||
"next": "SELECT * FROM tb_exchange_order WHERE old_asset_type = 'iot_card' AND old_asset_id = 1 AND status = 4 AND deleted_at IS NULL ORDER BY completed_at DESC NULLS LAST, id DESC LIMIT 1",
|
||||
} {
|
||||
var planLines []string
|
||||
if err := tx.Exec("SET LOCAL enable_seqscan = off").Error; err != nil {
|
||||
t.Fatalf("配置查询计划测试失败:%v", err)
|
||||
}
|
||||
if err := tx.Raw("EXPLAIN " + sql).Scan(&planLines).Error; err != nil {
|
||||
t.Fatalf("记录 %s 查询计划失败:%v", direction, err)
|
||||
}
|
||||
plan := strings.Join(planLines, "\n")
|
||||
expectedIndex := "idx_exchange_trace_old_asset"
|
||||
if direction == "previous" {
|
||||
expectedIndex = "idx_exchange_trace_new_asset"
|
||||
}
|
||||
if !strings.Contains(plan, expectedIndex) {
|
||||
t.Fatalf("%s 查询计划未使用预期索引 %s:%s", direction, expectedIndex, plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createTraceCard(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.IotCard {
|
||||
t.Helper()
|
||||
iccid := fmt.Sprintf("8986222222222222%04d", suffix)
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], VirtualNo: fmt.Sprintf("UR86-CARD-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建换货链测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createTraceDevice(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.Device {
|
||||
t.Helper()
|
||||
device := &model.Device{VirtualNo: fmt.Sprintf("UR86-DEVICE-%04d", suffix), IMEI: fmt.Sprintf("86222222222%04d", suffix), SN: fmt.Sprintf("UR86-SN-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建换货链测试设备失败:%v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func createTraceOrder(t *testing.T, tx *gorm.DB, exchangeNo, oldType string, oldID uint, oldIdentifier, newType string, newID uint, newIdentifier string, status int, completedAt time.Time) *model.ExchangeOrder {
|
||||
t.Helper()
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect,
|
||||
OldAssetType: oldType, OldAssetID: oldID, OldAssetIdentifier: oldIdentifier,
|
||||
NewAssetType: newType, NewAssetID: &newID, NewAssetIdentifier: newIdentifier,
|
||||
ExchangeReason: "UR86 换货链测试", Status: status,
|
||||
}
|
||||
if status == constants.ExchangeStatusCompleted {
|
||||
order.CompletedAt = &completedAt
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
t.Fatalf("创建换货链测试单失败:%v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func assertTraceAsset(t *testing.T, actual *dto.AssetExchangeTraceItem, assetType string, assetID uint, identifier, exchangeNo string, canView bool) {
|
||||
t.Helper()
|
||||
if actual == nil {
|
||||
t.Fatal("换货关联项不应为空")
|
||||
}
|
||||
if actual.AssetType != assetType || actual.Identifier != identifier || actual.ExchangeNo != exchangeNo || actual.CanView != canView {
|
||||
t.Fatalf("换货关联项错误:%+v", actual)
|
||||
}
|
||||
if canView {
|
||||
if actual.AssetID == nil || *actual.AssetID != assetID {
|
||||
t.Fatalf("可见关联资产应返回真实 ID:%+v", actual)
|
||||
}
|
||||
} else if actual.AssetID != nil {
|
||||
t.Fatalf("不可见关联资产不应返回 ID:%+v", actual)
|
||||
}
|
||||
}
|
||||
|
||||
type traceQueryCounter struct {
|
||||
logger.Interface
|
||||
count atomic.Int64
|
||||
}
|
||||
|
||||
func (l *traceQueryCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
l.count.Add(1)
|
||||
l.Interface.Trace(ctx, begin, fc, err)
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestListQuerySearchesOldAndNewAssetsIndependently 验证六类资产标识、新旧独立和双条件 AND。
|
||||
func TestListQuerySearchesOldAndNewAssetsIndependently(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewListQuery(tx)
|
||||
oldCard := createListTestCard(t, tx, 1, nil)
|
||||
newCard := createListTestCard(t, tx, 2, nil)
|
||||
oldDevice := createListTestDevice(t, tx, 1, nil)
|
||||
newDevice := createListTestDevice(t, tx, 2, nil)
|
||||
|
||||
cardOrder := createListTestOrder(t, tx, "UR45-Q-CARD", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, time.Now().Add(-time.Hour))
|
||||
deviceOrder := createListTestOrder(t, tx, "UR45-Q-DEVICE", oldDevice.ID, constants.ExchangeAssetTypeDevice, newDevice.ID, constants.ExchangeAssetTypeDevice, time.Now())
|
||||
|
||||
for name, keyword := range map[string]string{"卡ICCID": oldCard.ICCID, "卡接入号": oldCard.MSISDN, "卡虚拟号": oldCard.VirtualNo} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: keyword}, []uint{cardOrder.ID})
|
||||
})
|
||||
}
|
||||
for name, keyword := range map[string]string{"设备虚拟号": newDevice.VirtualNo, "设备IMEI": newDevice.IMEI, "设备SN": newDevice.SN} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{NewAssetKeyword: keyword}, []uint{deviceOrder.ID})
|
||||
})
|
||||
}
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.MSISDN, NewAssetKeyword: newCard.VirtualNo}, []uint{cardOrder.ID})
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.ICCID, NewAssetKeyword: newDevice.IMEI}, nil)
|
||||
}
|
||||
|
||||
// TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots 验证组合条件、历史快照和空结果契约。
|
||||
func TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewListQuery(tx)
|
||||
oldCard := createListTestCard(t, tx, 11, nil)
|
||||
newCard := createListTestCard(t, tx, 12, nil)
|
||||
createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
|
||||
order := createListTestOrder(t, tx, "UR45-Q-HISTORY", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, createdAt)
|
||||
if err := tx.Model(order).Updates(map[string]any{
|
||||
"old_asset_identifier": "历史旧快照", "new_asset_identifier": "历史新快照",
|
||||
"status": constants.ExchangeStatusCompleted, "flow_type": constants.ExchangeFlowTypeDirect,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("更新历史快照失败:%v", err)
|
||||
}
|
||||
status := constants.ExchangeStatusCompleted
|
||||
start, end := createdAt.Add(-time.Minute), createdAt.Add(time.Minute)
|
||||
result, err := query.List(context.Background(), &dto.ExchangeListRequest{
|
||||
OldAssetKeyword: oldCard.VirtualNo, NewAssetKeyword: newCard.MSISDN,
|
||||
Status: &status, FlowType: constants.ExchangeFlowTypeDirect, CreatedAtStart: &start, CreatedAtEnd: &end,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("查询历史换货单失败:%v", err)
|
||||
}
|
||||
if result.Total != 1 || len(result.List) != 1 || result.List[0].OldAssetIdentifier != "历史旧快照" || result.List[0].NewAssetIdentifier != "历史新快照" {
|
||||
t.Fatalf("历史快照查询结果错误:%+v", result)
|
||||
}
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: "不存在"}, nil)
|
||||
}
|
||||
|
||||
// TestListQueryExcludesDeletedAssetsAndAppliesShopScope 验证候选软删除和最终换货单店铺范围。
|
||||
func TestListQueryExcludesDeletedAssetsAndAppliesShopScope(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewListQuery(tx)
|
||||
shopOne, shopTwo := uint(45101), uint(45102)
|
||||
cardOne := createListTestCard(t, tx, 21, &shopOne)
|
||||
cardTwo := createListTestCard(t, tx, 22, &shopTwo)
|
||||
orderOne := createListTestOrder(t, tx, "UR45-Q-SCOPE-1", cardOne.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now())
|
||||
orderTwo := createListTestOrder(t, tx, "UR45-Q-SCOPE-2", cardTwo.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Second))
|
||||
if err := tx.Model(orderOne).Update("shop_id", shopOne).Error; err != nil {
|
||||
t.Fatalf("更新店铺范围失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(orderTwo).Update("shop_id", shopTwo).Error; err != nil {
|
||||
t.Fatalf("更新店铺范围失败:%v", err)
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{shopOne})
|
||||
result, err := query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: "UR45-Q-CARD"})
|
||||
if err != nil {
|
||||
t.Fatalf("按店铺范围查询失败:%v", err)
|
||||
}
|
||||
if result.Total != 1 || result.List[0].ID != orderOne.ID {
|
||||
t.Fatalf("店铺范围被关键词绕过:%+v", result)
|
||||
}
|
||||
if err := tx.Delete(cardOne).Error; err != nil {
|
||||
t.Fatalf("软删除测试卡失败:%v", err)
|
||||
}
|
||||
result, err = query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: cardOne.ICCID})
|
||||
if err != nil || result.Total != 0 {
|
||||
t.Fatalf("软删除候选资产不应命中:result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListQueryUsesFixedQueriesAndConsistentPagination 验证大结果集仅执行计数和分页两条 SQL。
|
||||
func TestListQueryUsesFixedQueriesAndConsistentPagination(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createListTestCard(t, tx, 31, nil)
|
||||
for index := 0; index < 120; index++ {
|
||||
createListTestOrder(t, tx, fmt.Sprintf("UR45-Q-PERF-%03d", index), card.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Duration(index)*time.Second))
|
||||
}
|
||||
counter := &queryCounter{Interface: tx.Logger}
|
||||
query := NewListQuery(tx.Session(&gorm.Session{Logger: counter}))
|
||||
page, pageSize := 2, 20
|
||||
req := &dto.ExchangeListRequest{OldAssetKeyword: card.ICCID, Page: &page, PageSize: &pageSize}
|
||||
durations := make([]time.Duration, 100)
|
||||
var result *dto.ExchangeListResponse
|
||||
for index := range durations {
|
||||
startedAt := time.Now()
|
||||
var err error
|
||||
result, err = query.List(context.Background(), req)
|
||||
durations[index] = time.Since(startedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次查询大结果集失败:%v", index+1, err)
|
||||
}
|
||||
}
|
||||
if counter.count.Load() != 200 || result.Total != 120 || len(result.List) != 20 || result.List[0].ExchangeNo != "UR45-Q-PERF-099" {
|
||||
t.Fatalf("查询次数或分页不一致:queries=%d total=%d items=%d first=%s", counter.count.Load(), result.Total, len(result.List), result.List[0].ExchangeNo)
|
||||
}
|
||||
sort.Slice(durations, func(left, right int) bool { return durations[left] < durations[right] })
|
||||
p95, p99 := durations[94], durations[98]
|
||||
if p95 >= 200*time.Millisecond || p99 >= 500*time.Millisecond {
|
||||
t.Fatalf("列表查询超过性能目标:p95=%s p99=%s", p95, p99)
|
||||
}
|
||||
planSQL := query.db.ToSQL(func(db *gorm.DB) *gorm.DB {
|
||||
return applyListFilters(db.Model(&model.ExchangeOrder{}), req).Order("created_at DESC").Offset(20).Limit(20).Find(&[]*model.ExchangeOrder{})
|
||||
})
|
||||
var planLines []string
|
||||
if err := tx.Raw("EXPLAIN " + planSQL).Scan(&planLines).Error; err != nil || len(planLines) == 0 {
|
||||
t.Fatalf("记录查询计划失败:plan=%v err=%v", planLines, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListQueryWrapsDatabaseErrors 验证数据库故障不会降级为空结果。
|
||||
func TestListQueryWrapsDatabaseErrors(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
callbackName := "ur45:force_query_error"
|
||||
if err := tx.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||
db.AddError(fmt.Errorf("UR45 模拟数据库故障"))
|
||||
}); err != nil {
|
||||
t.Fatalf("注册数据库故障回调失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tx.Callback().Query().Remove(callbackName) })
|
||||
_, err := NewListQuery(tx).List(context.Background(), &dto.ExchangeListRequest{})
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
if !ok || appErr.Code != errors.CodeDatabaseError {
|
||||
t.Fatalf("数据库故障应转换为统一错误,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type queryCounter struct {
|
||||
logger.Interface
|
||||
count atomic.Int64
|
||||
}
|
||||
|
||||
func (l *queryCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
l.count.Add(1)
|
||||
l.Interface.Trace(ctx, begin, fc, err)
|
||||
}
|
||||
|
||||
func createListTestCard(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.IotCard {
|
||||
t.Helper()
|
||||
iccid := fmt.Sprintf("8986111111111111%04d", suffix)
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1370000%04d", suffix), VirtualNo: fmt.Sprintf("UR45-Q-CARD-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createListTestDevice(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.Device {
|
||||
t.Helper()
|
||||
device := &model.Device{VirtualNo: fmt.Sprintf("UR45-Q-DEVICE-%04d", suffix), IMEI: fmt.Sprintf("86111111111%04d", suffix), SN: fmt.Sprintf("UR45-Q-SN-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建设备失败:%v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func createListTestOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldID uint, oldType string, newID uint, newType string, createdAt time.Time) *model.ExchangeOrder {
|
||||
t.Helper()
|
||||
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeShipping, OldAssetType: oldType, OldAssetID: oldID, OldAssetIdentifier: "历史旧快照", NewAssetType: newType, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 查询测试", Status: constants.ExchangeStatusPendingInfo}
|
||||
if newID > 0 {
|
||||
order.NewAssetID = &newID
|
||||
}
|
||||
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
t.Fatalf("创建换货单失败:%v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func assertListQueryIDs(t *testing.T, query *ListQuery, req *dto.ExchangeListRequest, expected []uint) {
|
||||
t.Helper()
|
||||
result, err := query.List(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("查询换货单失败:%v", err)
|
||||
}
|
||||
if result.Total != int64(len(expected)) || len(result.List) != len(expected) {
|
||||
t.Fatalf("命中数量错误:total=%d items=%d expected=%d", result.Total, len(result.List), len(expected))
|
||||
}
|
||||
for index, id := range expected {
|
||||
if result.List[index].ID != id {
|
||||
t.Fatalf("命中换货单错误:期望 %d,实际 %d", id, result.List[index].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
outboxquery "github.com/break/junhong_cmp_fiber/internal/query/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestMetricsCoverBacklogRetriesFailuresAndThresholdAlerts(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
states := []struct {
|
||||
id string
|
||||
eventType string
|
||||
status int
|
||||
retries int
|
||||
}{
|
||||
{id: "metrics-pending", eventType: "example.a", status: constants.OutboxStatusPending},
|
||||
{id: "metrics-delivering", eventType: "example.a", status: constants.OutboxStatusDelivering, retries: 1},
|
||||
{id: "metrics-delivered", eventType: "example.b", status: constants.OutboxStatusDelivered},
|
||||
{id: "metrics-failed", eventType: "example.b", status: constants.OutboxStatusFailed, retries: 3},
|
||||
}
|
||||
for _, state := range states {
|
||||
event, err := repository.Append(context.Background(), db, outbox.Envelope{
|
||||
EventID: state.id, EventType: state.eventType, AggregateType: "example", AggregateID: state.id,
|
||||
ResourceType: "example", ResourceID: state.id, Payload: struct {
|
||||
Visible bool `json:"visible"`
|
||||
}{Visible: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备指标事件失败:%v", err)
|
||||
}
|
||||
updates := map[string]any{"status": state.status, "retry_count": state.retries, "created_at": now.Add(-2 * time.Minute), "updated_at": now}
|
||||
if state.status == constants.OutboxStatusDelivering {
|
||||
updates["lease_owner"] = "dead-worker"
|
||||
updates["lease_expires_at"] = now.Add(-time.Minute)
|
||||
}
|
||||
if state.status == constants.OutboxStatusDelivered {
|
||||
updates["delivered_at"] = now.Add(-time.Minute)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(updates).Error; err != nil {
|
||||
t.Fatalf("设置指标事件状态失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
query := outboxquery.NewQuery(db, func() time.Time { return now })
|
||||
metrics, err := query.GetMetrics(context.Background(), time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("查询 Outbox 指标失败:%v", err)
|
||||
}
|
||||
if metrics.PendingCount != 1 || metrics.DeliveringCount != 1 || metrics.ExpiredLeaseCount != 1 ||
|
||||
metrics.DeliveredInWindow != 1 || metrics.FinalFailedCount != 1 || metrics.OldestPendingAgeSecs != 120 {
|
||||
t.Fatalf("Outbox 指标不完整:%+v", metrics)
|
||||
}
|
||||
if len(metrics.RetryDistribution) != 2 || len(metrics.BacklogByEventType) != 2 {
|
||||
t.Fatalf("重试分布或事件类型积压不完整:%+v", metrics)
|
||||
}
|
||||
alerts := outboxquery.EvaluateAlerts(metrics, outboxquery.Thresholds{
|
||||
PendingCount: 1, OldestPendingAge: time.Minute, ExpiredLeaseCount: 1, FinalFailedCount: 1,
|
||||
})
|
||||
if len(alerts) != 4 {
|
||||
t.Fatalf("阈值告警数量错误:%+v", alerts)
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 len(results) != len(assetIDs) || counter.count != 1 {
|
||||
t.Fatalf("100 个资产批量读取错误:results=%d queries=%d", len(results), counter.count)
|
||||
}
|
||||
for _, assetID := range assetIDs {
|
||||
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
|
||||
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
case "waiting":
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusWaitingActivation {
|
||||
t.Fatalf("等待激活场景错误:%+v", estimate)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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.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}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func timePointer(value time.Time) *time.Time { return &value }
|
||||
Reference in New Issue
Block a user