实现换货资产快照与新旧独立搜索
This commit is contained in:
133
internal/query/exchange/list.go
Normal file
133
internal/query/exchange/list.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package exchange 提供换货读取用例。
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListQuery 查询换货列表并完成权限、分页和响应投影。
|
||||
type ListQuery struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type assetSide string
|
||||
|
||||
const (
|
||||
// oldAssetSide 表示旧资产查询侧。
|
||||
oldAssetSide assetSide = "old"
|
||||
// newAssetSide 表示新资产查询侧。
|
||||
newAssetSide assetSide = "new"
|
||||
)
|
||||
|
||||
// NewListQuery 创建换货列表查询。
|
||||
func NewListQuery(db *gorm.DB) *ListQuery {
|
||||
return &ListQuery{db: db}
|
||||
}
|
||||
|
||||
// List 按新旧资产关键词和其他列表条件查询换货单。
|
||||
func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
|
||||
page := constants.DefaultPage
|
||||
if req.Page != nil {
|
||||
page = *req.Page
|
||||
}
|
||||
pageSize := constants.DefaultPageSize
|
||||
if req.PageSize != nil {
|
||||
pageSize = *req.PageSize
|
||||
}
|
||||
|
||||
query := q.db.WithContext(ctx).Model(&model.ExchangeOrder{})
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
query = applyListFilters(query, req)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单数量失败")
|
||||
}
|
||||
|
||||
var orders []*model.ExchangeOrder
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
|
||||
}
|
||||
|
||||
items := make([]*dto.ExchangeOrderResponse, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
items = append(items, projectExchangeOrder(order))
|
||||
}
|
||||
return &dto.ExchangeListResponse{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
// applyListFilters 组装列表计数和数据查询共用的全部过滤条件。
|
||||
func applyListFilters(query *gorm.DB, req *dto.ExchangeListRequest) *gorm.DB {
|
||||
if req.Status != nil {
|
||||
query = query.Where("status = ?", *req.Status)
|
||||
}
|
||||
if req.FlowType != "" {
|
||||
query = query.Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, req.FlowType)
|
||||
}
|
||||
query = applyAssetKeyword(query, oldAssetSide, req.OldAssetKeyword)
|
||||
query = applyAssetKeyword(query, newAssetSide, req.NewAssetKeyword)
|
||||
if req.CreatedAtStart != nil {
|
||||
query = query.Where("created_at >= ?", *req.CreatedAtStart)
|
||||
}
|
||||
if req.CreatedAtEnd != nil {
|
||||
query = query.Where("created_at <= ?", *req.CreatedAtEnd)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// applyAssetKeyword 使用子查询按资产类型和主键命中,避免依赖历史快照内容或逐行读取资产。
|
||||
func applyAssetKeyword(query *gorm.DB, side assetSide, keyword string) *gorm.DB {
|
||||
if keyword == "" {
|
||||
return query
|
||||
}
|
||||
like := "%" + keyword + "%"
|
||||
cardIDs := query.Session(&gorm.Session{NewDB: true}).Model(&model.IotCard{}).
|
||||
Select("id").
|
||||
Where("iccid LIKE ? OR msisdn LIKE ? OR virtual_no LIKE ?", like, like, like)
|
||||
deviceIDs := query.Session(&gorm.Session{NewDB: true}).Model(&model.Device{}).
|
||||
Select("id").
|
||||
Where("virtual_no LIKE ? OR imei LIKE ? OR sn LIKE ?", like, like, like)
|
||||
prefix := string(side)
|
||||
return query.Where(
|
||||
"("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?)) OR ("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?))",
|
||||
constants.ExchangeAssetTypeIotCard, cardIDs, constants.ExchangeAssetTypeDevice, deviceIDs,
|
||||
)
|
||||
}
|
||||
|
||||
// projectExchangeOrder 将只读模型投影为列表响应,不用于后续写侧判断。
|
||||
func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse {
|
||||
var deletedAt *time.Time
|
||||
if order.DeletedAt.Valid {
|
||||
deletedAt = &order.DeletedAt.Time
|
||||
}
|
||||
return &dto.ExchangeOrderResponse{
|
||||
ID: order.ID, ExchangeNo: order.ExchangeNo,
|
||||
FlowType: effectiveFlowType(order.FlowType), FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType),
|
||||
OldAssetType: order.OldAssetType, OldAssetID: order.OldAssetID, OldAssetIdentifier: order.OldAssetIdentifier,
|
||||
NewAssetType: order.NewAssetType, NewAssetID: order.NewAssetID, NewAssetIdentifier: order.NewAssetIdentifier,
|
||||
RecipientName: order.RecipientName, RecipientPhone: order.RecipientPhone, RecipientAddress: order.RecipientAddress,
|
||||
ExpressCompany: order.ExpressCompany, ExpressNo: order.ExpressNo,
|
||||
MigrateData: order.MigrateData, MigrationCompleted: order.MigrationCompleted, MigrationBalance: order.MigrationBalance,
|
||||
ShippedAt: order.ShippedAt, CompletedAt: order.CompletedAt,
|
||||
ExchangeReason: order.ExchangeReason, Remark: order.Remark,
|
||||
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), StatusText: constants.GetExchangeStatusName(order.Status),
|
||||
ShopID: order.ShopID, CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, DeletedAt: deletedAt,
|
||||
Creator: order.Creator, Updater: order.Updater,
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveFlowType(flowType string) string {
|
||||
if flowType == "" {
|
||||
return constants.ExchangeFlowTypeShipping
|
||||
}
|
||||
return flowType
|
||||
}
|
||||
219
internal/query/exchange/list_test.go
Normal file
219
internal/query/exchange/list_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user