feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
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:
809
internal/application/assetautorenewal/renew.go
Normal file
809
internal/application/assetautorenewal/renew.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user