feat(换货): AUG26-005 换货业务数据迁移状态与失败恢复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m42s
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:
@@ -38,7 +38,7 @@ func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (in
|
||||
func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符",
|
||||
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间",
|
||||
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "迁移状态", "创建人", "创建时间",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
|
||||
e.express_company,
|
||||
e.express_no,
|
||||
e.status,
|
||||
e.migration_status,
|
||||
e.created_at,
|
||||
COALESCE(ac.username, '') AS creator_name
|
||||
`).
|
||||
@@ -91,6 +92,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
|
||||
item.ExpressCompany,
|
||||
item.ExpressNo,
|
||||
constants.GetExchangeStatusName(item.Status),
|
||||
constants.GetExchangeMigrationStatusName(item.MigrationStatus),
|
||||
item.CreatorName,
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
})
|
||||
@@ -171,6 +173,7 @@ type exchangeExportRow struct {
|
||||
ExpressCompany string `gorm:"column:express_company"`
|
||||
ExpressNo string `gorm:"column:express_no"`
|
||||
Status int `gorm:"column:status"`
|
||||
MigrationStatus string `gorm:"column:migration_status"`
|
||||
CreatorName string `gorm:"column:creator_name"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type ExchangeShipRequest struct {
|
||||
ExpressCompany string `json:"express_company" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递公司"`
|
||||
ExpressNo string `json:"express_no" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递单号"`
|
||||
NewIdentifier string `json:"new_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"新资产输入标识,卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN;保存快照使用权威标识"`
|
||||
MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行, false:不执行)"`
|
||||
MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行,迁移状态置为 pending;false:不执行,迁移状态保持 not_migrated)"`
|
||||
}
|
||||
|
||||
type ExchangeCancelRequest struct {
|
||||
@@ -68,39 +68,42 @@ type ClientShippingInfoParams struct {
|
||||
|
||||
// ExchangeOrderResponse 换货单响应。
|
||||
type ExchangeOrderResponse struct {
|
||||
ID uint `json:"id" description:"换货单ID"`
|
||||
ExchangeNo string `json:"exchange_no" description:"换货单号"`
|
||||
FlowType string `json:"flow_type" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
|
||||
FlowTypeName string `json:"flow_type_name" description:"换货流程类型名称"`
|
||||
OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
|
||||
OldAssetID uint `json:"old_asset_id" description:"旧资产ID"`
|
||||
OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"`
|
||||
NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"`
|
||||
NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"`
|
||||
NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"`
|
||||
RecipientName string `json:"recipient_name" description:"收件人姓名"`
|
||||
RecipientPhone string `json:"recipient_phone" description:"收件人电话"`
|
||||
RecipientAddress string `json:"recipient_address" description:"收货地址"`
|
||||
ExpressCompany string `json:"express_company" description:"快递公司"`
|
||||
ExpressNo string `json:"express_no" description:"快递单号"`
|
||||
MigrateData bool `json:"migrate_data" description:"是否执行全量迁移"`
|
||||
MigrationCompleted bool `json:"migration_completed" description:"迁移是否已完成"`
|
||||
MigrationBalance int64 `json:"migration_balance" description:"迁移转移金额(分)"`
|
||||
ShippedAt *time.Time `json:"shipped_at,omitempty" description:"发货时间"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"换货完成时间"`
|
||||
ExchangeReason string `json:"exchange_reason" description:"换货原因"`
|
||||
Remark *string `json:"remark,omitempty" description:"备注"`
|
||||
Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
|
||||
StatusName string `json:"status_name" description:"换货状态名称(中文)"`
|
||||
StatusText string `json:"status_text" description:"换货状态文本"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
ID uint `json:"id" description:"换货单ID"`
|
||||
ExchangeNo string `json:"exchange_no" description:"换货单号"`
|
||||
FlowType string `json:"flow_type" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
|
||||
FlowTypeName string `json:"flow_type_name" description:"换货流程类型名称"`
|
||||
OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
|
||||
OldAssetID uint `json:"old_asset_id" description:"旧资产ID"`
|
||||
OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"`
|
||||
NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"`
|
||||
NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"`
|
||||
NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"`
|
||||
RecipientName string `json:"recipient_name" description:"收件人姓名"`
|
||||
RecipientPhone string `json:"recipient_phone" description:"收件人电话"`
|
||||
RecipientAddress string `json:"recipient_address" description:"收货地址"`
|
||||
ExpressCompany string `json:"express_company" description:"快递公司"`
|
||||
ExpressNo string `json:"express_no" description:"快递单号"`
|
||||
MigrateData bool `json:"migrate_data" description:"是否执行全量迁移"`
|
||||
MigrationCompleted bool `json:"migration_completed" description:"迁移是否已完成"`
|
||||
MigrationBalance int64 `json:"migration_balance" description:"迁移转移金额(分)"`
|
||||
MigrationStatus string `json:"migration_status" enum:"not_migrated,pending,migrated,failed" description:"业务数据迁移状态 (not_migrated:不迁移, pending:待迁移, migrated:已迁移, failed:迁移失败);迁移结果以本字段为准,勿再用布尔字段推断"`
|
||||
MigrationStatusName string `json:"migration_status_name" description:"业务数据迁移状态名称(中文)"`
|
||||
MigrationFailureReason string `json:"migration_failure_reason,omitempty" description:"最近一次业务数据迁移失败原因,仅迁移状态为 failed 时返回"`
|
||||
ShippedAt *time.Time `json:"shipped_at,omitempty" description:"发货时间"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"换货完成时间"`
|
||||
ExchangeReason string `json:"exchange_reason" description:"换货原因"`
|
||||
Remark *string `json:"remark,omitempty" description:"备注"`
|
||||
Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
|
||||
StatusName string `json:"status_name" description:"换货状态名称(中文)"`
|
||||
StatusText string `json:"status_text" description:"换货状态文本"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
}
|
||||
|
||||
type ExchangeListResponse struct {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
// ExchangeOrder 换货单模型
|
||||
// 承载物流换货与直接换货的完整生命周期:后台发起 → 可选客户填写收货信息 → 可选后台发货 → 确认完成(含可选全量迁移) → 旧资产可转新
|
||||
// 状态机:shipping 为 1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成,1/2 时可取消 → 5-已取消;direct 创建即完成为 4
|
||||
// 迁移状态机:物流换货创建为 not_migrated;发货按 migrate_data 写 pending 或 not_migrated;确认完成成功写 migrated、失败写 failed(保持 3 可再次确认完成)
|
||||
// 迁移结果以 migration_status 为准,migrate_data、migration_completed、migration_balance 仅保留兼容语义
|
||||
type ExchangeOrder struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
@@ -45,6 +47,10 @@ type ExchangeOrder struct {
|
||||
MigrationCompleted bool `gorm:"column:migration_completed;type:boolean;default:false;comment:迁移是否已完成" json:"migration_completed"`
|
||||
MigrationBalance int64 `gorm:"column:migration_balance;type:bigint;default:0;comment:迁移转移金额(分)" json:"migration_balance"`
|
||||
|
||||
// 迁移状态(保留上面的布尔字段供兼容客户端与既有业务读取,迁移结果以本字段为准)
|
||||
MigrationStatus string `gorm:"column:migration_status;type:varchar(20);not null;default:'not_migrated';comment:业务数据迁移状态 not_migrated-不迁移 pending-待迁移 migrated-已迁移 failed-迁移失败" json:"migration_status"`
|
||||
MigrationFailureReason string `gorm:"column:migration_failure_reason;type:varchar(500);not null;default:'';comment:最近一次业务数据迁移失败的安全化原因,仅迁移状态为 failed 时非空" json:"migration_failure_reason"`
|
||||
|
||||
// 业务时间
|
||||
ShippedAt *time.Time `gorm:"column:shipped_at;comment:发货时间,仅物流换货发货成功后写入" json:"shipped_at,omitempty"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:换货完成时间,物流换货和直接换货完成后写入" json:"completed_at,omitempty"`
|
||||
|
||||
@@ -126,6 +126,11 @@ func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse
|
||||
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: effectiveFlowType(order.FlowType), FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType),
|
||||
@@ -134,7 +139,9 @@ func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -42,10 +42,12 @@ func registerAdminExchangeRoutes(router fiber.Router, handler *admin.ExchangeHan
|
||||
|
||||
Register(router, doc, basePath, "POST", "/exchanges/:id/complete", handler.Complete, RouteSpec{
|
||||
Summary: "确认换货完成",
|
||||
Tags: []string{"换货管理"},
|
||||
Input: new(dto.ExchangeIDRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
Description: "确认换货完成;仅超级管理员或平台用户可对迁移状态为 failed 的物流换货单重试," +
|
||||
"其他账号对 failed 单返回 403/1005;非 failed 单沿用既有完成权限。",
|
||||
Tags: []string{"换货管理"},
|
||||
Input: new(dto.ExchangeIDRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(router, doc, basePath, "POST", "/exchanges/:id/cancel", handler.Cancel, RouteSpec{
|
||||
|
||||
149
internal/service/exchange/migration_failure.go
Normal file
149
internal/service/exchange/migration_failure.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// exchangeMigrationFailureReasonMaxRunes 是失败原因的最大字符数,与 tb_exchange_order.migration_failure_reason 列宽一致。
|
||||
exchangeMigrationFailureReasonMaxRunes = 500
|
||||
// exchangeMigrationInfrastructureFailureReason 是基础设施类迁移失败的固定安全摘要。
|
||||
// 数据库与内部错误的原文可能包含连接串、SQL 或载荷,不得进入可被列表和详情读取的字段。
|
||||
exchangeMigrationInfrastructureFailureReason = "迁移执行失败,请稍后重试或联系技术支持"
|
||||
)
|
||||
|
||||
// isExchangeMigrationFailure 判断错误链上是否带业务数据迁移失败码。
|
||||
// executeMigrationWithTx 的每个子步骤都以 CodeExchangeMigrationFailed 收口,
|
||||
// 因此该判定等价于「本次确认完成在业务数据迁移步骤失败」;其他步骤失败不写迁移失败状态。
|
||||
// 与 asset_audit.BuildErrorInfo 一致地按标准库错误链接口穿透,链中出现非 AppError 包装时继续向上查找。
|
||||
func isExchangeMigrationFailure(err error) bool {
|
||||
for err != nil {
|
||||
if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeExchangeMigrationFailed {
|
||||
return true
|
||||
}
|
||||
err = stderrors.Unwrap(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isExchangeMigrationRetryAuthorized 判断当前调用者是否可以确认完成该换货单。
|
||||
// 只有迁移状态为 failed 的重试才要求超级管理员或平台用户;其他状态沿用既有完成门禁,
|
||||
// 不削弱代理与企业账号既有的确认完成权限。
|
||||
func isExchangeMigrationRetryAuthorized(ctx context.Context, migrationStatus string) bool {
|
||||
if migrationStatus != constants.ExchangeMigrationStatusFailed {
|
||||
return true
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform
|
||||
}
|
||||
|
||||
// exchangeMigrationFailureReason 生成可安全展示的迁移失败原因。
|
||||
// 只拼接 AppError 链上的中文 Message,丢弃非 AppError 的底层 cause;
|
||||
// 数据库、内部错误等基础设施失败降级为固定安全摘要,禁止把数据库、外部服务或敏感载荷原文写入该字段。
|
||||
func exchangeMigrationFailureReason(err error) string {
|
||||
var parts []string
|
||||
for current := err; current != nil; current = stderrors.Unwrap(current) {
|
||||
appErr, ok := current.(*errors.AppError)
|
||||
if !ok {
|
||||
// 丢弃非 AppError 的底层 cause,禁止把数据库/外部原文写入可展示字段。
|
||||
continue
|
||||
}
|
||||
if isExchangeMigrationInfrastructureCode(appErr.Code) {
|
||||
return exchangeMigrationInfrastructureFailureReason
|
||||
}
|
||||
parts = append(parts, appErr.Message)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return exchangeMigrationInfrastructureFailureReason
|
||||
}
|
||||
return truncateExchangeMigrationFailureReason(strings.Join(parts, ":"))
|
||||
}
|
||||
|
||||
func isExchangeMigrationInfrastructureCode(code int) bool {
|
||||
return code == errors.CodeDatabaseError || code == errors.CodeInternalError
|
||||
}
|
||||
|
||||
// truncateExchangeMigrationFailureReason 按 rune 截断失败原因,保留 499 个字符并以省略号结尾,
|
||||
// 总长度不超过列宽 500,便于后端与前端识别原因已被裁剪。
|
||||
func truncateExchangeMigrationFailureReason(reason string) string {
|
||||
runes := []rune(reason)
|
||||
if len(runes) <= exchangeMigrationFailureReasonMaxRunes {
|
||||
return reason
|
||||
}
|
||||
return string(runes[:exchangeMigrationFailureReasonMaxRunes-1]) + "…"
|
||||
}
|
||||
|
||||
// markExchangeMigrationFailedWithTx 在同一短事务内条件更新迁移失败状态与安全化失败原因。
|
||||
// 条件为换货单仍是物流换货的「已发货待确认」状态:并发确认已完成换货时命中 0 行,
|
||||
// 此时不写状态,避免把已经 migrated 的换货单改回 failed;失败原因只随状态一并写入。
|
||||
func (s *Service) markExchangeMigrationFailedWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, businessErr error) error {
|
||||
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
|
||||
Where("id = ? AND status = ?", order.ID, constants.ExchangeStatusShipped).
|
||||
Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, constants.ExchangeFlowTypeShipping).
|
||||
Updates(map[string]any{
|
||||
"migration_status": constants.ExchangeMigrationStatusFailed,
|
||||
"migration_failure_reason": exchangeMigrationFailureReason(businessErr),
|
||||
"updater": middleware.GetUserIDFromContext(ctx),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新换货单迁移失败状态失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
order.MigrationStatus = constants.ExchangeMigrationStatusFailed
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordExchangeMigrationFailure 在主事务回滚后以独立短事务保存迁移失败状态、安全化原因与失败审计。
|
||||
// 该短事务不与已回滚的主事务共用连接或事务,符合 ENG-TX-001 的 failed 事实例外;
|
||||
// 短事务自身异常复用既有换货失败审计的次级故障记录方式,不改变对外返回的原始迁移失败。
|
||||
//
|
||||
// 本函数刻意镜像既有失败审计接缝(audit.go 的 recordCardExchangeFailure/recordCardExchangeAuditSecondaryFailure
|
||||
// 与 device_audit.go 的 recordExchangeOrderFailure):后者只写审计、不写换货单状态,而本用例需要在同一短事务内
|
||||
// 同时落 failed 状态与失败原因,故未改动既有接缝。后续修改既有卡/设备失败审计接缝时须同步此处,
|
||||
// 保持资产类型分派、动作码转换(deviceExchangeActionCode)、摘要改写与次级故障记录方式一致。
|
||||
func (s *Service) recordExchangeMigrationFailure(ctx context.Context, order *model.ExchangeOrder, businessErr error) {
|
||||
if order == nil {
|
||||
return
|
||||
}
|
||||
isDevice := order.OldAssetType == constants.ExchangeAssetTypeDevice
|
||||
if !isDevice && order.OldAssetType != constants.ExchangeAssetTypeIotCard {
|
||||
return
|
||||
}
|
||||
actionCode, summary, seamMessage := constants.AuditActionCardExchangeCompleted, "完成卡换货失败", "卡换货统一审计接缝未配置"
|
||||
if isDevice {
|
||||
actionCode = deviceExchangeActionCode(actionCode)
|
||||
summary = strings.ReplaceAll(summary, "卡", "设备")
|
||||
seamMessage = "设备换货统一审计接缝未配置"
|
||||
}
|
||||
if s.db == nil || s.auditWriter == nil {
|
||||
recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, errors.New(errors.CodeInvalidStatus, seamMessage))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if updateErr := s.markExchangeMigrationFailedWithTx(ctx, tx, order, businessErr); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
if isDevice {
|
||||
oldDevice, newDevice := s.loadDeviceExchangeAuditDevices(ctx, order)
|
||||
return s.appendDeviceExchangeAudit(ctx, tx, actionCode, summary, cardExchangeFailureResult(businessErr), order, oldDevice, newDevice,
|
||||
nil, nil, nil, nil, nil, nil, nil, businessErr)
|
||||
}
|
||||
oldCard, newCard := s.loadCardExchangeAuditCards(ctx, order)
|
||||
return s.appendCardExchangeAudit(ctx, tx, actionCode, summary, cardExchangeFailureResult(businessErr), order, oldCard, newCard,
|
||||
nil, nil, nil, nil, nil, nil, nil, businessErr)
|
||||
}); err != nil {
|
||||
recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, err)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user