package exchange import ( "context" "strings" "time" exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "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" "github.com/break/junhong_cmp_fiber/pkg/middleware" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Service struct { db *gorm.DB exchangeStore *postgres.ExchangeOrderStore refundStore *postgres.RefundStore iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore assetWalletStore *postgres.AssetWalletStore assetWalletTransactionStore *postgres.AssetWalletTransactionStore packageUsageStore *postgres.PackageUsageStore packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore resourceTagStore *postgres.ResourceTagStore customerBinding *customerBindingSvc.Service shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier auditWriter *audit.Writer logger *zap.Logger } func New( db *gorm.DB, exchangeStore *postgres.ExchangeOrderStore, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, assetWalletStore *postgres.AssetWalletStore, assetWalletTransactionStore *postgres.AssetWalletTransactionStore, packageUsageStore *postgres.PackageUsageStore, packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore, resourceTagStore *postgres.ResourceTagStore, customerBinding *customerBindingSvc.Service, logger *zap.Logger, ) *Service { return &Service{ db: db, exchangeStore: exchangeStore, refundStore: postgres.NewRefundStore(db), iotCardStore: iotCardStore, deviceStore: deviceStore, assetWalletStore: assetWalletStore, assetWalletTransactionStore: assetWalletTransactionStore, packageUsageStore: packageUsageStore, packageUsageDailyRecordStore: packageUsageDailyRecordStore, resourceTagStore: resourceTagStore, customerBinding: customerBinding, logger: logger, } } // SetShippingCreatedNotifier 注入物流换货创建后的可靠通知用例。 func (s *Service) SetShippingCreatedNotifier(notifier *exchangeapp.ShippingCreatedNotifier) { s.shippingCreatedNotifier = notifier } func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*dto.ExchangeOrderResponse, error) { flowType := normalizeExchangeFlowType(req.FlowType) if !isValidExchangeFlowType(flowType) { return nil, errors.New(errors.CodeInvalidParam, "换货流程类型不合法") } if flowType == constants.ExchangeFlowTypeDirect && strings.TrimSpace(req.NewIdentifier) == "" { return nil, errors.New(errors.CodeInvalidParam, "直接换货必须填写新资产标识") } asset, err := s.resolveAssetByIdentifier(ctx, req.OldAssetType, req.OldIdentifier) if err != nil { return nil, err } migrateData := false if req.MigrateData != nil { migrateData = *req.MigrateData } creator := middleware.GetUserIDFromContext(ctx) order := &model.ExchangeOrder{ ExchangeNo: model.GenerateExchangeNo(), FlowType: flowType, OldAssetType: asset.AssetType, OldAssetID: asset.AssetID, OldAssetIdentifier: asset.Identifier, ExchangeReason: req.ExchangeReason, Remark: req.Remark, Status: constants.ExchangeStatusPendingInfo, MigrationCompleted: false, MigrationBalance: 0, MigrateData: flowType == constants.ExchangeFlowTypeDirect && migrateData, // 创建阶段不写迁移意图:物流换货的唯一写入点是发货,直接换货在同一事务内完成。 MigrationStatus: constants.ExchangeMigrationStatusNotMigrated, BaseModel: model.BaseModel{Creator: creator, Updater: creator}, } if asset.ShopID != nil { order.ShopID = asset.ShopID } if !isExchangeableAssetStatus(asset.AssetStatus) { err = oldAssetStatusError(asset.AssetStatus) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err) return nil, err } hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID) if err != nil { err = errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败") s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err) return nil, err } if hasUnfinishedRefund { err = errors.New(errors.CodeExchangeActiveRefund) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err) return nil, err } if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil { err = errors.New(errors.CodeExchangeInProgress) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err) return nil, err } else if err != gorm.ErrRecordNotFound { err = errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败") s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err) return nil, err } if flowType == constants.ExchangeFlowTypeDirect { orderID, directErr := s.createDirectExchange(ctx, req, asset, order) if directErr != nil { order.ID = 0 order.Status = constants.ExchangeStatusPendingInfo order.MigrationStatus = constants.ExchangeMigrationStatusNotMigrated order.MigrationCompleted = false order.MigrationBalance = 0 order.CompletedAt = nil s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡直接换货失败", order, directErr) return nil, directErr } return s.Get(ctx, orderID) } if s.shippingCreatedNotifier == nil { err = errors.New(errors.CodeInternalError, "物流换货通知服务未配置") s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err) return nil, err } requestID := "" if value := middleware.GetRequestIDFromContext(ctx); value != nil { requestID = *value } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if createErr := tx.WithContext(ctx).Create(order).Error; createErr != nil { return errors.Wrap(errors.CodeDatabaseError, createErr, "创建换货单失败") } customerIDs, queryErr := s.customerBinding.ActiveCustomerIDsByAsset(ctx, tx, asset.AssetType, asset.AssetID) if queryErr != nil { return queryErr } for _, customerID := range customerIDs { if notifyErr := s.shippingCreatedNotifier.Notify(ctx, tx, exchangeapp.ShippingCreatedEvent{ ExchangeID: order.ID, ExchangeNo: order.ExchangeNo, CustomerID: customerID, AssetType: asset.AssetType, AssetID: asset.AssetID, AssetIdentifier: asset.Identifier, RequestID: requestID, CorrelationID: requestID, }); notifyErr != nil { return notifyErr } } return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCreated, "已创建卡换货单", constants.AuditResultSuccess, order, asset, nil, map[string]any{"exists": false}, map[string]any{"status": constants.ExchangeStatusPendingInfo}, nil, nil, nil, nil, nil, nil) }) if err != nil { order.ID = 0 s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err) return nil, err } resp := s.toExchangeOrderResponse(order) resp.SubmitterName = s.loadExchangeSubmitterNameBestEffort(ctx, order.Creator) return resp, nil } func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) { order, err := s.exchangeStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeExchangeOrderNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单详情失败") } resp := s.toExchangeOrderResponse(order) resp.SubmitterName = s.loadExchangeSubmitterNameBestEffort(ctx, order.Creator) return resp, nil } func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipRequest) (*dto.ExchangeOrderResponse, error) { order, err := s.exchangeStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeExchangeOrderNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") } if order.Status != constants.ExchangeStatusPendingShip { err = errors.New(errors.CodeExchangeStatusInvalid) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err) return nil, err } if !isShippingExchangeFlow(order.FlowType) { err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货") s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err) return nil, err } if err = s.shipWithTx(ctx, order, req); err != nil { s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货失败", order, err) return nil, err } return s.Get(ctx, id) } func (s *Service) Complete(ctx context.Context, id uint) error { order, err := s.exchangeStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeExchangeOrderNotFound) } return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") } if order.Status != constants.ExchangeStatusShipped { err = errors.New(errors.CodeExchangeStatusInvalid) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err) return err } if !isShippingExchangeFlow(order.FlowType) { err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成") s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err) return err } // 事务外预读只做快速拒绝,授权判定以锁内读到的迁移状态为准。 if !isExchangeMigrationRetryAuthorized(ctx, order.MigrationStatus) { err = errors.New(errors.CodeForbidden, "仅超级管理员或平台用户可重试迁移失败的换货单") s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err) return err } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { lockedOrder, lockErr := s.lockExchangeOrderByID(ctx, tx, id) if lockErr != nil { return lockErr } if !isShippingExchangeFlow(lockedOrder.FlowType) { return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成") } if lockedOrder.Status != constants.ExchangeStatusShipped { return errors.New(errors.CodeExchangeStatusInvalid) } if !isExchangeMigrationRetryAuthorized(ctx, lockedOrder.MigrationStatus) { return errors.New(errors.CodeForbidden, "仅超级管理员或平台用户可重试迁移失败的换货单") } return s.completeExchangeWithTx(ctx, tx, lockedOrder, constants.ExchangeStatusShipped) }) if err != nil { if isExchangeMigrationFailure(err) { // 迁移失败已在主事务回滚,失败状态、安全化原因与失败审计在回滚后短事务内落库。 s.recordExchangeMigrationFailure(ctx, order, err) } else { s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err) } } return err } func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error { order, err := s.exchangeStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeExchangeOrderNotFound) } return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") } if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip { err = errors.New(errors.CodeExchangeStatusInvalid) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err) return err } if !isShippingExchangeFlow(order.FlowType) { err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消") s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err) return err } updates := map[string]any{ "updater": middleware.GetUserIDFromContext(ctx), "updated_at": time.Now(), } if req != nil { updates["remark"] = req.Remark } oldAsset, resolveErr := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID) if resolveErr != nil { s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, resolveErr) return resolveErr } fromStatus := order.Status err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { values := make(map[string]any, len(updates)+1) for key, value := range updates { values[key] = value } values["status"] = constants.ExchangeStatusCancelled result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).Where("id = ? AND status = ?", id, fromStatus).Updates(values) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "取消换货失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid) } order.Status = constants.ExchangeStatusCancelled return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCancelled, "已取消卡换货单", constants.AuditResultSuccess, order, oldAsset, nil, map[string]any{"status": fromStatus}, map[string]any{"status": constants.ExchangeStatusCancelled}, nil, nil, nil, nil, nil, nil) }) if err != nil { order.Status = fromStatus s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, err) } return err } func (s *Service) Renew(ctx context.Context, id uint) error { order, err := s.exchangeStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeExchangeOrderNotFound) } return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") } if order.Status != constants.ExchangeStatusCompleted { err = errors.New(errors.CodeExchangeStatusInvalid) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新被拒绝", order, err) return err } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if order.OldAssetType == constants.ExchangeAssetTypeIotCard { var card model.IotCard if queryErr := tx.WithContext(ctx).Where("id = ?", order.OldAssetID).First(&card).Error; queryErr != nil { if queryErr == gorm.ErrRecordNotFound { return errors.New(errors.CodeAssetNotFound) } return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡失败") } if card.AssetStatus != constants.AssetStatusExchanged { return errors.New(errors.CodeExchangeAssetNotExchanged) } var newCard *model.IotCard if order.NewAssetID != nil && *order.NewAssetID > 0 { var value model.IotCard if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil { if queryErr == gorm.ErrRecordNotFound { return errors.New(errors.CodeAssetNotFound) } return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新卡失败") } newCard = &value } auditBefore, auditErr := s.captureCardExchangeAuditBefore(ctx, tx, &card, newCard) if auditErr != nil { return auditErr } cardBefore := map[string]any{"generation": card.Generation, "asset_status": card.AssetStatus} if updateErr := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{ "generation": card.Generation + 1, "asset_status": constants.AssetStatusInStock, "accumulated_recharge_by_series": "{}", "first_recharge_triggered_by_series": "{}", "updater": middleware.GetUserIDFromContext(ctx), "updated_at": time.Now(), }).Error; updateErr != nil { return errors.Wrap(errors.CodeDatabaseError, updateErr, "重置旧卡转新状态失败") } cardKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &card, VirtualNo: card.VirtualNo}) if cardKey != "" { if unbindErr := s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeIotCard, card.ID, cardKey); unbindErr != nil { return unbindErr } } if deleteErr := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; deleteErr != nil { return errors.Wrap(errors.CodeDatabaseError, deleteErr, "清理旧钱包失败") } shopTag := uint(0) if card.ShopID != nil { shopTag = *card.ShopID } if createErr := tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; createErr != nil { return errors.Wrap(errors.CodeDatabaseError, createErr, "创建新钱包失败") } var renewedCard model.IotCard if queryErr := tx.WithContext(ctx).Where("id = ?", card.ID).First(&renewedCard).Error; queryErr != nil { return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡转新结果失败") } walletResource, resourceErr := loadCardExchangeRenewWalletResource(ctx, tx, card.ID, auditBefore.Wallets) if resourceErr != nil { return resourceErr } extra := cardExchangeOldBindingResources(auditBefore) extra = append(extra, *walletResource) return s.appendCardExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess, order, &renewedCard, newCard, nil, nil, cardBefore, map[string]any{"generation": renewedCard.Generation, "asset_status": renewedCard.AssetStatus}, nil, nil, extra, nil) } var device model.Device if err = tx.Where("id = ?", order.OldAssetID).First(&device).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeAssetNotFound) } return errors.Wrap(errors.CodeDatabaseError, err, "查询旧设备失败") } if device.AssetStatus != constants.AssetStatusExchanged { return errors.New(errors.CodeExchangeAssetNotExchanged) } var newDevice *model.Device if order.NewAssetID != nil && *order.NewAssetID > 0 { var value model.Device if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil { if queryErr == gorm.ErrRecordNotFound { return errors.New(errors.CodeAssetNotFound) } return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新设备失败") } newDevice = &value } deviceAuditBefore, auditErr := s.captureDeviceExchangeAuditBefore(ctx, tx, &device, newDevice) if auditErr != nil { return auditErr } deviceBefore := map[string]any{"generation": device.Generation, "asset_status": device.AssetStatus} if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{ "generation": device.Generation + 1, "asset_status": constants.AssetStatusInStock, "accumulated_recharge_by_series": "{}", "first_recharge_triggered_by_series": "{}", "updater": middleware.GetUserIDFromContext(ctx), "updated_at": time.Now(), }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "重置旧设备转新状态失败") } deviceKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &device, VirtualNo: device.VirtualNo}) if deviceKey != "" { if err = s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeDevice, device.ID, deviceKey); err != nil { return err } } if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败") } shopTag := uint(0) if device.ShopID != nil { shopTag = *device.ShopID } if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败") } var renewedDevice model.Device if queryErr := tx.WithContext(ctx).Where("id = ?", device.ID).First(&renewedDevice).Error; queryErr != nil { return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧设备转新结果失败") } walletResource, resourceErr := loadDeviceExchangeRenewWalletResource(ctx, tx, device.ID, deviceAuditBefore.Wallets) if resourceErr != nil { return resourceErr } extra := deviceExchangeOldCustomerBindingResources(deviceAuditBefore) simResources, resourceErr := loadDeviceExchangeSIMResources(ctx, tx, &renewedDevice, newDevice) if resourceErr != nil { return resourceErr } extra = append(extra, simResources...) extra = append(extra, *walletResource) return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess, order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &renewedDevice}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: newDevice}, nil, nil, deviceBefore, map[string]any{"generation": renewedDevice.Generation, "asset_status": renewedDevice.AssetStatus}, nil, nil, extra, nil) }) if err != nil { s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新失败", order, err) } return err } func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) { asset, err := s.resolveAssetByIdentifier(ctx, "", identifier) if err != nil { return nil, err } if !s.customerOwnsAsset(ctx, asset) { return nil, errors.New(errors.CodeAssetNotFound) } order, err := s.exchangeStore.FindShippingPendingByOldAsset(ctx, asset.AssetType, asset.AssetID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待处理换货单失败") } return &dto.ClientExchangePendingResponse{ ID: order.ID, ExchangeNo: order.ExchangeNo, FlowType: effectiveExchangeFlowType(order.FlowType), Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), StatusText: constants.GetExchangeStatusName(order.Status), ExchangeReason: order.ExchangeReason, CreatedAt: order.CreatedAt, }, nil } func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.ClientShippingInfoRequest) error { order, err := s.exchangeStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeExchangeOrderNotFound) } return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") } if !isShippingExchangeFlow(order.FlowType) { err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息") s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err) return err } if order.Status != constants.ExchangeStatusPendingInfo { err = errors.New(errors.CodeExchangeStatusInvalid) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err) return err } oldAsset, err := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID) if err != nil { s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err) return err } if !s.customerOwnsAsset(ctx, oldAsset) { err = errors.New(errors.CodeExchangeOrderNotFound) s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err) return err } updates := map[string]any{ "recipient_name": req.RecipientName, "recipient_phone": req.RecipientPhone, "recipient_address": req.RecipientAddress, "updated_at": time.Now(), } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { values := make(map[string]any, len(updates)+1) for key, value := range updates { values[key] = value } values["status"] = constants.ExchangeStatusPendingShip result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}). Where("id = ? AND status = ?", id, constants.ExchangeStatusPendingInfo). Updates(values) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "提交收货信息失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid) } order.Status = constants.ExchangeStatusPendingShip return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShippingInfoSubmitted, "已提交卡换货收货信息", constants.AuditResultSuccess, order, oldAsset, nil, map[string]any{"status": constants.ExchangeStatusPendingInfo}, map[string]any{"status": constants.ExchangeStatusPendingShip}, nil, nil, nil, nil, nil, nil) }) if err != nil { order.Status = constants.ExchangeStatusPendingInfo s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err) } return err } type resolvedExchangeAsset struct { AssetType string AssetID uint Identifier string VirtualNo string AssetStatus int ShopID *uint Card *model.IotCard Device *model.Device } func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) { if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice { device, err := s.deviceStore.GetByIdentifier(ctx, identifier) if err == nil { if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice { return nil, errors.New(errors.CodeExchangeAssetTypeMismatch) } return newResolvedDeviceAsset(device), nil } if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") } } if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard { card, err := s.iotCardStore.GetByIdentifier(ctx, identifier) if err == nil { if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard { return nil, errors.New(errors.CodeExchangeAssetTypeMismatch) } return newResolvedIotCardAsset(card), nil } else if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") } } return nil, errors.New(errors.CodeAssetNotFound) } // isExchangeableAssetStatus 判断旧资产状态是否允许发起换货(在库和已销售均可换货) func isExchangeableAssetStatus(status int) bool { return status == constants.AssetStatusInStock || status == constants.AssetStatusSold } // oldAssetStatusError 根据旧资产的实际状态返回具体的错误原因 func oldAssetStatusError(status int) error { switch status { case constants.AssetStatusExchanged: return errors.New(errors.CodeExchangeStatusInvalid, "该资产已完成过换货,如需再次换货请使用换货后的新资产") case constants.AssetStatusDeactivated: return errors.New(errors.CodeExchangeStatusInvalid, "该资产已停用,无法发起换货") default: return errors.New(errors.CodeExchangeStatusInvalid, "该资产当前状态不允许换货") } } func normalizeExchangeFlowType(flowType string) string { flowType = strings.TrimSpace(flowType) if flowType == "" { return constants.ExchangeFlowTypeShipping } return flowType } func effectiveExchangeFlowType(flowType string) string { return normalizeExchangeFlowType(flowType) } func isValidExchangeFlowType(flowType string) bool { return flowType == constants.ExchangeFlowTypeShipping || flowType == constants.ExchangeFlowTypeDirect } func isShippingExchangeFlow(flowType string) bool { return effectiveExchangeFlowType(flowType) == constants.ExchangeFlowTypeShipping } func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset, order *model.ExchangeOrder) (uint, error) { var orderID uint err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { lockedOldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, oldAsset.AssetType, oldAsset.AssetID) if err != nil { return err } if !isExchangeableAssetStatus(lockedOldAsset.AssetStatus) { return oldAssetStatusError(lockedOldAsset.AssetStatus) } if err = s.ensureNoActiveExchangeWithTx(ctx, tx, lockedOldAsset.AssetType, lockedOldAsset.AssetID); err != nil { return err } newAsset, err := s.resolveAssetByIdentifierWithTx(ctx, tx, "", req.NewIdentifier) if err != nil { return err } if newAsset.AssetType != lockedOldAsset.AssetType { return errors.New(errors.CodeExchangeAssetTypeMismatch) } order.OldAssetType = lockedOldAsset.AssetType order.OldAssetID = lockedOldAsset.AssetID order.OldAssetIdentifier = lockedOldAsset.Identifier order.NewAssetType = newAsset.AssetType order.NewAssetID = &newAsset.AssetID order.NewAssetIdentifier = newAsset.Identifier order.ShopID = cloneShopID(lockedOldAsset.ShopID) if err = tx.WithContext(ctx).Create(order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建直接换货单失败") } if err = s.completeExchangeWithTx(ctx, tx, order, constants.ExchangeStatusPendingInfo); err != nil { return err } orderID = order.ID return nil }) if err != nil { return 0, err } return orderID, nil } func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, req *dto.ExchangeShipRequest) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { lockedOrder, err := s.lockExchangeOrderByID(ctx, tx, order.ID) if err != nil { return err } if !isShippingExchangeFlow(lockedOrder.FlowType) { return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货") } if lockedOrder.Status != constants.ExchangeStatusPendingShip { return errors.New(errors.CodeExchangeStatusInvalid) } oldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, lockedOrder.OldAssetType, lockedOrder.OldAssetID) if err != nil { return err } newAsset, err := s.resolveAssetByIdentifierWithTx(ctx, tx, "", req.NewIdentifier) if err != nil { return err } if newAsset.AssetType != lockedOrder.OldAssetType { return errors.New(errors.CodeExchangeAssetTypeMismatch) } if err = s.validateExchangeAssetsWithTx(ctx, tx, lockedOrder.ID, oldAsset, newAsset); err != nil { return err } now := time.Now() // 迁移意图在发货时确定:选择迁移进入待迁移,否则保持不迁移。 migrationStatus := constants.ExchangeMigrationStatusNotMigrated if req.MigrateData { migrationStatus = constants.ExchangeMigrationStatusPending } result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}). Where("id = ? AND status = ?", lockedOrder.ID, constants.ExchangeStatusPendingShip). Updates(map[string]any{ "new_asset_type": newAsset.AssetType, "new_asset_id": newAsset.AssetID, "new_asset_identifier": newAsset.Identifier, "express_company": req.ExpressCompany, "express_no": req.ExpressNo, "migrate_data": req.MigrateData, "migration_status": migrationStatus, "shipped_at": now, "status": constants.ExchangeStatusShipped, "updater": middleware.GetUserIDFromContext(ctx), "updated_at": now, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新换货单发货状态失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid) } lockedOrder.NewAssetType = newAsset.AssetType lockedOrder.NewAssetID = &newAsset.AssetID lockedOrder.NewAssetIdentifier = newAsset.Identifier lockedOrder.MigrateData = req.MigrateData lockedOrder.MigrationStatus = migrationStatus lockedOrder.ShippedAt = &now lockedOrder.Status = constants.ExchangeStatusShipped return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShipped, "卡换货单已发货", constants.AuditResultSuccess, lockedOrder, oldAsset, newAsset, map[string]any{"status": constants.ExchangeStatusPendingShip}, map[string]any{"status": constants.ExchangeStatusShipped}, nil, nil, nil, nil, nil, nil) }) } func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, fromStatus int) error { if order.NewAssetID == nil || *order.NewAssetID == 0 || order.NewAssetIdentifier == "" { return errors.New(errors.CodeInvalidParam, "新资产信息缺失") } oldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, order.OldAssetType, order.OldAssetID) if err != nil { return err } newAsset, err := s.resolveAssetByIDWithTx(ctx, tx, order.NewAssetType, *order.NewAssetID) if err != nil { return err } if err = s.validateExchangeAssetsWithTx(ctx, tx, order.ID, oldAsset, newAsset); err != nil { return err } var auditBefore *cardExchangeAuditBefore var deviceAuditBefore *deviceExchangeAuditBefore if order.OldAssetType == constants.ExchangeAssetTypeIotCard { auditBefore, err = s.captureCardExchangeAuditBefore(ctx, tx, oldAsset.Card, newAsset.Card) if err != nil { return err } } else { deviceAuditBefore, err = s.captureDeviceExchangeAuditBefore(ctx, tx, oldAsset.Device, newAsset.Device) if err != nil { return err } } if err = s.syncNewAssetOwnershipWithTx(ctx, tx, oldAsset, newAsset); err != nil { return err } if err = s.switchCustomerBindingWithTx(ctx, tx, oldAsset, newAsset); err != nil { return err } if err = s.updateAssetStatusesForCompletion(ctx, tx, oldAsset, newAsset); err != nil { return err } var migration *exchangeMigrationResult if order.MigrateData { migration, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset) if err != nil { return err } } now := time.Now() // 成功后迁移状态落到 migrated 或 not_migrated,并清空上一次失败原因。 migrationStatus := constants.ExchangeMigrationStatusNotMigrated if order.MigrateData { migrationStatus = constants.ExchangeMigrationStatusMigrated } beforeMigrationStatus := order.MigrationStatus updates := map[string]any{ "status": constants.ExchangeStatusCompleted, "migration_status": migrationStatus, "migration_failure_reason": "", "completed_at": now, "updater": middleware.GetUserIDFromContext(ctx), "updated_at": now, } if order.MigrateData { updates["migration_completed"] = true updates["migration_balance"] = migration.Balance } result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}). Where("id = ? AND status = ?", order.ID, fromStatus). Updates(updates) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "确认换货完成失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid) } orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance, "migration_status": beforeMigrationStatus} order.Status = constants.ExchangeStatusCompleted order.CompletedAt = &now order.MigrationStatus = migrationStatus order.MigrationFailureReason = "" if migration != nil { order.MigrationCompleted = true order.MigrationBalance = migration.Balance } if order.OldAssetType == constants.ExchangeAssetTypeDevice { var oldDeviceAfter, newDeviceAfter model.Device if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldDeviceAfter).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货旧设备结果失败") } if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newDeviceAfter).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货新设备结果失败") } extra, resourceErr := s.buildDeviceExchangeCompletionResources(ctx, tx, order, &oldDeviceAfter, &newDeviceAfter, deviceAuditBefore, migration) if resourceErr != nil { return resourceErr } return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess, order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &oldDeviceAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &newDeviceAfter}, orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance, "migration_status": order.MigrationStatus}, map[string]any{"asset_status": oldAsset.Device.AssetStatus, "shop_id": oldAsset.Device.ShopID}, map[string]any{"asset_status": oldDeviceAfter.AssetStatus, "shop_id": oldDeviceAfter.ShopID}, map[string]any{"asset_status": newAsset.Device.AssetStatus, "shop_id": newAsset.Device.ShopID}, map[string]any{"asset_status": newDeviceAfter.AssetStatus, "shop_id": newDeviceAfter.ShopID}, extra, nil) } var oldCardAfter, newCardAfter model.IotCard if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldCardAfter).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货旧卡结果失败") } if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newCardAfter).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货新卡结果失败") } extra, err := s.buildCardExchangeCompletionResources(ctx, tx, order, &oldCardAfter, &newCardAfter, auditBefore, migration) if err != nil { return err } return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess, order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &oldCardAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &newCardAfter}, orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance, "migration_status": order.MigrationStatus}, map[string]any{"asset_status": oldAsset.Card.AssetStatus, "shop_id": oldAsset.Card.ShopID}, map[string]any{"asset_status": oldCardAfter.AssetStatus, "shop_id": oldCardAfter.ShopID}, map[string]any{"asset_status": newAsset.Card.AssetStatus, "shop_id": newAsset.Card.ShopID}, map[string]any{"asset_status": newCardAfter.AssetStatus, "shop_id": newCardAfter.ShopID}, extra, nil) } func (s *Service) lockExchangeOrderByID(ctx context.Context, tx *gorm.DB, id uint) (*model.ExchangeOrder, error) { var order model.ExchangeOrder query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id) query = middleware.ApplyShopFilter(ctx, query) if err := query.First(&order).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeExchangeOrderNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") } return &order, nil } func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetID uint) (*resolvedExchangeAsset, error) { if assetType == constants.ExchangeAssetTypeIotCard { card, err := s.iotCardStore.GetByID(ctx, assetID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeAssetNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") } return newResolvedIotCardAsset(card), nil } if assetType == constants.ExchangeAssetTypeDevice { device, err := s.deviceStore.GetByID(ctx, assetID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeAssetNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") } return newResolvedDeviceAsset(device), nil } return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法") } func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*resolvedExchangeAsset, error) { if assetType == constants.ExchangeAssetTypeIotCard { var card model.IotCard query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", assetID) query = middleware.ApplyShopFilter(ctx, query) if err := query.First(&card).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeAssetNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") } return newResolvedIotCardAsset(&card), nil } if assetType == constants.ExchangeAssetTypeDevice { var device model.Device query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", assetID) query = middleware.ApplyShopFilter(ctx, query) if err := query.First(&device).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeAssetNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") } return newResolvedDeviceAsset(&device), nil } return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法") } func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.DB, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) { if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice { var device model.Device query := tx.WithContext(ctx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier) query = middleware.ApplyShopFilter(ctx, query) if err := query.First(&device).Error; err == nil { if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice { return nil, errors.New(errors.CodeExchangeAssetTypeMismatch) } return newResolvedDeviceAsset(&device), nil } else if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") } } if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard { var card model.IotCard query := tx.WithContext(ctx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("virtual_no = ? OR iccid = ? OR msisdn = ? OR iccid_19 = ? OR iccid_20 = ?", identifier, identifier, identifier, identifier, identifier) query = middleware.ApplyShopFilter(ctx, query) if err := query.First(&card).Error; err == nil { if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard { return nil, errors.New(errors.CodeExchangeAssetTypeMismatch) } return newResolvedIotCardAsset(&card), nil } else if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") } } 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{}). Where("old_asset_type = ? AND old_asset_id = ?", assetType, assetID). Where("status IN ?", []int{constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped}) query = middleware.ApplyShopFilter(ctx, query) if err := query.Count(&count).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败") } if count > 0 { return errors.New(errors.CodeExchangeInProgress) } return nil } func (s *Service) validateExchangeAssetsWithTx(ctx context.Context, tx *gorm.DB, orderID uint, oldAsset, newAsset *resolvedExchangeAsset) error { if oldAsset.AssetType != newAsset.AssetType { return errors.New(errors.CodeExchangeAssetTypeMismatch) } if !isExchangeableAssetStatus(oldAsset.AssetStatus) { return oldAssetStatusError(oldAsset.AssetStatus) } if err := s.ensureNewAssetBindingAvailableWithTx(ctx, tx, oldAsset, newAsset); err != nil { return err } occupied, err := s.hasShippingNewAssetOccupiedWithTx(ctx, tx, newAsset.AssetType, newAsset.AssetID, orderID) if err != nil { return err } if occupied { return errors.New(errors.CodeExchangeStatusInvalid, "新资产已被其他换货单占用") } return nil } func (s *Service) ensureNewAssetBindingAvailableWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { newKey := exchangeAssetBindingKey(newAsset) // 新资产无虚拟号时不再拦截:Migrate() 已能正确处理无绑定跳过、有绑定迁移到 pci 的情形 if newKey == "" { return nil } var newBindCount int64 if err := tx.WithContext(ctx).Model(&model.PersonalCustomerDevice{}). Where("virtual_no = ? AND status = ?", newKey, constants.StatusEnabled). Count(&newBindCount).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询新资产客户绑定失败") } if newBindCount > 0 { return errors.New(errors.CodeExchangeStatusInvalid, "新资产存在有效客户绑定,不可用于换货") } return nil } func (s *Service) switchCustomerBindingWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { return s.customerBinding.Migrate(ctx, tx, oldAsset.AssetType, oldAsset.AssetID, newAsset.AssetType, newAsset.AssetID) } // syncNewAssetOwnershipWithTx 将新资产归属同步为旧资产当前归属。 // 归属继承不受 migrate_data 控制,避免平台库存资产换入店铺后仍处于平台租户范围。 func (s *Service) syncNewAssetOwnershipWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { ownershipStatus := constants.IotCardStatusInStock if oldAsset.ShopID != nil { ownershipStatus = constants.IotCardStatusDistributed } modelValue := any(&model.IotCard{}) if newAsset.AssetType == constants.ExchangeAssetTypeDevice { modelValue = &model.Device{} ownershipStatus = constants.DeviceStatusInStock if oldAsset.ShopID != nil { ownershipStatus = constants.DeviceStatusDistributed } } result := tx.WithContext(ctx).Model(modelValue). Where("id = ?", newAsset.AssetID). Updates(map[string]any{ "shop_id": oldAsset.ShopID, "status": ownershipStatus, "updated_at": time.Now(), }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "同步新资产店铺归属失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeAssetNotFound) } shopIDTag := uint(0) if oldAsset.ShopID != nil { shopIDTag = *oldAsset.ShopID } if err := tx.WithContext(ctx).Model(&model.AssetWallet{}). Where("resource_type = ? AND resource_id = ?", newAsset.AssetType, newAsset.AssetID). Updates(map[string]any{"shop_id_tag": shopIDTag, "updated_at": time.Now()}).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "同步新资产钱包店铺标签失败") } newAsset.ShopID = cloneShopID(oldAsset.ShopID) return nil } func cloneShopID(shopID *uint) *uint { if shopID == nil { return nil } value := *shopID return &value } func (s *Service) updateAssetStatusesForCompletion(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { now := time.Now() if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard { result := tx.WithContext(ctx).Model(&model.IotCard{}). Where("id = ? AND asset_status IN ?", oldAsset.AssetID, []int{constants.AssetStatusInStock, constants.AssetStatusSold}). Updates(map[string]any{"asset_status": constants.AssetStatusExchanged, "updated_at": now}) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新旧卡状态失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid, "旧资产状态已被修改,请重试") } result = tx.WithContext(ctx).Model(&model.IotCard{}). Where("id = ?", newAsset.AssetID). Updates(map[string]any{"asset_status": constants.AssetStatusSold, "updated_at": now}) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新新卡状态失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeAssetNotFound) } return nil } result := tx.WithContext(ctx).Model(&model.Device{}). Where("id = ? AND asset_status IN ?", oldAsset.AssetID, []int{constants.AssetStatusInStock, constants.AssetStatusSold}). Updates(map[string]any{"asset_status": constants.AssetStatusExchanged, "updated_at": now}) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新旧设备状态失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid, "旧资产状态已被修改,请重试") } result = tx.WithContext(ctx).Model(&model.Device{}). Where("id = ?", newAsset.AssetID). Updates(map[string]any{"asset_status": constants.AssetStatusSold, "updated_at": now}) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新新设备状态失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeAssetNotFound) } return nil } func (s *Service) hasShippingNewAssetOccupiedWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, excludeOrderID uint) (bool, error) { var count int64 query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}). Where("new_asset_type = ? AND new_asset_id = ?", assetType, assetID). Where("status = ?", constants.ExchangeStatusShipped). Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, constants.ExchangeFlowTypeShipping) if excludeOrderID > 0 { query = query.Where("id <> ?", excludeOrderID) } query = middleware.ApplyShopFilter(ctx, query) if err := query.Count(&count).Error; err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新资产占用失败") } return count > 0, nil } func (s *Service) customerOwnsAsset(ctx context.Context, asset *resolvedExchangeAsset) bool { customerID := middleware.GetCustomerIDFromContext(ctx) if customerID == 0 || asset == nil { return false } key := exchangeAssetBindingKey(asset) if key == "" { return false } var count int64 if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerDevice{}). Where("customer_id = ? AND virtual_no = ? AND status = ?", customerID, key, constants.StatusEnabled). Count(&count).Error; err != nil { return false } return count > 0 } func exchangeAssetBindingKey(asset *resolvedExchangeAsset) string { if asset == nil { return "" } if asset.AssetType == constants.ExchangeAssetTypeIotCard { if asset.Card != nil { return asset.Card.VirtualNo } return asset.VirtualNo } if asset.Device == nil { return asset.VirtualNo } if asset.Device.VirtualNo != "" { return asset.Device.VirtualNo } return asset.Device.IMEI } func preferredDeviceIdentifier(device *model.Device) string { if device == nil { return "" } if device.VirtualNo != "" { return device.VirtualNo } if device.IMEI != "" { return device.IMEI } return device.SN } func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.ExchangeOrderResponse { if order == nil { return nil } var deletedAt *time.Time if order.DeletedAt.Valid { deletedAt = &order.DeletedAt.Time } // 失败原因只在迁移失败时对外可见,其他状态一律不返回,避免把历史原因误读为当前状态。 failureReason := "" if order.MigrationStatus == constants.ExchangeMigrationStatusFailed { failureReason = order.MigrationFailureReason } return &dto.ExchangeOrderResponse{ ID: order.ID, ExchangeNo: order.ExchangeNo, FlowType: effectiveExchangeFlowType(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, MigrationStatus: order.MigrationStatus, MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus), MigrationFailureReason: failureReason, 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, SubmitterID: order.Creator, Creator: order.Creator, Updater: order.Updater, } } func (s *Service) loadExchangeSubmitterNameBestEffort(ctx context.Context, id uint) string { accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, []uint{id}) if err != nil { s.logger.Warn("查询换货提交人失败", zap.Uint("submitter_id", id), zap.Error(err)) return "" } if len(accounts) == 0 { return "" } return accounts[0].Username }