feat(换货): AUG26-005 换货业务数据迁移状态与失败恢复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m42s

- 新增成对迁移 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),
  旧资产带标签时迁移最后一步失败,待另立变更修复
This commit is contained in:
2026-09-14 18:32:26 +08:00
parent c7f9e005af
commit 93e072e1e2
20 changed files with 597 additions and 125 deletions

View File

@@ -100,7 +100,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: flowType == constants.ExchangeFlowTypeDirect && migrateData,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
// 创建阶段不写迁移意图:物流换货的唯一写入点是发货,直接换货在同一事务内完成。
MigrationStatus: constants.ExchangeMigrationStatusNotMigrated,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if asset.ShopID != nil {
order.ShopID = asset.ShopID
@@ -137,6 +139,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
if directErr != nil {
order.ID = 0
order.Status = constants.ExchangeStatusPendingInfo
order.MigrationStatus = constants.ExchangeMigrationStatusNotMigrated
order.MigrationCompleted = false
order.MigrationBalance = 0
order.CompletedAt = nil
@@ -246,6 +249,12 @@ func (s *Service) Complete(ctx context.Context, id uint) error {
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)
@@ -258,10 +267,18 @@ func (s *Service) Complete(ctx context.Context, id uint) error {
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 {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err)
if isExchangeMigrationFailure(err) {
// 迁移失败已在主事务回滚,失败状态、安全化原因与失败审计在回滚后短事务内落库。
s.recordExchangeMigrationFailure(ctx, order, err)
} else {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err)
}
}
return err
}
@@ -740,6 +757,11 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re
}
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{
@@ -749,6 +771,7 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re
"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),
@@ -764,6 +787,7 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re
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,
@@ -821,11 +845,19 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
}
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,
"completed_at": now,
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": now,
"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
@@ -840,9 +872,11 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance}
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
@@ -861,7 +895,7 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
}
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},
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)
@@ -879,7 +913,7 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
}
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},
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)
@@ -1222,39 +1256,47 @@ func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.Excha
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,
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,
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,
}
}