实现换货资产快照与新旧独立搜索

This commit is contained in:
2026-07-22 16:37:08 +09:00
parent 785907ce85
commit 55bdc3a8d0
14 changed files with 1060 additions and 128 deletions

View File

@@ -0,0 +1,243 @@
package exchange
import (
"context"
"fmt"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"go.uber.org/zap"
"gorm.io/gorm"
)
// TestResolveAssetByIdentifierUsesAuthoritativeSnapshot 验证任意受支持标识都生成权威换货快照。
func TestResolveAssetByIdentifierUsesAuthoritativeSnapshot(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
service := &Service{
iotCardStore: postgres.NewIotCardStore(tx, nil),
deviceStore: postgres.NewDeviceStore(tx, nil),
}
ctx := context.Background()
card := &model.IotCard{ICCID: "89860012345678901234", ICCID19: "8986001234567890123", MSISDN: "13800138000", VirtualNo: "UR45-CARD", AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(card).Error; err != nil {
t.Fatalf("创建测试卡失败:%v", err)
}
for _, identifier := range []string{card.ICCID, card.MSISDN, card.VirtualNo} {
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeIotCard, identifier)
if err != nil {
t.Fatalf("通过 %s 解析测试卡失败:%v", identifier, err)
}
if asset.Identifier != card.ICCID {
t.Fatalf("卡快照应为 ICCID输入 %s实际 %s", identifier, asset.Identifier)
}
}
device := &model.Device{VirtualNo: "UR45-DEVICE", IMEI: "860000000000001", SN: "UR45-SN-1", AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(device).Error; err != nil {
t.Fatalf("创建设备失败:%v", err)
}
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeDevice, device.IMEI)
if err != nil {
t.Fatalf("解析设备失败:%v", err)
}
if asset.Identifier != device.VirtualNo {
t.Fatalf("设备应优先保存虚拟号,实际 %s", asset.Identifier)
}
for _, testCase := range []struct {
device *model.Device
expected string
}{
{device: &model.Device{IMEI: "860000000000002", SN: "UR45-SN-2"}, expected: "860000000000002"},
{device: &model.Device{SN: "UR45-SN-3"}, expected: "UR45-SN-3"},
} {
if actual := newResolvedDeviceAsset(testCase.device).Identifier; actual != testCase.expected {
t.Fatalf("设备快照优先级错误,期望 %s实际 %s", testCase.expected, actual)
}
}
}
// TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots 验证三个写入入口持久化卡的权威 ICCID。
func TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots(t *testing.T) {
testCases := []struct {
name string
identifier func(*model.IotCard) string
}{
{name: "ICCID", identifier: func(card *model.IotCard) string { return card.ICCID }},
{name: "接入号", identifier: func(card *model.IotCard) string { return card.MSISDN }},
{name: "虚拟号", identifier: func(card *model.IotCard) string { return card.VirtualNo }},
}
for index, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
service := newExchangeSnapshotTestService(tx)
oldCard := createExchangeSnapshotCard(t, tx, index*10+1)
newCard := createExchangeSnapshotCard(t, tx, index*10+2)
direct, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
OldAssetType: constants.ExchangeAssetTypeIotCard,
OldIdentifier: testCase.identifier(oldCard),
FlowType: constants.ExchangeFlowTypeDirect,
NewIdentifier: testCase.identifier(newCard),
ExchangeReason: "UR45 卡快照测试",
})
if err != nil {
t.Fatalf("创建直接换货失败:%v", err)
}
if direct.OldAssetIdentifier != oldCard.ICCID || direct.NewAssetIdentifier != newCard.ICCID {
t.Fatalf("直接换货卡快照错误old=%s new=%s", direct.OldAssetIdentifier, direct.NewAssetIdentifier)
}
shippingOld := createExchangeSnapshotCard(t, tx, index*10+3)
shippingNew := createExchangeSnapshotCard(t, tx, index*10+4)
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
OldAssetType: constants.ExchangeAssetTypeIotCard,
OldIdentifier: testCase.identifier(shippingOld),
FlowType: constants.ExchangeFlowTypeShipping,
ExchangeReason: "UR45 物流换货快照测试",
})
if err != nil {
t.Fatalf("创建物流换货失败:%v", err)
}
if shipping.OldAssetIdentifier != shippingOld.ICCID {
t.Fatalf("物流换货旧卡快照应为 ICCID实际 %s", shipping.OldAssetIdentifier)
}
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Updates(map[string]any{
"status": constants.ExchangeStatusPendingShip, "recipient_name": "测试用户",
"recipient_phone": "13800138000", "recipient_address": "测试地址",
}).Error; err != nil {
t.Fatalf("建立待发货测试前置状态失败:%v", err)
}
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
ExpressCompany: "测试快递", ExpressNo: "UR45-EXPRESS", NewIdentifier: testCase.identifier(shippingNew),
})
if err != nil {
t.Fatalf("物流换货发货失败:%v", err)
}
if shipped.NewAssetIdentifier != shippingNew.ICCID {
t.Fatalf("物流换货新卡快照应为 ICCID实际 %s", shipped.NewAssetIdentifier)
}
})
}
}
// TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots 验证设备输入标识不影响稳定快照优先级。
func TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots(t *testing.T) {
testCases := []struct {
name string
identifier func(*model.Device) string
}{
{name: "虚拟号", identifier: func(device *model.Device) string { return device.VirtualNo }},
{name: "IMEI", identifier: func(device *model.Device) string { return device.IMEI }},
{name: "SN", identifier: func(device *model.Device) string { return device.SN }},
}
for index, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
service := newExchangeSnapshotTestService(tx)
oldDevice := createExchangeSnapshotDevice(t, tx, index*10+1, true, true)
newDevice := createExchangeSnapshotDevice(t, tx, index*10+2, true, true)
order, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
OldAssetType: constants.ExchangeAssetTypeDevice,
OldIdentifier: testCase.identifier(oldDevice),
FlowType: constants.ExchangeFlowTypeDirect,
NewIdentifier: testCase.identifier(newDevice),
ExchangeReason: "UR45 设备快照测试",
})
if err != nil {
t.Fatalf("创建设备直接换货失败:%v", err)
}
if order.OldAssetIdentifier != oldDevice.VirtualNo || order.NewAssetIdentifier != newDevice.VirtualNo {
t.Fatalf("设备快照应优先使用虚拟号old=%s new=%s", order.OldAssetIdentifier, order.NewAssetIdentifier)
}
persisted, err := service.Get(context.Background(), order.ID)
if err != nil || persisted.OldAssetIdentifier != oldDevice.VirtualNo || persisted.NewAssetIdentifier != newDevice.VirtualNo {
t.Fatalf("详情未读回设备权威快照order=%+v err=%v", persisted, err)
}
})
}
for index, testCase := range testCases {
t.Run("物流"+testCase.name, func(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
service := newExchangeSnapshotTestService(tx)
oldDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+1, true, true)
newDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+2, true, true)
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
OldAssetType: constants.ExchangeAssetTypeDevice, OldIdentifier: testCase.identifier(oldDevice),
FlowType: constants.ExchangeFlowTypeShipping, ExchangeReason: "UR45 设备物流快照测试",
})
if err != nil {
t.Fatalf("创建设备物流换货失败:%v", err)
}
if shipping.OldAssetIdentifier != oldDevice.VirtualNo {
t.Fatalf("物流创建设备旧快照应使用虚拟号,实际 %s", shipping.OldAssetIdentifier)
}
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Update("status", constants.ExchangeStatusPendingShip).Error; err != nil {
t.Fatalf("建立待发货状态失败:%v", err)
}
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
ExpressCompany: "测试快递", ExpressNo: "UR45-DEVICE-EXPRESS", NewIdentifier: testCase.identifier(newDevice),
})
if err != nil {
t.Fatalf("设备物流换货发货失败:%v", err)
}
if shipped.OldAssetIdentifier != oldDevice.VirtualNo || shipped.NewAssetIdentifier != newDevice.VirtualNo {
t.Fatalf("设备物流快照应使用虚拟号old=%s new=%s", shipped.OldAssetIdentifier, shipped.NewAssetIdentifier)
}
})
}
}
func newExchangeSnapshotTestService(tx *gorm.DB) *Service {
iotCardStore := postgres.NewIotCardStore(tx, nil)
deviceStore := postgres.NewDeviceStore(tx, nil)
return New(
tx,
postgres.NewExchangeOrderStore(tx),
iotCardStore,
deviceStore,
postgres.NewAssetWalletStore(tx, nil),
postgres.NewAssetWalletTransactionStore(tx, nil),
postgres.NewPackageUsageStore(tx, nil),
postgres.NewPackageUsageDailyRecordStore(tx, nil),
postgres.NewResourceTagStore(tx),
customerBindingSvc.New(tx, iotCardStore, deviceStore),
zap.NewNop(),
)
}
func createExchangeSnapshotCard(t *testing.T, tx *gorm.DB, suffix int) *model.IotCard {
t.Helper()
iccid := fmt.Sprintf("8986000000000000%04d", suffix)
card := &model.IotCard{
ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1390000%04d", suffix),
VirtualNo: fmt.Sprintf("UR45-CARD-%04d", suffix), AssetStatus: constants.AssetStatusInStock,
}
if err := tx.Create(card).Error; err != nil {
t.Fatalf("创建测试卡失败:%v", err)
}
return card
}
func createExchangeSnapshotDevice(t *testing.T, tx *gorm.DB, suffix int, withVirtualNo, withIMEI bool) *model.Device {
t.Helper()
device := &model.Device{SN: fmt.Sprintf("UR45-SN-%04d", suffix), AssetStatus: constants.AssetStatusInStock}
if withVirtualNo {
device.VirtualNo = fmt.Sprintf("UR45-DEVICE-%04d", suffix)
}
if withIMEI {
device.IMEI = fmt.Sprintf("86000000000%04d", suffix)
}
if err := tx.Create(device).Error; err != nil {
t.Fatalf("创建设备失败:%v", err)
}
return device
}

