Files
junhong_cmp_fiber/internal/service/exchange/service.go
break 93e072e1e2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m42s
feat(换货): AUG26-005 换货业务数据迁移状态与失败恢复
- 新增成对迁移 000222:tb_exchange_order 增加非空 migration_status 与
  migration_failure_reason,按既有 migrate_data/migration_completed 回填历史,
  并加四值 CHECK 约束,不新增索引
- 模型与常量定义四种迁移状态及中文名称,保留既有布尔字段兼容语义
- 物流换货创建恒 not_migrated,发货按请求落 pending/not_migrated,
  完成成功写 migrated/not_migrated 并清空失败原因、同步兼容字段
- 直接换货创建即完成,任一步失败整体回滚,不持久化换货单、不产生 failed
- 迁移失败回滚全部业务修改后,在独立短事务内条件更新 failed 与安全失败原因
  并写失败审计,RowsAffected 为 0 时跳过状态写入但仍写审计
- failed 物流单重试仅限超级管理员或平台用户,授权以锁内 FOR UPDATE 判定为准,
  重试从钱包余额起整表重跑;非 failed 单沿用既有完成门禁
- 列表与详情返回迁移状态与中文名称,仅 failed 返回失败原因;既有三字段保持兼容
- 换货导出在「状态」列后新增中文「迁移状态」列,不导出失败原因
- 同步 order-refund-exchange 主 spec 与验证证据,归档本 Change
- 登记 KNOWN-ISSUE-001:既有标签复制 OnConflict 未声明部分索引谓词(42P10),
  旧资产带标签时迁移最后一步失败,待另立变更修复
2026-09-14 18:32:26 +08:00

1314 lines
55 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}