Files
junhong_cmp_fiber/internal/service/exchange/migration_failure.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

150 lines
7.4 KiB
Go
Raw Permalink 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"
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)
}
}