package h5popup import ( "context" "strconv" "strings" "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // RiskExchangeService 处理个人客户自助风险换卡的地址提交。 // 幂等靠「锁定旧资产行 + 去重查询既有活动物流换货单」实现,不引入数据库唯一约束: // 资产实例同一时刻只属于一个客户,锁资产行即可覆盖重复提交与并发提交。 type RiskExchangeService struct { db *gorm.DB ownership AssetOwnership auditWriter *audit.Writer } // NewRiskExchangeService 创建风险换卡地址提交事务脚本。 func NewRiskExchangeService(db *gorm.DB, ownership AssetOwnership, auditWriter *audit.Writer) *RiskExchangeService { return &RiskExchangeService{db: db, ownership: ownership, auditWriter: auditWriter} } // Submit 幂等提交风险换卡收货地址,创建关联旧资产的物流换货单。 // 事务内顺序固定为:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才插入。 // 重复提交返回首次创建的换货单与首次地址,不覆盖既有地址。 func (s *RiskExchangeService) Submit(ctx context.Context, customerID, assetID uint, request dto.ClientRiskExchangeAddressParams) (*dto.ClientRiskExchangeResponse, error) { if customerID == 0 { return nil, errors.New(errors.CodeUnauthorized) } if assetID == 0 { return nil, errors.New(errors.CodeInvalidParam, "风险换卡资产ID不合法") } if s == nil || s.db == nil || s.ownership == nil || s.auditWriter == nil { return nil, errors.New(errors.CodeServiceUnavailable, "风险换卡能力尚未配置") } // 归属校验必须使用权威实现;资产不存在与归属失败返回同态不可见结果。 owned, err := s.ownership.OwnsAsset(ctx, customerID, constants.AssetTypeIotCard, assetID) if err != nil { if isAssetNotFound(err) { return nil, invisibleAssetError() } return nil, err } if !owned { return nil, invisibleAssetError() } var result *dto.ClientRiskExchangeResponse err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var card model.IotCard if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", assetID).Take(&card).Error; err != nil { if isRecordNotFound(err) { return invisibleAssetError() } return errors.Wrap(errors.CodeDatabaseError, err, "锁定换卡资产失败") } // 锁内复核风险资格:持锁前的判定可能已被并发状态同步改变。 if card.CarrierType != constants.CarrierTypeCBN || strings.TrimSpace(card.GatewayExtend) != constants.GatewayCardExtendRiskStop { return errors.New(errors.CodeH5PopupRiskNotEligible) } existing, err := findActiveShippingExchange(ctx, tx, constants.AssetTypeIotCard, card.ID) if err != nil { return err } if existing != nil { result = toRiskExchangeResponse(existing) return nil } order := &model.ExchangeOrder{ ExchangeNo: model.GenerateExchangeNo(), FlowType: constants.ExchangeFlowTypeShipping, OldAssetType: constants.AssetTypeIotCard, OldAssetID: card.ID, OldAssetIdentifier: card.ICCID, RecipientName: request.RecipientName, RecipientPhone: request.RecipientPhone, RecipientAddress: request.RecipientAddress, ShopID: card.ShopID, ExchangeReason: constants.H5PopupRiskExchangeReason, // 客户已提交收货信息,因此创建即待发货;不预设业务数据迁移,发货选新资产时仍由后台按既有流程决定。 Status: constants.ExchangeStatusPendingShip, MigrateData: false, MigrationStatus: constants.ExchangeMigrationStatusNotMigrated, // H5 客户上下文没有后台账号 ID,置 0 表示由客户自助发起,不冒用任何后台账号身份。 BaseModel: model.BaseModel{Creator: 0, Updater: 0}, } if err := tx.WithContext(ctx).Create(order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建风险换卡单失败") } result = toRiskExchangeResponse(order) return s.appendRiskExchangeAudit(ctx, tx, customerID, order, &card) }) if err != nil { return nil, err } return result, nil } // appendRiskExchangeAudit 在同一事务内记录客户自助换卡的状态事实与旧卡引用。 func (s *RiskExchangeService) appendRiskExchangeAudit(ctx context.Context, tx *gorm.DB, customerID uint, order *model.ExchangeOrder, card *model.IotCard) error { orderID := strconv.FormatUint(uint64(order.ID), 10) cardID := strconv.FormatUint(uint64(card.ID), 10) customerText := strconv.FormatUint(uint64(customerID), 10) summary := "客户自助提交风险换卡地址" return s.auditWriter.Append(ctx, tx, audit.AppendInput{ ActionCode: constants.AuditActionCardRiskExchangeRequested, Summary: summary, Actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: customerText}, Source: constants.AuditSourcePersonalAPI, // 个人客户本人业务范围;不使用 platform,避免把客户自助事实记成后台操作。 ScopeType: constants.AuditScopePersonalCustomer, ScopeID: customerText, Result: constants.AuditResultSuccess, Metadata: map[string]any{"flow_type": constants.ExchangeFlowTypeShipping, "migrate_data": false}, Resources: []audit.ResourceInput{ { Type: constants.AuditResourceExchangeOrder, ID: &orderID, Key: order.ExchangeNo, DisplayName: order.ExchangeNo, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder, IdentitySnapshot: map[string]any{ "id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": order.FlowType, "old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID, "old_asset_identifier": order.OldAssetIdentifier, "shop_id": order.ShopID, "status": order.Status, }, AfterData: map[string]any{ "status": order.Status, "migrate_data": order.MigrateData, "migration_status": order.MigrationStatus, }, SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary, }, { Type: constants.AuditResourceIotCard, ID: &cardID, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeOldCard, IdentitySnapshot: audit.IotCardIdentitySnapshot(card), SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary, }, }, }) } // toRiskExchangeResponse 将换货单投影为地址提交结果。 // 地址取记录中的既有值:重复提交返回首次地址,不做任何覆盖。 func toRiskExchangeResponse(order *model.ExchangeOrder) *dto.ClientRiskExchangeResponse { if order == nil { return nil } return &dto.ClientRiskExchangeResponse{ ID: order.ID, ExchangeNo: order.ExchangeNo, Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), FlowType: order.FlowType, OldAssetType: order.OldAssetType, OldAssetID: order.OldAssetID, OldAssetIdentifier: order.OldAssetIdentifier, RecipientName: order.RecipientName, RecipientPhone: order.RecipientPhone, RecipientAddress: order.RecipientAddress, MigrateData: order.MigrateData, MigrationStatus: order.MigrationStatus, MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus), ExchangeReason: order.ExchangeReason, CreatedAt: order.CreatedAt, } }