feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s

- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
This commit is contained in:
2026-09-17 16:39:26 +08:00
parent 70e6b186df
commit d52be16802
54 changed files with 5164 additions and 97 deletions

View File

@@ -0,0 +1,809 @@
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)
}