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) } }