实现资产双向换货链路查询

This commit is contained in:
2026-07-22 18:05:38 +09:00
parent 7c8a4cd328
commit c58773e35b
16 changed files with 1133 additions and 40 deletions

View File

@@ -7,6 +7,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
@@ -128,7 +129,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
deps.Logger,
svc.AssetAudit,
)
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc)
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger))
h.SetLifecycleService(svc.AssetLifecycle)
return h
}(),

View File

@@ -1,6 +1,7 @@
package admin
import (
"context"
"strconv"
"strings"
"time"
@@ -29,6 +30,12 @@ type AssetHandler struct {
iotCardStopResume *iotCardService.StopResumeService
assetPolling *pollingSvc.AssetPollingService
assetLifecycleService AssetLifecycleService
exchangeTraceQuery AssetExchangeTraceResolver
}
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
type AssetExchangeTraceResolver interface {
Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error)
}
// NewAssetHandler 创建资产管理处理器
@@ -39,14 +46,16 @@ func NewAssetHandler(
iotCardSvc *iotCardService.Service,
iotCardStopResume *iotCardService.StopResumeService,
assetPolling *pollingSvc.AssetPollingService,
exchangeTraceQuery AssetExchangeTraceResolver,
) *AssetHandler {
return &AssetHandler{
assetService: assetSvc,
assetAuditService: assetAuditService,
deviceService: deviceSvc,
iotCardService: iotCardSvc,
iotCardStopResume: iotCardStopResume,
assetPolling: assetPolling,
assetService: assetSvc,
assetAuditService: assetAuditService,
deviceService: deviceSvc,
iotCardService: iotCardSvc,
iotCardStopResume: iotCardStopResume,
assetPolling: assetPolling,
exchangeTraceQuery: exchangeTraceQuery,
}
}
@@ -78,6 +87,13 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
if err != nil {
return err
}
result.ExchangeTrace = &dto.AssetExchangeTrace{}
if h.exchangeTraceQuery != nil {
result.ExchangeTrace, err = h.exchangeTraceQuery.Resolve(c.UserContext(), result.AssetType, result.AssetID)
if err != nil {
return err
}
}
return response.Success(c, result)
}

View File

@@ -69,8 +69,24 @@ type AssetResolveResponse struct {
GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend用于展示运营商侧实际停机原因"`
GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI由 Gateway 卡状态接口同步,非设备自身 IMEI无业务含义仅供查看"`
// 流量汇总字段(仅在 ?include_usage_summary=true 时返回非 null 值)
TotalVirtualUsedMB *float64 `json:"total_virtual_used_mb" description:"当前世代所有套餐虚已用之和MB未请求时为 null"`
TotalVirtualRemainingMB *float64 `json:"total_virtual_remaining_mb" description:"当前世代所有套餐虚剩余之和MB未请求时为 null"`
TotalVirtualUsedMB *float64 `json:"total_virtual_used_mb" description:"当前世代所有套餐虚已用之和MB未请求时为 null"`
TotalVirtualRemainingMB *float64 `json:"total_virtual_remaining_mb" description:"当前世代所有套餐虚剩余之和MB未请求时为 null"`
ExchangeTrace *AssetExchangeTrace `json:"exchange_trace" description:"换货链路;对象始终存在,前代或后代不存在时对应字段为 null"`
}
// AssetExchangeTrace 资产单节点双向换货链路。
type AssetExchangeTrace struct {
PreviousAsset *AssetExchangeTraceItem `json:"previous_asset" nullable:"true" description:"当前资产的换货前代,不存在时为 null"`
NextAsset *AssetExchangeTraceItem `json:"next_asset" nullable:"true" description:"当前资产的换货后代,不存在时为 null"`
}
// AssetExchangeTraceItem 换货链路中的单个关联资产。
type AssetExchangeTraceItem struct {
AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetID *uint `json:"asset_id" description:"关联资产数据库 ID无权限时为 null"`
Identifier string `json:"identifier" description:"换货单保存的不可变资产标识快照"`
ExchangeNo string `json:"exchange_no" description:"换货单号"`
CanView bool `json:"can_view" description:"是否有权跳转查看关联资产"`
}
// BoundCardInfo 设备绑定的卡信息

