package assetautorenewal import ( "context" "strconv" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" "github.com/break/junhong_cmp_fiber/internal/model" assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/outboxid" ) // renewalHalt 是执行事务内重读资格事实后必须终止本次尝试的可安全记录原因。 // // 它作为事务闭包的返回错误使已取得行锁的续费事务整体回滚(此时尚未写入任何资金与订单事实), // 再由调用方在独立短事务中落终态、投递通知与写审计。 // FailureReason 与 SkipReason 恰有一个非空:非空失败原因触发通知,跳过原因不触发通知。 type renewalHalt struct { FailureReason string SkipReason string Detail string } // Error 实现 error,使事务闭包能把终止信号回传给调用方。 func (h *renewalHalt) Error() string { if h.SkipReason != "" { return "自动续费跳过:" + constants.GetAssetAutoRenewalSkipReasonName(h.SkipReason) } return "自动续费未执行:" + constants.GetAssetAutoRenewalFailureReasonName(h.FailureReason) } // renewalFacts 是一次续费执行成功后用于运行日志的关键事实。 type renewalFacts struct { RenewPrice int64 OrderID uint OrderNo string } // executeRenewal 在单个事务内闭合一次续购:先锁资产钱包行、后锁资产载体行,锁后重读全部资格事实, // 再扣可用余额、建订单与明细、写已支付支付记录、写钱包流水、激活套餐、写佣金与观测 Outbox、 // 更新尝试记录为成功并写成功审计。任一步失败整体回滚,不存在部分成功状态。 // // windowDays 是本次扫描使用的配置窗口,必须传入实际配置值:窗口是触发条件而不是资格不变式, // 用常量上限会让「人工已把最终到期推远」被误判为失败。 // // 返回 halt 表示重读后应落失败或跳过终态(事务已回滚且未写入任何事实);返回 err 表示事务失败。 func (s *Service) executeRenewal(ctx context.Context, candidate assetquery.Candidate, attempt *model.AssetAutoRenewalAttempt, windowDays int) (*renewalFacts, *renewalHalt, error) { if s.outbox == nil { return nil, nil, errors.New(errors.CodeInvalidStatus, "自动续费 Outbox 未配置") } wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, candidate.AssetType, candidate.AssetID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "资产钱包不存在,无法以可用余额续购", }, nil } return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "读取资产钱包失败") } facts := &renewalFacts{} var halt *renewalHalt err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 锁序固定为「先资产钱包行、后资产载体行」,与人工路径的「先冻结钱包、后激活套餐」一致, // 避免与人工事务的锁序反转形成死锁。 lockedWallet, lockErr := s.assetWalletStore.LockByIDWithTx(ctx, tx, wallet.ID) if lockErr != nil { return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定资产钱包失败") } if carrierErr := s.lockCarrier(ctx, tx, candidate.AssetType, candidate.AssetID); carrierErr != nil { return carrierErr } // 锁后重读:最终到期、当前主套餐、待生效主套餐、可售续费价与可用余额都以重读结果为准。 execution, execErr := s.rereadUnderLock(ctx, tx, candidate, lockedWallet, windowDays) if execErr != nil { var halted *renewalHalt if asRenewalHalt(execErr, &halted) { halt = halted return execErr } return execErr } if err := s.writeRenewalFacts(ctx, tx, execution, attempt, facts); err != nil { return err } return nil }) if err != nil { if halt != nil { return nil, halt, nil } return nil, nil, err } return facts, nil, nil } // executionPlan 是锁后重读得到的执行输入。 type executionPlan struct { candidate assetquery.Candidate asset *assetSnapshot wallet *model.AssetWallet pkg *model.Package sellerShop *uint price int64 costPrice int64 } // assetSnapshot 是执行事务内锁定的资产事实。 type assetSnapshot struct { assetType string assetID uint identifier string shopID *uint seriesID *uint generation int } // rereadUnderLock 在行锁内重读全部资格事实,并给出可执行或必须终止的判断。 // // 判定顺序体现「资格不变式先于触发条件」: // 1. 先判资格不变式——已存在待生效主套餐即「人工已完成续购 / 不叠加周期」,无论最终到期被推到多远 // 都 MUST 跳过(规格 Requirement 8),绝不退化为「不可续费」失败与错误通知; // 2. 再判窗口与推算(触发条件)——不在窗口或推算不再明确,才是「当前条件不允许自动续购」。 // // 之后依次判在途人工订单、钱包状态、可售续费价与可用余额。 func (s *Service) rereadUnderLock(ctx context.Context, tx *gorm.DB, candidate assetquery.Candidate, wallet *model.AssetWallet, windowDays int) (*executionPlan, error) { asset, err := s.lockAndSnapshotAsset(ctx, tx, candidate.AssetType, candidate.AssetID) if err != nil { return nil, err } inTxQuery := s.candidates.WithDB(tx) // 第 1 步:资格不变式(先于窗口判定)。 state, err := inTxQuery.MainUsageStateOf(ctx, candidate.AssetType, candidate.AssetID) if err != nil { return nil, err } if state.HasPendingMainPackage { return nil, &renewalHalt{ SkipReason: constants.AssetAutoRenewalSkipManualRenewed, Detail: "锁后重读发现该资产已存在待生效主套餐", } } if state.CurrentPackageID == 0 { return nil, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "锁后重读未找到当前主套餐商品", } } // 第 2 步:触发条件(窗口与推算口径),窗口取本次扫描的配置值。 current, err := inTxQuery.Candidate(ctx, candidate.AssetType, candidate.AssetID, windowDays) if err != nil { return nil, err } if current == nil { return nil, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "锁后重读最终到期已不在触发窗口或推算结果不再明确", } } inFlight, err := inTxQuery.OpenManualMainPackageOrder(ctx, wallet.ID, candidate.AssetType, candidate.AssetID) if err != nil { return nil, err } if inFlight { return nil, &renewalHalt{ SkipReason: constants.AssetAutoRenewalSkipManualOrderPending, Detail: "锁后重读发现该资产存在未关闭的个人资产钱包主套餐订单", } } if wallet.Status != constants.AssetWalletStatusNormal { return nil, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "资产钱包当前不可用于扣款", } } price, pkg, sellerShop, costPrice, err := s.resolveExecutablePrice(ctx, candidate.AssetType, candidate.AssetID, current.CurrentPackageID) if err != nil { var halted *renewalHalt if asRenewalHalt(err, &halted) { return nil, halted } return nil, err } if wallet.GetAvailableBalance() < price { return nil, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureInsufficientBalance, Detail: "资产钱包可用余额小于执行时当前可售续费价", } } return &executionPlan{ candidate: *current, asset: asset, wallet: wallet, pkg: pkg, sellerShop: sellerShop, price: price, costPrice: costPrice, }, nil } // resolveExecutablePrice 复用应用层购买校验与价格策略取得可续费判定与执行时续费价。 // // 校验入口是个人卡/设备购买校验(含续费豁免下架与生效零售价、成本价比较), // 绝不依赖 handler 层续费价实现;任何校验失败都归一为「不可续费」并保留可安全记录的说明。 func (s *Service) resolveExecutablePrice(ctx context.Context, assetType string, assetID, renewPackageID uint) (int64, *model.Package, *uint, int64, error) { if s.purchaseValidation == nil { return 0, nil, nil, 0, errors.New(errors.CodeServiceUnavailable, "购买校验能力未配置") } packageIDs := []uint{renewPackageID} var result *purchase_validation.PurchaseValidationResult var err error switch assetType { case constants.AssetWalletResourceTypeIotCard: result, err = s.purchaseValidation.ValidatePersonalCardPurchase(ctx, assetID, packageIDs) case constants.AssetWalletResourceTypeDevice: result, err = s.purchaseValidation.ValidatePersonalDevicePurchase(ctx, assetID, packageIDs) default: return 0, nil, nil, 0, errors.New(errors.CodeInvalidParam, "资产类型无效") } if err != nil { return 0, nil, nil, 0, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "当前条件不允许自动续购:" + purchaseValidationReason(err), } } if len(result.Packages) == 0 { return 0, nil, nil, 0, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "当前条件不允许自动续购:未解析到可售续费套餐", } } var sellerShop uint if result.Card != nil && result.Card.ShopID != nil { sellerShop = *result.Card.ShopID } if result.Device != nil && result.Device.ShopID != nil { sellerShop = *result.Device.ShopID } costPrice := int64(0) if sellerShop > 0 { resolved, costErr := s.purchaseValidation.GetCostPrice(ctx, result.Packages[0], sellerShop) if costErr != nil { return 0, nil, nil, 0, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "当前条件不允许自动续购:渠道成本价不可读", } } costPrice = resolved } if result.TotalPrice <= 0 { return 0, nil, nil, 0, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "当前条件不允许自动续购:生效续费价异常", } } var sellerShopPtr *uint if sellerShop > 0 { sellerShopPtr = &sellerShop } return result.TotalPrice, result.Packages[0], sellerShopPtr, costPrice, nil } // writeRenewalFacts 在同一事务内闭合扣款、订单、支付、钱包流水、套餐生效、可靠事件、尝试记录与审计。 func (s *Service) writeRenewalFacts(ctx context.Context, tx *gorm.DB, plan *executionPlan, attempt *model.AssetAutoRenewalAttempt, facts *renewalFacts) error { now := s.now() wallet := plan.wallet if err := s.assetWalletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, plan.price, wallet.Version); err != nil { return errors.Wrap(errors.CodeConflict, err, "资产钱包扣款失败") } order, item, err := s.buildRenewalOrder(ctx, tx, plan, now) if err != nil { return err } if err := tx.WithContext(ctx).Create(order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单失败") } item.OrderID = order.ID if err := tx.WithContext(ctx).Create(item).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单明细失败") } payment := &model.Payment{ PaymentNo: order.OrderNo, OrderID: order.ID, OrderType: model.PaymentOrderTypePackage, PaymentMethod: model.PaymentByWallet, Amount: plan.price, Status: model.PaymentRecordStatusPaid, } if err := tx.WithContext(ctx).Create(payment).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入续费支付记录失败") } referenceType := constants.ReferenceTypeOrder walletTransaction := &model.AssetWalletTransaction{ AssetWalletID: wallet.ID, ResourceType: wallet.ResourceType, ResourceID: wallet.ResourceID, UserID: plan.candidate.CustomerID, TransactionType: constants.AssetTransactionTypeDeduct, Amount: -plan.price, BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - plan.price, Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceNo: &order.OrderNo, Creator: plan.candidate.CustomerID, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, } if err := s.walletTransactionStore.CreateWithTx(ctx, tx, walletTransaction); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入续费钱包流水失败") } usage, err := s.activateMainPackage(ctx, tx, order, plan, now) if err != nil { return err } if err := commissiondelivery.AppendCommissionCalculate(ctx, tx, s.outbox, order.ID); err != nil { return err } if s.observationEvents == nil { return errors.New(errors.CodeInvalidStatus, "自动续费观测 Outbox 未配置") } observationID := "asset-auto-renewal:" + strconv.FormatUint(uint64(attempt.ID), 10) if err := s.observationEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{ EventID: outboxEventID(observationID), Scene: constants.CardObservationScenePackageChanged, ResourceType: observationResourceType(plan.asset.assetType), ResourceID: plan.asset.assetID, SyncTypes: []string{ constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork, }, Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now.UTC(), RequestID: observationID, CorrelationID: observationID, }); err != nil { return err } updates := map[string]any{ "status": constants.AssetAutoRenewalAttemptStatusSucceeded, "failure_reason": "", "failure_detail": "", "skip_reason": "", "final_expires_at": plan.candidate.FinalExpiresAt, "current_usage_id": plan.candidate.CurrentUsageID, "current_package_id": plan.candidate.CurrentPackageID, "renew_package_id": plan.pkg.ID, "renew_price": plan.price, "wallet_id": wallet.ID, "wallet_transaction_id": walletTransaction.ID, "deduct_amount": plan.price, "balance_before": wallet.Balance, "balance_after": wallet.Balance - plan.price, "order_id": order.ID, "order_no": order.OrderNo, } // current_usage_id / current_package_id 保留**触发时**解析到的当前主套餐快照(规格 Requirement 7 // 要求「触发时解析」),不覆盖为本次新生成的套餐使用记录;新记录通过 order_id / order_no 追溯, // 「续购后处于待生效」也可由 usage_after_success 断言直接观察。 resumeReady, resumeReason, err := s.evaluateResumeGate(ctx, plan) if err != nil { return err } if resumeReady { updates["resume_status"] = constants.AssetAutoRenewalResumeStatusRequested updates["resume_failure_reason"] = "" } else { updates["resume_status"] = constants.AssetAutoRenewalResumeStatusSkipped updates["resume_failure_reason"] = resumeReason } updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, updates) if err != nil { return err } if !updated { return errors.Wrap(errors.CodeConflict, gorm.ErrInvalidData, "续费尝试已非处理中,拒绝重复成功") } facts.RenewPrice = plan.price facts.OrderID = order.ID facts.OrderNo = order.OrderNo if resumeReady { attempt.ResumeStatus = constants.AssetAutoRenewalResumeStatusRequested if err := AppendResumeRequested(ctx, tx, s.outbox, attempt); err != nil { return err } } return s.appendRenewalAudit(ctx, tx, plan, attempt, order, payment, wallet, walletTransaction, usage, updates) } // evaluateResumeGate 在同一事务内按可复机判定给出复机去向。 // // 判定只做数据库读取、不持有任何外部 I/O,因此可以安全地留在资金事务闭包内(ENG-TX-001); // 它也不对资产钱包行或载体行加锁,因此不会与已持有的行锁形成等待。 func (s *Service) evaluateResumeGate(ctx context.Context, plan *executionPlan) (bool, string, error) { if s.resume == nil { return false, "", errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置") } ready, reason, err := s.resume.AutoRenewalResumeReady(ctx, plan.asset.assetType, plan.asset.assetID) if err != nil { return false, "", err } return ready, reason, nil } // buildRenewalOrder 组装续购订单与唯一明细:买家恒为当前个人客户,金额为执行时当前可售续费价。 func (s *Service) buildRenewalOrder(ctx context.Context, tx *gorm.DB, plan *executionPlan, now time.Time) (*model.Order, *model.OrderItem, error) { orderType := model.OrderTypeSingleCard var iotCardID, deviceID *uint if plan.asset.assetType == constants.AssetWalletResourceTypeDevice { orderType = model.OrderTypeDevice deviceID = &plan.asset.assetID } else { iotCardID = &plan.asset.assetID } generation := plan.asset.generation if generation <= 0 { generation = 1 } paidAmount := plan.price operatorAccountID, operatorAccountName := s.personalCustomerOperatorSnapshot(ctx, plan.candidate.CustomerID) order := &model.Order{ BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID}, OrderNo: s.orderStore.GenerateOrderNo(), OrderType: orderType, BuyerType: model.BuyerTypePersonal, BuyerID: plan.candidate.CustomerID, IotCardID: iotCardID, DeviceID: deviceID, AssetIdentifier: plan.asset.identifier, TotalAmount: plan.price, PaymentMethod: model.PaymentMethodWallet, PaymentStatus: model.PaymentStatusPaid, PaidAt: &now, CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: 0, Source: constants.OrderSourceClient, Generation: generation, ActualPaidAmount: &paidAmount, OperatorAccountID: operatorAccountID, OperatorAccountType: model.OperatorAccountTypePersonalCustomer, OperatorAccountName: operatorAccountName, SellerShopID: plan.sellerShop, SeriesID: plan.asset.seriesID, SellerCostPrice: plan.costPrice, } item := &model.OrderItem{ BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID}, PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, Quantity: 1, UnitPrice: plan.price, Amount: plan.price, PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift, } return order, item, nil } // personalCustomerOperatorSnapshot 读取个人客户昵称作为订单操作者名称快照。 func (s *Service) personalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) { if customerID == 0 { return nil, "" } customer, err := s.personalCustomerStore.GetByID(ctx, customerID) if err != nil { return &customerID, "" } return &customerID, customer.Nickname } // activateMainPackage 在同一事务内激活续购的主套餐:按既有排队规则决定待生效或立即生效。 // // 资格前置保证本次执行前不存在待生效主套餐,因此续购最多领先一个周期; // 只有当当前主套餐在执行前刚好过期时新记录才立即生效,此时按既有规则追加套餐生效优先轮询请求。 func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, plan *executionPlan, now time.Time) (*model.PackageUsage, error) { terms, err := packagepkg.ResolveTermsFromTx(ctx, tx, plan.pkg, order.SellerShopID) if err != nil { return nil, err } hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), plan.asset.assetType, plan.asset.assetID, now) if err != nil { return nil, err } var status, priority int var activatedAt, expiresAt time.Time var nextResetAt *time.Time pendingRealnameActivation := false if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation { realnamed, realnameErr := s.isCarrierRealnamed(ctx, tx, plan.asset.assetType, plan.asset.assetID) if realnameErr != nil { return nil, realnameErr } pendingRealnameActivation = !realnamed } if hasCurrentMain { status = constants.PackageUsageStatusPending var maxPriority int if err := tx.WithContext(ctx).Model(&model.PackageUsage{}). Where(carrierColumn(plan.asset.assetType)+" = ?", plan.asset.assetID). Select("COALESCE(MAX(priority), 0)").Scan(&maxPriority).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐排队优先级失败") } priority = maxPriority + 1 } else { priority = 1 if pendingRealnameActivation { status = constants.PackageUsageStatusPending } else { status = constants.PackageUsageStatusActive activatedAt = now expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays) nextResetAt = packagepkg.CalculateNextResetTime(plan.pkg.DataResetCycle, terms.CalendarType, now, activatedAt) } } virtualTotalMB, displayGainRatio, enableVirtualData := model.BuildPackageUsageSnapshotValues(plan.pkg) retailAmount := order.TotalAmount usage := &model.PackageUsage{ BaseModel: model.BaseModel{Creator: order.Creator, Updater: order.Creator}, OrderID: order.ID, OrderNo: order.OrderNo, PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, UsageType: order.OrderType, DataLimitMB: plan.pkg.RealDataMB, VirtualTotalMBSnapshot: virtualTotalMB, DisplayGainRatioSnapshot: displayGainRatio, EnableVirtualDataSnapshot: enableVirtualData, Status: status, Priority: priority, DataResetCycle: plan.pkg.DataResetCycle, PendingRealnameActivation: pendingRealnameActivation, Generation: order.Generation, PaidAmount: &order.SellerCostPrice, RetailAmount: &retailAmount, PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift, } terms.Apply(usage) if plan.asset.assetType == constants.AssetWalletResourceTypeIotCard { usage.IotCardID = plan.asset.assetID } else { usage.DeviceID = plan.asset.assetID } if status == constants.PackageUsageStatusActive { usage.ActivatedAt = &activatedAt usage.ExpiresAt = &expiresAt usage.NextResetAt = nextResetAt } if err := tx.WithContext(ctx).Omit("status", "pending_realname_activation").Create(usage).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "写入续费套餐使用记录失败") } if err := tx.WithContext(ctx).Model(usage).Updates(map[string]any{ "status": usage.Status, "pending_realname_activation": usage.PendingRealnameActivation, }).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "写回续费套餐使用记录状态失败") } if status != constants.PackageUsageStatusActive { return usage, nil } triggerType, err := packagepkg.ResolveActivationTriggerType(ctx, tx, plan.asset.assetType, plan.asset.assetID, usage.ID) if err != nil { return nil, err } if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, plan.asset.assetType, plan.asset.assetID, triggerType, activatedAt); err != nil { return nil, err } return usage, nil } // isCarrierRealnamed 判断载体是否已满足实名激活条件,口径与既有自动购包一致。 func (s *Service) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (bool, error) { switch assetType { case constants.AssetWalletResourceTypeIotCard: var card model.IotCard if err := tx.WithContext(ctx).Select("real_name_status").First(&card, assetID).Error; err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "读取卡实名状态失败") } return card.RealNameStatus == constants.RealNameStatusVerified, nil case constants.AssetWalletResourceTypeDevice: var count int64 subQuery := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}). Select("iot_card_id").Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound) if err := tx.WithContext(ctx).Model(&model.IotCard{}). Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified). Count(&count).Error; err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "统计设备实名卡失败") } return count > 0, nil default: return false, errors.New(errors.CodeInvalidParam, "资产类型无效") } } // lockCarrier 在事务内按资产类型对载体行加行锁,作为与人工路径共享的序列化点。 func (s *Service) lockCarrier(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error { switch assetType { case constants.AssetWalletResourceTypeIotCard: var card model.IotCard if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, assetID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败") } return nil case constants.AssetWalletResourceTypeDevice: var device model.Device if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, assetID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败") } return nil default: return errors.New(errors.CodeInvalidParam, "资产类型无效") } } // lockAndSnapshotAsset 在已有行锁的事务内读取资产快照。 func (s *Service) lockAndSnapshotAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*assetSnapshot, error) { switch assetType { case constants.AssetWalletResourceTypeIotCard: var card model.IotCard if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费卡事实失败") } if !card.IsStandalone { return nil, &renewalHalt{ FailureReason: constants.AssetAutoRenewalFailureNotRenewable, Detail: "该卡已绑定设备,独立卡维度不执行自动续费", } } return &assetSnapshot{ assetType: constants.AssetWalletResourceTypeIotCard, assetID: card.ID, identifier: card.ICCID, shopID: card.ShopID, seriesID: card.SeriesID, generation: card.Generation, }, nil case constants.AssetWalletResourceTypeDevice: var device model.Device if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费设备事实失败") } identifier := device.VirtualNo if identifier == "" { identifier = device.IMEI } return &assetSnapshot{ assetType: constants.AssetWalletResourceTypeDevice, assetID: device.ID, identifier: identifier, shopID: device.ShopID, seriesID: device.SeriesID, generation: device.Generation, }, nil default: return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") } } // carrierColumn 返回套餐使用记录上的资产外键列名。 func carrierColumn(assetType string) string { if assetType == constants.AssetWalletResourceTypeDevice { return "device_id" } return "iot_card_id" } // observationResourceType 把资产类型映射为观测序列的资源类型。 func observationResourceType(assetType string) string { if assetType == constants.AssetWalletResourceTypeDevice { return constants.CardObservationResourceTypeDevice } return constants.CardObservationResourceTypeCard } // asRenewalHalt 从错误中取出终止信号,非终止信号返回 false。 func asRenewalHalt(err error, target **renewalHalt) bool { halt, ok := err.(*renewalHalt) if !ok { return false } *target = halt return true } // appendRenewalAudit 在续费事务内写成功审计,资源覆盖尝试记录、订单、钱包、流水与套餐使用记录。 func (s *Service) appendRenewalAudit( ctx context.Context, tx *gorm.DB, plan *executionPlan, attempt *model.AssetAutoRenewalAttempt, order *model.Order, payment *model.Payment, wallet *model.AssetWallet, walletTransaction *model.AssetWalletTransaction, usage *model.PackageUsage, updates map[string]any, ) error { if s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置") } attemptID := strconv.FormatUint(uint64(attempt.ID), 10) resources := []audit.ResourceInput{{ Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID, DisplayName: "自动续费尝试 " + attemptID, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget, IdentitySnapshot: map[string]any{ "id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID, "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), "status": constants.AssetAutoRenewalAttemptStatusSucceeded, "renew_package_id": plan.pkg.ID, "renew_price": plan.price, "wallet_id": wallet.ID, "wallet_transaction_id": walletTransaction.ID, "deduct_amount": plan.price, "balance_before": wallet.Balance, "balance_after": wallet.Balance - plan.price, "order_id": order.ID, "order_no": order.OrderNo, "resume_status": updates["resume_status"], }, BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing}, AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusSucceeded, "failure_reason": ""}, SubjectVisibility: constants.AuditSubjectInternalOnly, }} orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRoleAssetAutoRenewalOrder) resources = append(resources, orderResource) walletID := strconv.FormatUint(uint64(wallet.ID), 10) resources = append(resources, audit.ResourceInput{ Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID, Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAutoRenewalWallet, IdentitySnapshot: map[string]any{ "id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID, }, BeforeData: map[string]any{"balance": walletTransaction.BalanceBefore}, AfterData: map[string]any{"balance": walletTransaction.BalanceAfter}, }) walletTxID := strconv.FormatUint(uint64(walletTransaction.ID), 10) resources = append(resources, audit.ResourceInput{ Type: constants.AuditResourceAssetWalletTransaction, ID: &walletTxID, Key: walletTxID, DisplayName: "资产钱包流水 " + walletTxID, Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAutoRenewalWalletTransaction, IdentitySnapshot: map[string]any{ "id": walletTransaction.ID, "asset_wallet_id": walletTransaction.AssetWalletID, "resource_type": walletTransaction.ResourceType, "resource_id": walletTransaction.ResourceID, "transaction_type": walletTransaction.TransactionType, "reference_no": walletTransaction.ReferenceNo, "status": walletTransaction.Status, }, AfterData: map[string]any{ "amount": walletTransaction.Amount, "balance_before": walletTransaction.BalanceBefore, "balance_after": walletTransaction.BalanceAfter, }, }) resources = append(resources, audit.PaymentResource(payment, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPayment, nil, nil)) if usage != nil { resources = append(resources, audit.PackageUsageResource(usage, constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, nil, nil)) } return s.auditWriter.Append(ctx, tx, audit.AppendInput{ ActionCode: constants.AuditActionAssetAutoRenewalRenewed, Summary: "资产钱包自动续费完成", ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, CorrelationID: order.OrderNo, Metadata: map[string]any{ "asset_type": plan.asset.assetType, "asset_id": plan.asset.assetID, "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), }, Resources: resources, }) } // appendFailureAudit 在独立短事务内写失败审计;跳过终态不写审计,由尝试记录本身承载(Domain Ledger)。 func (s *Service) appendFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error { if s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置") } attemptID := strconv.FormatUint(uint64(attempt.ID), 10) return s.auditWriter.Append(ctx, tx, audit.AppendInput{ ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费失败", ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed, ErrorCode: strconv.Itoa(reasonCode(reason)), ErrorSummary: detail, CorrelationID: attemptID, Metadata: map[string]any{ "asset_type": attempt.AssetType, "asset_id": attempt.AssetID, "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), "failure_reason": reason, }, Resources: []audit.ResourceInput{{ Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID, DisplayName: "自动续费尝试 " + attemptID, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget, IdentitySnapshot: map[string]any{ "id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID, "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), "status": constants.AssetAutoRenewalAttemptStatusFailed, "failure_reason": reason, }, BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing}, AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusFailed, "failure_reason": reason}, SubjectVisibility: constants.AuditSubjectInternalOnly, }}, }) } // reasonCode 把失败归类映射为审计错误码位,便于按原因检索失败事件。 func reasonCode(reason string) int { switch reason { case constants.AssetAutoRenewalFailureInsufficientBalance: return 1 case constants.AssetAutoRenewalFailureNotRenewable: return 2 case constants.AssetAutoRenewalFailureOrderFailed: return 3 default: return 0 } } // purchaseValidationReason 从购买校验错误中提取可安全记录的说明,不写底层错误细节。 func purchaseValidationReason(err error) string { if err == nil { return "" } message := err.Error() switch { case strings.Contains(message, "套餐已禁用"): return "套餐商品被禁用" case strings.Contains(message, "套餐已下架"): return "当前渠道下架且不满足续费豁免" case strings.Contains(message, "价格配置异常"): return "生效零售价低于成本价" case strings.Contains(message, "可购买范围"), strings.Contains(message, "未关联套餐系列"), strings.Contains(message, "绑定设备"): return "不在可购买范围或资产未关联套餐系列" case strings.Contains(message, "赠送套餐"): return "赠送套餐不参与自动续购" default: return "当前条件不允许自动续购" } } // outboxEventID 把观测事件标识裁剪进 Outbox 的事件 ID 长度预算。 func outboxEventID(value string) string { return outboxid.Stable("card-observation:", value) }