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,零外部渠道调用)
810 lines
35 KiB
Go
810 lines
35 KiB
Go
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)
|
||
}
|