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), 旧资产带标签时迁移最后一步失败,待另立变更修复
150 lines
7.4 KiB
Go
150 lines
7.4 KiB
Go
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)
|
||
}
|
||
}
|