View File

@@ -5,9 +5,9 @@ import (
"strings"
"time"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -112,47 +112,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
return s.toExchangeOrderResponse(order), nil
}
func (s *Service) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
page := req.Page
page = max(page, 1)
pageSize := req.PageSize
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
filters := make(map[string]any)
if req.Status != nil {
filters["status"] = *req.Status
}
if req.FlowType != "" {
filters["flow_type"] = normalizeExchangeFlowType(req.FlowType)
}
if req.Identifier != "" {
filters["identifier"] = req.Identifier
}
if req.CreatedAtStart != nil {
filters["created_at_start"] = *req.CreatedAtStart
}
if req.CreatedAtEnd != nil {
filters["created_at_end"] = *req.CreatedAtEnd
}
orders, total, err := s.exchangeStore.List(ctx, filters, page, pageSize)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
}
list := make([]*dto.ExchangeOrderResponse, 0, len(orders))
for _, item := range orders {
list = append(list, s.toExchangeOrderResponse(item))
}
return &dto.ExchangeListResponse{List: list, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
@@ -433,7 +392,7 @@ func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetTyp
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
return newResolvedDeviceAsset(device), nil
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
@@ -446,7 +405,7 @@ func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetTyp
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}, nil
return newResolvedIotCardAsset(card), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
@@ -684,7 +643,7 @@ func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetI
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.VirtualNo, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}, nil
return newResolvedIotCardAsset(card), nil
}
if assetType == constants.ExchangeAssetTypeDevice {
device, err := s.deviceStore.GetByID(ctx, assetID)
@@ -694,7 +653,7 @@ func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetI
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
return newResolvedDeviceAsset(device), nil
}
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
}
@@ -710,7 +669,7 @@ func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, asset
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.VirtualNo, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
return newResolvedIotCardAsset(&card), nil
}
if assetType == constants.ExchangeAssetTypeDevice {
var device model.Device
@@ -722,7 +681,7 @@ func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, asset
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(&device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: &device}, nil
return newResolvedDeviceAsset(&device), nil
}
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
}
@@ -738,7 +697,7 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: &device}, nil
return newResolvedDeviceAsset(&device), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
@@ -753,7 +712,7 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
return newResolvedIotCardAsset(&card), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
@@ -761,6 +720,16 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
return nil, errors.New(errors.CodeAssetNotFound)
}
// newResolvedIotCardAsset 将卡的权威 ICCID 固化为换货快照,避免请求标识污染历史记录。
func newResolvedIotCardAsset(card *model.IotCard) *resolvedExchangeAsset {
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}
}
// newResolvedDeviceAsset 按虚拟号、IMEI、SN 的稳定优先级生成设备换货快照。
func newResolvedDeviceAsset(device *model.Device) *resolvedExchangeAsset {
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}
}
func (s *Service) ensureNoActiveExchangeWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
var count int64
query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).