View File

@@ -0,0 +1,228 @@
// Package asset 提供资产详情读取投影。
package asset
import (
"context"
"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"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ExchangeTraceQuery 查询资产单节点换货链路并投影关联资产可见性。
type ExchangeTraceQuery struct {
db *gorm.DB
logger *zap.Logger
}
// ExchangeTraceAssetRef 标识批量换货关系查询中的当前资产。
type ExchangeTraceAssetRef struct {
// AssetType 为统一资产详情或换货模型中的资产类型。
AssetType string
// AssetID 为资产数据库 ID。
AssetID uint
}
// NewExchangeTraceQuery 创建资产换货链路查询。
func NewExchangeTraceQuery(db *gorm.DB, logger *zap.Logger) *ExchangeTraceQuery {
if logger == nil {
logger = zap.NewNop()
}
return &ExchangeTraceQuery{db: db, logger: logger}
}
// Resolve 查询当前资产的前代与后代换货投影。
func (q *ExchangeTraceQuery) Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error) {
trace := &dto.AssetExchangeTrace{}
assetType = normalizeExchangeAssetType(assetType)
ref := ExchangeTraceAssetRef{AssetType: assetType, AssetID: assetID}
previousBatch, err := q.FindPreviousCompleted(ctx, []ExchangeTraceAssetRef{ref})
if err != nil {
return nil, q.wrapQueryError(constants.ExchangeTraceDirectionPrevious, assetType, assetID, err)
}
nextBatch, err := q.FindNextCompleted(ctx, []ExchangeTraceAssetRef{ref})
if err != nil {
return nil, q.wrapQueryError(constants.ExchangeTraceDirectionNext, assetType, assetID, err)
}
previousOrders := previousBatch[ref]
nextOrders := nextBatch[ref]
previous := q.selectLatest(constants.ExchangeTraceDirectionPrevious, assetType, assetID, previousOrders)
next := q.selectLatest(constants.ExchangeTraceDirectionNext, assetType, assetID, nextOrders)
visibility, err := q.loadVisibility(ctx, previous, next)
if err != nil {
q.logger.Error("查询换货关联资产可见性失败",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Error(err))
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货关联资产可见性失败")
}
if previous != nil {
key := relatedAssetKey{assetType: previous.OldAssetType, assetID: previous.OldAssetID}
previousAssetID := previous.OldAssetID
trace.PreviousAsset = newTraceItem(previous.OldAssetType, &previousAssetID, previous.OldAssetIdentifier, previous.ExchangeNo, visibility[key])
}
if next != nil {
var canView bool
if next.NewAssetID != nil {
key := relatedAssetKey{assetType: next.NewAssetType, assetID: *next.NewAssetID}
canView = visibility[key]
} else {
q.logger.Warn("已完成换货记录缺少新资产 ID",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Uint("exchange_id", next.ID),
zap.String("exchange_no", next.ExchangeNo))
}
trace.NextAsset = newTraceItem(next.NewAssetType, next.NewAssetID, next.NewAssetIdentifier, next.ExchangeNo, canView)
}
return trace, nil
}
func normalizeExchangeAssetType(assetType string) string {
if assetType == constants.AssetResolveTypeCard {
return constants.ExchangeAssetTypeIotCard
}
return assetType
}
type relatedAssetKey struct {
assetType string
assetID uint
}
// FindPreviousCompleted 批量查询当前资产作为新资产时的已完成换货记录。
func (q *ExchangeTraceQuery) FindPreviousCompleted(ctx context.Context, refs []ExchangeTraceAssetRef) (map[ExchangeTraceAssetRef][]*model.ExchangeOrder, error) {
return q.findRelatedOrdersBatch(ctx, constants.ExchangeTraceDirectionPrevious, refs)
}
// FindNextCompleted 批量查询当前资产作为旧资产时的已完成换货记录。
func (q *ExchangeTraceQuery) FindNextCompleted(ctx context.Context, refs []ExchangeTraceAssetRef) (map[ExchangeTraceAssetRef][]*model.ExchangeOrder, error) {
return q.findRelatedOrdersBatch(ctx, constants.ExchangeTraceDirectionNext, refs)
}
func (q *ExchangeTraceQuery) findRelatedOrdersBatch(ctx context.Context, direction string, refs []ExchangeTraceAssetRef) (map[ExchangeTraceAssetRef][]*model.ExchangeOrder, error) {
result := make(map[ExchangeTraceAssetRef][]*model.ExchangeOrder, len(refs))
if len(refs) == 0 {
return result, nil
}
var orders []*model.ExchangeOrder
query := q.db.WithContext(ctx).Where("status = ?", constants.ExchangeStatusCompleted)
assetConditions := q.db.Session(&gorm.Session{NewDB: true}).Where("1 = 0")
for _, ref := range refs {
ref.AssetType = normalizeExchangeAssetType(ref.AssetType)
if direction == constants.ExchangeTraceDirectionPrevious {
assetConditions = assetConditions.Or("new_asset_type = ? AND new_asset_id = ?", ref.AssetType, ref.AssetID)
} else {
assetConditions = assetConditions.Or("old_asset_type = ? AND old_asset_id = ?", ref.AssetType, ref.AssetID)
}
}
err := query.Where(assetConditions).
Order("completed_at DESC NULLS LAST, id DESC").
Find(&orders).Error
if err != nil {
return nil, err
}
for _, order := range orders {
ref := ExchangeTraceAssetRef{AssetType: order.OldAssetType, AssetID: order.OldAssetID}
if direction == constants.ExchangeTraceDirectionPrevious && order.NewAssetID != nil {
ref = ExchangeTraceAssetRef{AssetType: order.NewAssetType, AssetID: *order.NewAssetID}
}
result[ref] = append(result[ref], order)
}
return result, nil
}
func (q *ExchangeTraceQuery) selectLatest(direction string, assetType string, assetID uint, orders []*model.ExchangeOrder) *model.ExchangeOrder {
if len(orders) == 0 {
return nil
}
selected := orders[0]
if len(orders) > 1 {
q.logger.Warn("检测到同方向多条已完成换货记录",
zap.String("direction", string(direction)),
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Int("candidate_count", len(orders)),
zap.Uint("selected_exchange_id", selected.ID),
zap.String("selected_exchange_no", selected.ExchangeNo))
}
return selected
}
func (q *ExchangeTraceQuery) loadVisibility(ctx context.Context, previous, next *model.ExchangeOrder) (map[relatedAssetKey]bool, error) {
result := make(map[relatedAssetKey]bool, 2)
cardIDs, deviceIDs := relatedAssetIDs(previous, next)
if len(cardIDs) > 0 {
var cards []*model.IotCard
if err := q.db.WithContext(ctx).Select("id", "shop_id").Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
return nil, err
}
for _, card := range cards {
result[relatedAssetKey{assetType: constants.ExchangeAssetTypeIotCard, assetID: card.ID}] = canViewShopAsset(ctx, card.ShopID)
}
}
if len(deviceIDs) > 0 {
var devices []*model.Device
if err := q.db.WithContext(ctx).Select("id", "shop_id").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil {
return nil, err
}
for _, device := range devices {
result[relatedAssetKey{assetType: constants.ExchangeAssetTypeDevice, assetID: device.ID}] = canViewShopAsset(ctx, device.ShopID)
}
}
return result, nil
}
func canViewShopAsset(ctx context.Context, shopID *uint) bool {
if middleware.IsUnrestricted(ctx) {
return true
}
return shopID != nil && middleware.ContainsShopID(ctx, *shopID)
}
func relatedAssetIDs(previous, next *model.ExchangeOrder) ([]uint, []uint) {
cardIDs := make([]uint, 0, 2)
deviceIDs := make([]uint, 0, 2)
appendID := func(assetType string, assetID uint) {
if assetType == constants.ExchangeAssetTypeIotCard {
cardIDs = append(cardIDs, assetID)
} else if assetType == constants.ExchangeAssetTypeDevice {
deviceIDs = append(deviceIDs, assetID)
}
}
if previous != nil {
appendID(previous.OldAssetType, previous.OldAssetID)
}
if next != nil && next.NewAssetID != nil {
appendID(next.NewAssetType, *next.NewAssetID)
}
return cardIDs, deviceIDs
}
func (q *ExchangeTraceQuery) wrapQueryError(direction string, assetType string, assetID uint, err error) error {
q.logger.Error("查询资产换货关系失败",
zap.String("direction", string(direction)),
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Error(err))
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产换货关系失败")
}
func newTraceItem(assetType string, assetID *uint, identifier, exchangeNo string, canView bool) *dto.AssetExchangeTraceItem {
item := &dto.AssetExchangeTraceItem{
AssetType: assetType,
Identifier: identifier,
ExchangeNo: exchangeNo,
CanView: canView,
}
if canView && assetID != nil {
item.AssetID = assetID
}
return item
}

