// 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 }