View File

@@ -0,0 +1,435 @@
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)
}

View File

@@ -14,7 +14,7 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
Register(assets, doc, groupPath, "GET", "/resolve/:identifier", handler.Resolve, RouteSpec{
Summary: "解析资产",
Description: "通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。",
Description: "通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。exchange_trace 始终存在previous_asset/next_asset 无关系时为 null关联项仅在 can_view=true 且 asset_id 非空时允许跳转。企业账号禁止调用。",
Tags: []string{"资产管理"},
Input: new(dto.AssetResolveRequest),
Output: new(dto.AssetResolveResponse),

View File

@@ -0,0 +1,268 @@
package routes
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace 验证真实认证、权限、资产解析和双向换货投影。
func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
visibleShop := env.createShop(t, "UR86 可见店铺", nil, 1)
hiddenShop := env.createShop(t, "UR86 不可见店铺", nil, 1)
oldCard := env.createCard(t, 1, &hiddenShop.ID)
middleCard := env.createCard(t, 2, &visibleShop.ID)
nextCard := env.createCard(t, 3, &visibleShop.ID)
env.createOrder(t, "UR86-HTTP-PREV", oldCard, middleCard, "HTTP 历史前代快照", "HTTP 中间快照", time.Now().Add(-time.Minute))
env.createOrder(t, "UR86-HTTP-NEXT", middleCard, nextCard, "HTTP 中间快照", "HTTP 历史后代快照", time.Now())
token := env.newAgentToken(t, visibleShop.ID)
status, body := env.request(t, "/api/admin/assets/resolve/"+middleCard.ICCID, token)
if status != http.StatusOK {
t.Fatalf("查询中间资产失败,状态 %d%s", status, body)
}
var success struct {
Code int `json:"code"`
Data struct {
AssetID uint `json:"asset_id"`
ExchangeTrace struct {
PreviousAsset *struct {
AssetID *uint `json:"asset_id"`
Identifier string `json:"identifier"`
ExchangeNo string `json:"exchange_no"`
CanView bool `json:"can_view"`
} `json:"previous_asset"`
NextAsset *struct {
AssetID *uint `json:"asset_id"`
Identifier string `json:"identifier"`
ExchangeNo string `json:"exchange_no"`
CanView bool `json:"can_view"`
} `json:"next_asset"`
} `json:"exchange_trace"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &success); err != nil {
t.Fatalf("解析资产详情响应失败:%v", err)
}
if success.Code != errors.CodeSuccess || success.Data.AssetID != middleCard.ID || success.Data.ExchangeTrace.PreviousAsset == nil || success.Data.ExchangeTrace.NextAsset == nil {
t.Fatalf("双向换货响应不完整:%s", body)
}
previous := success.Data.ExchangeTrace.PreviousAsset
if previous.CanView || previous.AssetID != nil || previous.Identifier != "HTTP 历史前代快照" || previous.ExchangeNo != "UR86-HTTP-PREV" {
t.Fatalf("不可见前代未按契约隐藏 ID%s", body)
}
next := success.Data.ExchangeTrace.NextAsset
if !next.CanView || next.AssetID == nil || *next.AssetID != nextCard.ID || next.Identifier != "HTTP 历史后代快照" || next.ExchangeNo != "UR86-HTTP-NEXT" {
t.Fatalf("可见后代未按契约返回:%s", body)
}
noTraceCard := env.createCard(t, 4, &visibleShop.ID)
status, body = env.request(t, "/api/admin/assets/resolve/"+noTraceCard.ICCID, token)
assertHTTPTraceDirections(t, status, body, false, false)
previousOnlyOld := env.createCard(t, 5, &visibleShop.ID)
previousOnlyNew := env.createCard(t, 6, &visibleShop.ID)
env.createOrder(t, "UR86-HTTP-PREV-ONLY", previousOnlyOld, previousOnlyNew, "仅前代旧快照", "仅前代新快照", time.Now())
status, body = env.request(t, "/api/admin/assets/resolve/"+previousOnlyNew.ICCID, token)
assertHTTPTraceDirections(t, status, body, true, false)
nextOnlyOld := env.createCard(t, 7, &visibleShop.ID)
nextOnlyNew := env.createCard(t, 8, &visibleShop.ID)
env.createOrder(t, "UR86-HTTP-NEXT-ONLY", nextOnlyOld, nextOnlyNew, "仅后代旧快照", "仅后代新快照", time.Now())
status, body = env.request(t, "/api/admin/assets/resolve/"+nextOnlyOld.ICCID, token)
assertHTTPTraceDirections(t, status, body, false, true)
status, body = env.request(t, "/api/admin/assets/resolve/"+oldCard.ICCID, token)
if status != http.StatusNotFound {
t.Fatalf("当前资产无权限时应维持防枚举响应,状态 %d%s", status, body)
}
var denied struct {
Code int `json:"code"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &denied); err != nil || denied.Code != errors.CodeNotFound || denied.Data != nil {
t.Fatalf("当前资产无权限响应不符合原契约:%s", body)
}
}
func assertHTTPTraceDirections(t *testing.T, status int, body []byte, hasPrevious, hasNext bool) {
t.Helper()
if status != http.StatusOK {
t.Fatalf("资产详情状态错误,实际 %d%s", status, body)
}
var response struct {
Data struct {
ExchangeTrace struct {
PreviousAsset any `json:"previous_asset"`
NextAsset any `json:"next_asset"`
} `json:"exchange_trace"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析换货方向响应失败:%v", err)
}
if (response.Data.ExchangeTrace.PreviousAsset != nil) != hasPrevious || (response.Data.ExchangeTrace.NextAsset != nil) != hasNext {
t.Fatalf("换货方向空值语义错误previous=%v next=%v body=%s", hasPrevious, hasNext, body)
}
}
type assetTraceHTTPEnv struct {
app *fiber.App
db *gorm.DB
redis *redis.Client
tokenManager *auth.TokenManager
}
func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
t.Helper()
if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
t.Skip("未加载 .env.local跳过依赖真实 PostgreSQL 和 Redis 的资产换货链 HTTP 集成测试")
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("加载测试配置失败:%v", err)
}
log := zap.NewNop()
db, err := database.InitPostgreSQL(&cfg.Database, log)
if err != nil {
t.Fatalf("连接 PostgreSQL 失败:%v", err)
}
tx := db.Begin()
if tx.Error != nil {
t.Fatalf("开启资产换货链测试事务失败:%v", tx.Error)
}
redisClient, err := database.NewRedisClient(database.RedisConfig{
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port), Password: cfg.Redis.Password, DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize, MinIdleConns: cfg.Redis.MinIdleConns,
DialTimeout: cfg.Redis.DialTimeout, ReadTimeout: cfg.Redis.ReadTimeout, WriteTimeout: cfg.Redis.WriteTimeout,
}, log)
if err != nil {
t.Fatalf("连接 Redis 失败:%v", err)
}
t.Cleanup(func() {
_ = tx.Rollback().Error
_ = redisClient.Close()
if sqlDB, dbErr := db.DB(); dbErr == nil {
_ = sqlDB.Close()
}
})
shopStore := postgres.NewShopStore(tx, redisClient)
deviceStore := postgres.NewDeviceStore(tx, redisClient)
cardStore := postgres.NewIotCardStore(tx, redisClient)
assetSvc := assetService.New(
tx, deviceStore, cardStore,
postgres.NewPackageUsageStore(tx, redisClient), postgres.NewPackageStore(tx), postgres.NewPackageSeriesStore(tx),
postgres.NewDeviceSimBindingStore(tx, redisClient), shopStore, redisClient, nil, nil,
postgres.NewAssetIdentifierStore(tx), postgres.NewOrderStore(tx, redisClient), postgres.NewOrderItemStore(tx, redisClient),
postgres.NewExchangeOrderStore(tx), nil,
)
handler := admin.NewAssetHandler(assetSvc, nil, nil, nil, nil, nil, assetQuery.NewExchangeTraceQuery(tx, log))
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token)
if validateErr != nil {
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
}
return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, Username: info.Username, ShopID: info.ShopID}, nil
},
ShopStore: shopStore,
})
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(log)})
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{Asset: handler}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager}
}
func (e *assetTraceHTTPEnv) createShop(t *testing.T, name string, parentID *uint, level int) *model.Shop {
t.Helper()
shop := &model.Shop{ShopName: name, ShopCode: fmt.Sprintf("UR86-%d", time.Now().UnixNano()), ParentID: parentID, Level: level, Status: constants.ShopStatusEnabled}
if err := e.db.Create(shop).Error; err != nil {
t.Fatalf("创建资产换货链测试店铺失败:%v", err)
}
return shop
}
func (e *assetTraceHTTPEnv) createCard(t *testing.T, suffix int, shopID *uint) *model.IotCard {
t.Helper()
iccid := fmt.Sprintf("8986333333333333%04d", suffix)
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], VirtualNo: fmt.Sprintf("UR86-HTTP-CARD-%04d", suffix), ShopID: shopID, Status: constants.IotCardStatusInStock, AssetStatus: constants.AssetStatusInStock}
if err := e.db.Create(card).Error; err != nil {
t.Fatalf("创建资产换货链 HTTP 测试卡失败:%v", err)
}
return card
}
func (e *assetTraceHTTPEnv) createOrder(t *testing.T, exchangeNo string, oldCard, newCard *model.IotCard, oldSnapshot, newSnapshot string, completedAt time.Time) {
t.Helper()
newID := newCard.ID
order := &model.ExchangeOrder{
ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect,
OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: oldSnapshot,
NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: newSnapshot,
ExchangeReason: "UR86 HTTP 测试", Status: constants.ExchangeStatusCompleted, CompletedAt: &completedAt,
}
if err := e.db.Create(order).Error; err != nil {
t.Fatalf("创建资产换货链 HTTP 测试单失败:%v", err)
}
}
func (e *assetTraceHTTPEnv) newAgentToken(t *testing.T, shopID uint) string {
t.Helper()
info := &auth.TokenInfo{UserID: uint(time.Now().UnixNano() % 1_000_000_000), UserType: constants.UserTypeAgent, ShopID: shopID, Username: "UR86测试代理"}
accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), info)
if err != nil {
t.Fatalf("创建资产换货链测试令牌失败:%v", err)
}
t.Cleanup(func() {
_ = e.tokenManager.RevokeToken(context.Background(), accessToken)
_ = e.tokenManager.RevokeToken(context.Background(), refreshToken)
_ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err()
})
return accessToken
}
func (e *assetTraceHTTPEnv) request(t *testing.T, path, token string) (int, []byte) {
t.Helper()
request, err := http.NewRequest(http.MethodGet, path, nil)
if err != nil {
t.Fatalf("创建资产换货链 HTTP 请求失败:%v", err)
}
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
response, err := e.app.Test(request, -1)
if err != nil {
t.Fatalf("执行资产换货链 HTTP 请求失败:%v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("读取资产换货链 HTTP 响应失败:%v", err)
}
return response.StatusCode, body
}