Files
junhong_cmp_fiber/internal/task/auto_purchase.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

929 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package task
import (
"context"
"errors"
"strconv"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"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/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AutoPurchasePayload 充值后自动购包任务载荷
type AutoPurchasePayload struct {
RechargeOrderID uint `json:"recharge_order_id"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// AutoPurchaseHandler 充值后自动购包任务处理器
type AutoPurchaseHandler struct {
db *gorm.DB
orderStore *postgres.OrderStore
rechargeOrderStore *postgres.RechargeOrderStore
paymentStore *postgres.PaymentStore
walletStore *postgres.AssetWalletStore
walletTransactionStore *postgres.AssetWalletTransactionStore
packageUsageStore *postgres.PackageUsageStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore // 用于查询卖家成本价
iotCardStore *postgres.IotCardStore // 用于获取卡的系列ID
deviceStore *postgres.DeviceStore // 用于获取设备的系列ID
redis *redis.Client
asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务
logger *zap.Logger
observationSeriesEvents cardObservationApp.SeriesEventWriter
priorityEvents priorityapp.PriorityEventWriter
auditWriter *audit.Writer
silenceSetter *packagesilenceapp.Setter
outboundRecorder *packagesilenceapp.OutboundRecorder
}
// SetSilenceSetter 注入沉默期设置用例;自动购包是无人参与路径,只用套餐默认沉默期。
func (h *AutoPurchaseHandler) SetSilenceSetter(setter *packagesilenceapp.Setter) {
h.silenceSetter = setter
}
// SetOutboundRecorder 注入资产出库时间写入用例。
func (h *AutoPurchaseHandler) SetOutboundRecorder(recorder *packagesilenceapp.OutboundRecorder) {
h.outboundRecorder = recorder
}
// NewAutoPurchaseHandler 创建充值后自动购包处理器
func NewAutoPurchaseHandler(
db *gorm.DB,
orderStore *postgres.OrderStore,
rechargeOrderStore *postgres.RechargeOrderStore,
paymentStore *postgres.PaymentStore,
walletStore *postgres.AssetWalletStore,
walletTransactionStore *postgres.AssetWalletTransactionStore,
packageUsageStore *postgres.PackageUsageStore,
redisClient *redis.Client,
asynqClient *asynq.Client,
logger *zap.Logger,
observationSeriesEvents cardObservationApp.SeriesEventWriter,
priorityEvents priorityapp.PriorityEventWriter,
auditWriter *audit.Writer,
) *AutoPurchaseHandler {
if orderStore == nil {
orderStore = postgres.NewOrderStore(db, redisClient)
}
if rechargeOrderStore == nil {
rechargeOrderStore = postgres.NewRechargeOrderStore(db, redisClient)
}
if paymentStore == nil {
paymentStore = postgres.NewPaymentStore(db, redisClient)
}
if walletStore == nil {
walletStore = postgres.NewAssetWalletStore(db, redisClient)
}
if walletTransactionStore == nil {
walletTransactionStore = postgres.NewAssetWalletTransactionStore(db, redisClient)
}
if packageUsageStore == nil {
packageUsageStore = postgres.NewPackageUsageStore(db, redisClient)
}
return &AutoPurchaseHandler{
db: db,
orderStore: orderStore,
rechargeOrderStore: rechargeOrderStore,
paymentStore: paymentStore,
walletStore: walletStore,
walletTransactionStore: walletTransactionStore,
packageUsageStore: packageUsageStore,
shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(db),
iotCardStore: postgres.NewIotCardStore(db, redisClient),
deviceStore: postgres.NewDeviceStore(db, redisClient),
redis: redisClient,
asynqClient: asynqClient,
logger: logger,
observationSeriesEvents: observationSeriesEvents,
priorityEvents: priorityEvents,
auditWriter: auditWriter,
}
}
// ProcessTask 处理充值后自动购包任务
func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) error {
var payload AutoPurchasePayload
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
h.logger.Error("解析自动购包任务载荷失败", zap.Error(err))
return asynq.SkipRetry
}
if payload.RechargeOrderID == 0 {
h.logger.Error("自动购包任务载荷无效", zap.Uint("recharge_order_id", payload.RechargeOrderID))
return asynq.SkipRetry
}
rechargeOrder, err := h.rechargeOrderStore.GetByID(ctx, payload.RechargeOrderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
h.logger.Warn("充值订单不存在,跳过自动购包", zap.Uint("recharge_order_id", payload.RechargeOrderID))
return asynq.SkipRetry
}
h.logger.Error("查询充值订单失败", zap.Uint("recharge_order_id", payload.RechargeOrderID), zap.Error(err))
return err
}
correlationID := payload.CorrelationID
if correlationID == "" {
correlationID = rechargeOrder.RechargeOrderNo
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeAutoPurchaseAfterRecharge,
ActorName: "充值后自动购包任务", Source: constants.AuditSourceWorker,
RequestID: payload.RequestID, CorrelationID: correlationID, ParentEventID: payload.ParentEventID,
})
if rechargeOrder.AutoPurchaseStatus == constants.AutoPurchaseStatusSuccess {
return nil
}
if rechargeOrder.AutoPurchaseStatus == constants.AutoPurchaseStatusFailed {
return nil
}
packageIDs, err := parseLinkedPackageIDs(rechargeOrder.LinkedPackageIDs)
if err != nil {
h.logger.Error("解析关联套餐ID失败", zap.Uint("recharge_order_id", rechargeOrder.ID), zap.Error(err))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeOrder.ID)
return asynq.SkipRetry
}
if len(packageIDs) == 0 {
h.logger.Error("关联套餐ID为空,无法自动购包", zap.Uint("recharge_order_id", rechargeOrder.ID))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeOrder.ID)
return asynq.SkipRetry
}
packages, totalAmount, err := h.loadPackages(ctx, packageIDs)
if err != nil {
h.logger.Error("加载关联套餐失败", zap.Uint("recharge_order_id", rechargeOrder.ID), zap.Error(err))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeOrder.ID)
return err
}
// 获取资产系列ID,用于差价佣金和一次性佣金计算
var seriesID *uint
if rechargeOrder.LinkedCarrierID != nil && *rechargeOrder.LinkedCarrierID > 0 {
if rechargeOrder.LinkedCarrierType == "card" || rechargeOrder.LinkedCarrierType == constants.AssetWalletResourceTypeIotCard {
if card, cardErr := h.iotCardStore.GetByID(ctx, *rechargeOrder.LinkedCarrierID); cardErr == nil {
seriesID = card.SeriesID
} else {
h.logger.Warn("自动购包获取卡系列ID失败",
zap.Uint("card_id", *rechargeOrder.LinkedCarrierID),
zap.Error(cardErr))
}
} else if rechargeOrder.LinkedCarrierType == "device" || rechargeOrder.LinkedCarrierType == constants.AssetWalletResourceTypeDevice {
if device, deviceErr := h.deviceStore.GetByID(ctx, *rechargeOrder.LinkedCarrierID); deviceErr == nil {
seriesID = device.SeriesID
} else {
h.logger.Warn("自动购包获取设备系列ID失败",
zap.Uint("device_id", *rechargeOrder.LinkedCarrierID),
zap.Error(deviceErr))
}
}
}
// 获取卖家成本价,用于差价佣金链式计算的起点
// 平台直销(ShopIDTag == 0)时成本价为 0,差价佣金计算会直接跳过
var sellerCostPrice int64
if rechargeOrder.ShopIDTag > 0 && len(packages) > 0 {
allocation, allocErr := h.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, rechargeOrder.ShopIDTag, packages[0].ID)
if allocErr == nil {
sellerCostPrice = allocation.CostPrice
} else {
h.logger.Warn("自动购包获取卖家成本价失败,差价佣金将计算为0",
zap.Uint("shop_id", rechargeOrder.ShopIDTag),
zap.Uint("package_id", packages[0].ID),
zap.Error(allocErr))
}
}
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
wallet, walletErr := h.walletStore.GetByID(ctx, rechargeOrder.AssetWalletID)
if walletErr != nil {
if walletErr == gorm.ErrRecordNotFound {
return errors.New("资产钱包不存在")
}
return walletErr
}
if wallet.GetAvailableBalance() < totalAmount {
return errors.New("钱包余额不足")
}
if err = h.walletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, totalAmount, wallet.Version); err != nil {
return err
}
now := time.Now()
order, orderItems, buildErr := h.buildOrderAndItems(ctx, rechargeOrder, packages, totalAmount, seriesID, sellerCostPrice, now)
if buildErr != nil {
return buildErr
}
if err = tx.Create(order).Error; err != nil {
return err
}
for _, item := range orderItems {
item.OrderID = order.ID
}
if err = tx.CreateInBatches(orderItems, 100).Error; err != nil {
return err
}
payment := &model.Payment{
PaymentNo: order.OrderNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: model.PaymentByWallet,
Amount: totalAmount,
Status: model.PaymentRecordStatusPaid,
}
if err = tx.Create(payment).Error; err != nil {
return err
}
refType := constants.ReferenceTypeOrder
walletTx := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
UserID: rechargeOrder.UserID,
TransactionType: constants.AssetTransactionTypeDeduct,
Amount: -totalAmount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - totalAmount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceNo: &order.OrderNo,
Creator: rechargeOrder.UserID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err = h.walletTransactionStore.CreateWithTx(ctx, tx, walletTx); err != nil {
return err
}
if err = h.activatePackages(ctx, tx, order, packages, now); err != nil {
return err
}
if h.observationSeriesEvents == nil {
return pkgerrors.New(pkgerrors.CodeInternalError, "自动购包观测 Outbox Writer 未配置")
}
resourceType, resourceID := orderObservationResource(order)
if resourceID == 0 {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "自动购包观测事件缺少载体")
}
if err = commissiondelivery.AppendCommissionCalculate(ctx, tx, outbox.NewRepository(), order.ID); err != nil {
return err
}
requestID := "card-observation:auto-purchase:" + strconv.FormatUint(uint64(order.ID), 10)
if err = h.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: requestID, Scene: constants.CardObservationScenePackageChanged,
ResourceType: resourceType, ResourceID: resourceID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now,
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
if err = tx.Model(&model.RechargeOrder{}).
Where("id = ?", rechargeOrder.ID).
Update("auto_purchase_status", constants.AutoPurchaseStatusSuccess).Error; err != nil {
return err
}
if h.auditWriter == nil {
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "自动购包统一审计接缝未配置")
}
return h.appendAutoPurchaseAudit(ctx, tx, rechargeOrder, order, payment, wallet, walletTx, packages)
}); err != nil {
h.logger.Error("自动购包任务执行失败",
zap.Uint("recharge_record_id", rechargeOrder.ID),
zap.Error(err),
)
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeOrder.ID)
return err
}
h.logger.Info("自动购包任务执行成功", zap.Uint("recharge_record_id", rechargeOrder.ID))
return nil
}
func (h *AutoPurchaseHandler) appendAutoPurchaseAudit(ctx context.Context, tx *gorm.DB, recharge *model.RechargeOrder, order *model.Order, payment *model.Payment, wallet *model.AssetWallet, walletTx *model.AssetWalletTransaction, packages []*model.Package) error {
rechargeID := strconv.FormatUint(uint64(recharge.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRechargeTarget,
IdentitySnapshot: map[string]any{
"id": recharge.ID, "recharge_order_no": recharge.RechargeOrderNo, "user_id": recharge.UserID,
"asset_wallet_id": recharge.AssetWalletID, "resource_type": recharge.ResourceType,
"resource_id": recharge.ResourceID, "amount": recharge.Amount, "status": recharge.Status,
},
BeforeData: map[string]any{"auto_purchase_status": recharge.AutoPurchaseStatus},
AfterData: map[string]any{"auto_purchase_status": constants.AutoPurchaseStatusSuccess},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "充值后自动购包已完成",
}}
orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRoleRechargeAutoPurchaseOrder)
orderResource.SubjectVisibility = constants.AuditSubjectResult
orderResource.SubjectSummary = "充值后自动购包已完成"
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.AuditResourceRoleRechargeWallet,
IdentitySnapshot: map[string]any{"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID, "currency": wallet.Currency},
BeforeData: map[string]any{"balance": walletTx.BalanceBefore}, AfterData: map[string]any{"balance": walletTx.BalanceAfter},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "充值后自动购包已完成",
})
walletTxID := strconv.FormatUint(uint64(walletTx.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWalletTransaction, ID: &walletTxID, Key: walletTxID, DisplayName: "资产钱包流水 " + walletTxID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRechargeWalletTransaction,
IdentitySnapshot: map[string]any{
"id": walletTx.ID, "asset_wallet_id": walletTx.AssetWalletID, "resource_type": walletTx.ResourceType,
"resource_id": walletTx.ResourceID, "transaction_type": walletTx.TransactionType,
"reference_type": walletTx.ReferenceType, "reference_no": walletTx.ReferenceNo, "status": walletTx.Status,
},
AfterData: map[string]any{"amount": walletTx.Amount, "balance_before": walletTx.BalanceBefore, "balance_after": walletTx.BalanceAfter},
})
resources = append(resources, audit.PaymentResource(payment, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPayment, nil, nil))
for _, pkg := range packages {
resources = append(resources, audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPackage, nil, nil))
}
var usages []model.PackageUsage
if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&usages).Error; err != nil {
return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询自动购包套餐权益审计快照失败")
}
for i := range usages {
resources = append(resources, audit.PackageUsageResource(&usages[i], constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, nil, nil))
}
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetRechargeAutoPurchased, Summary: "充值后自动购包已完成",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: recharge.RechargeOrderNo, Resources: resources,
})
}
func orderObservationResource(order *model.Order) (string, uint) {
if order != nil && order.DeviceID != nil {
return constants.CardObservationResourceTypeDevice, *order.DeviceID
}
if order != nil && order.IotCardID != nil {
return constants.CardObservationResourceTypeCard, *order.IotCardID
}
return "", 0
}
// NewAutoPurchaseTask 创建充值后自动购包任务
func NewAutoPurchaseTask(rechargeOrderID uint) (*asynq.Task, error) {
payloadBytes, err := sonic.Marshal(AutoPurchasePayload{RechargeOrderID: rechargeOrderID})
if err != nil {
return nil, err
}
return asynq.NewTask(constants.TaskTypeAutoPurchaseAfterRecharge, payloadBytes,
asynq.MaxRetry(3),
asynq.Timeout(2*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAutoPurchaseAfterRecharge)),
), nil
}
func (h *AutoPurchaseHandler) markAutoPurchaseFailedIfFinalRetry(ctx context.Context, rechargeOrderID uint) {
retryCount, ok := asynq.GetRetryCount(ctx)
if !ok {
return
}
maxRetry, ok := asynq.GetMaxRetry(ctx)
if !ok {
return
}
if retryCount < maxRetry-1 {
return
}
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var recharge model.RechargeOrder
if err := tx.WithContext(ctx).First(&recharge, rechargeOrderID).Error; err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RechargeOrder{}).
Where("id = ? AND auto_purchase_status <> ?", rechargeOrderID, constants.AutoPurchaseStatusFailed).
Update("auto_purchase_status", constants.AutoPurchaseStatusFailed)
if result.Error != nil || result.RowsAffected == 0 {
return result.Error
}
if h.auditWriter == nil {
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "自动购包统一审计接缝未配置")
}
rechargeID := strconv.FormatUint(uint64(recharge.ID), 10)
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetRechargeAutoPurchased, Summary: "充值后自动购包失败",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
CorrelationID: recharge.RechargeOrderNo,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRechargeTarget,
IdentitySnapshot: map[string]any{
"id": recharge.ID, "recharge_order_no": recharge.RechargeOrderNo, "user_id": recharge.UserID,
"asset_wallet_id": recharge.AssetWalletID, "resource_type": recharge.ResourceType,
"resource_id": recharge.ResourceID, "amount": recharge.Amount, "status": recharge.Status,
},
BeforeData: map[string]any{"auto_purchase_status": recharge.AutoPurchaseStatus},
AfterData: map[string]any{"auto_purchase_status": constants.AutoPurchaseStatusFailed},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "充值后自动购包失败",
}},
})
}); err != nil {
h.logger.Error("更新自动购包失败状态失败",
zap.Uint("recharge_record_id", rechargeOrderID),
zap.Error(err),
)
return
}
h.logger.Warn("自动购包达到最大重试次数,已标记失败", zap.Uint("recharge_record_id", rechargeOrderID))
}
func (h *AutoPurchaseHandler) loadPackages(ctx context.Context, packageIDs []uint) ([]*model.Package, int64, error) {
packages := make([]*model.Package, 0, len(packageIDs))
if err := h.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil {
return nil, 0, err
}
if len(packages) != len(packageIDs) {
return nil, 0, gorm.ErrRecordNotFound
}
totalAmount := int64(0)
for _, pkg := range packages {
if pkg.IsGift {
return nil, 0, errors.New("赠送套餐不能进入自动购包链路")
}
totalAmount += packageprice.PackageEffectiveRetailPrice(pkg)
}
if err := validatePackageTypeMix(packages); err != nil {
return nil, 0, err
}
return packages, totalAmount, nil
}
func (h *AutoPurchaseHandler) buildOrderAndItems(
ctx context.Context,
rechargeOrder *model.RechargeOrder,
packages []*model.Package,
totalAmount int64,
seriesID *uint,
sellerCostPrice int64,
now time.Time,
) (*model.Order, []*model.OrderItem, error) {
orderType, iotCardID, deviceID, err := parseLinkedCarrier(rechargeOrder.LinkedOrderType, rechargeOrder.LinkedCarrierType, rechargeOrder.LinkedCarrierID)
if err != nil {
return nil, nil, err
}
generation := rechargeOrder.Generation
if generation <= 0 {
generation = 1
}
var sellerShopID *uint
if rechargeOrder.ShopIDTag > 0 {
shopID := rechargeOrder.ShopIDTag
sellerShopID = &shopID
}
paidAmount := totalAmount
operatorAccountID, operatorAccountName := h.buildPersonalCustomerOperatorSnapshot(ctx, rechargeOrder.UserID)
order := &model.Order{
BaseModel: model.BaseModel{
Creator: rechargeOrder.UserID,
Updater: rechargeOrder.UserID,
},
OrderNo: h.orderStore.GenerateOrderNo(),
OrderType: orderType,
BuyerType: model.BuyerTypePersonal,
BuyerID: rechargeOrder.UserID,
IotCardID: iotCardID,
DeviceID: deviceID,
TotalAmount: totalAmount,
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: sellerShopID,
SeriesID: seriesID,
SellerCostPrice: sellerCostPrice,
}
items := make([]*model.OrderItem, 0, len(packages))
for _, pkg := range packages {
unitPrice := packageprice.PackageEffectiveRetailPrice(pkg)
items = append(items, &model.OrderItem{
BaseModel: model.BaseModel{
Creator: rechargeOrder.UserID,
Updater: rechargeOrder.UserID,
},
PackageID: pkg.ID,
PackageName: pkg.PackageName,
Quantity: 1,
UnitPrice: unitPrice,
Amount: unitPrice,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
})
}
return order, items, nil
}
func (h *AutoPurchaseHandler) buildPersonalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) {
if customerID == 0 {
return nil, ""
}
var customer model.PersonalCustomer
if err := h.db.WithContext(ctx).Where("id = ?", customerID).First(&customer).Error; err != nil {
id := customerID
return &id, ""
}
id := customerID
return &id, customer.Nickname
}
func (h *AutoPurchaseHandler) activatePackages(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
packages []*model.Package,
now time.Time,
) error {
carrierType := constants.AssetWalletResourceTypeIotCard
carrierID := uint(0)
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
carrierID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
carrierType = constants.AssetWalletResourceTypeDevice
carrierID = *order.DeviceID
} else {
return errors.New("无效的订单载体")
}
// 在查询既有记录前锁定载体,避免并发任务同时判定不存在而重复创建套餐使用记录。
if err := h.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
return err
}
createdUsage := false
for _, pkg := range packages {
var existingUsage model.PackageUsage
err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&existingUsage).Error
if err == nil {
continue
}
if err != gorm.ErrRecordNotFound {
return err
}
createdUsage = true
if pkg.PackageType == constants.PackageTypeFormal {
if err = h.activateMainPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil {
return err
}
continue
}
if pkg.PackageType == constants.PackageTypeAddon {
if err = h.activateAddonPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil {
return err
}
}
}
if createdUsage && h.outboundRecorder != nil {
// 出库时间:资产当前世代第 1 次购买套餐的支付时间
return h.outboundRecorder.RecordForOrderInTx(ctx, tx, order)
}
return nil
}
func (h *AutoPurchaseHandler) activateMainPackage(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
pkg *model.Package,
carrierType string,
carrierID uint,
now time.Time,
) error {
terms, err := h.resolvePackageTerms(ctx, tx, pkg, order.SellerShopID)
if err != nil {
h.logger.Error("自动购包生成套餐计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err))
return err
}
if err := h.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
return err
}
hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), carrierType, carrierID, now)
if err != nil {
return err
}
var status int
var priority int
var activatedAt time.Time
var expiresAt time.Time
var nextResetAt *time.Time
var pendingRealnameActivation bool
if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
realnamed, realnameErr := h.isCarrierRealnamed(ctx, tx, carrierType, carrierID)
if realnameErr != nil {
return realnameErr
}
pendingRealnameActivation = !realnamed
}
if hasCurrentMain {
status = constants.PackageUsageStatusPending
var maxPriority int
tx.Model(&model.PackageUsage{}).
Where(carrierType+"_id = ?", carrierID).
Select("COALESCE(MAX(priority), 0)").
Scan(&maxPriority)
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(pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
}
}
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
retailAmount := order.TotalAmount
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
OrderNo: order.OrderNo,
PackageID: pkg.ID,
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: status,
Priority: priority,
DataResetCycle: pkg.DataResetCycle,
PendingRealnameActivation: pendingRealnameActivation,
Generation: order.Generation,
PaidAmount: &order.SellerCostPrice,
RetailAmount: &retailAmount,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
}
terms.Apply(usage)
if carrierType == constants.AssetWalletResourceTypeIotCard {
usage.IotCardID = carrierID
} else {
usage.DeviceID = carrierID
}
if status == constants.PackageUsageStatusActive {
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
}
if err = tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
return err
}
if err = tx.Model(usage).Updates(map[string]any{
"status": usage.Status,
"pending_realname_activation": usage.PendingRealnameActivation,
}).Error; err != nil {
return err
}
// 沉默期:无人参与路径只用套餐默认值(上下文不携带调整值)
if h.silenceSetter != nil {
paidAt := now
if order.PaidAt != nil {
paidAt = *order.PaidAt
}
if err = h.silenceSetter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, constants.SilenceTriggerAutoPurchase); err != nil {
return err
}
}
// 仅在生效状态分支追加优先轮询请求:待生效(含等待实名激活)不属于触发场景。
if status == constants.PackageUsageStatusActive {
triggerType, classifyErr := packagepkg.ResolveActivationTriggerType(ctx, tx, carrierType, carrierID, usage.ID)
if classifyErr != nil {
return classifyErr
}
return packagepkg.AppendActivatedPriorityRequested(ctx, tx, h.priorityEvents, usage, carrierType, carrierID, triggerType, activatedAt)
}
return nil
}
// isCarrierRealnamed 查询自动购包载体是否已满足实名激活条件。
func (h *AutoPurchaseHandler) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
switch carrierType {
case constants.AssetWalletResourceTypeIotCard, "card":
var card model.IotCard
if err := tx.WithContext(ctx).Select("real_name_status").First(&card, carrierID).Error; err != nil {
return false, 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 = ?", carrierID, 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, err
}
return count > 0, nil
default:
return false, pkgerrors.New(pkgerrors.CodeInvalidParam, "无效的套餐载体类型")
}
}
func (h *AutoPurchaseHandler) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
switch carrierType {
case constants.AssetWalletResourceTypeIotCard, "card":
var card model.IotCard
return tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, carrierID).Error
case constants.AssetWalletResourceTypeDevice:
var device model.Device
return tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, carrierID).Error
default:
return errors.New("无效的套餐载体类型")
}
}
func (h *AutoPurchaseHandler) activateAddonPackage(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
pkg *model.Package,
carrierType string,
carrierID uint,
now time.Time,
) error {
terms, err := h.resolvePackageTerms(ctx, tx, pkg, order.SellerShopID)
if err != nil {
h.logger.Error("自动购包生成加油包计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err))
return err
}
mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now)
if err == gorm.ErrRecordNotFound {
return errors.New("必须有主套餐才能购买加油包")
}
if err != nil {
return err
}
var maxPriority int
tx.Model(&model.PackageUsage{}).
Where(carrierType+"_id = ?", carrierID).
Select("COALESCE(MAX(priority), 0)").
Scan(&maxPriority)
priority := maxPriority + 1
expiresAt := mainPackage.ExpiresAt
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
addonRetailAmount := order.TotalAmount
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
OrderNo: order.OrderNo,
PackageID: pkg.ID,
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: constants.PackageUsageStatusActive,
Priority: priority,
MasterUsageID: &mainPackage.ID,
ActivatedAt: &now,
ExpiresAt: expiresAt,
DataResetCycle: pkg.DataResetCycle,
Generation: order.Generation,
PaidAmount: &order.SellerCostPrice,
RetailAmount: &addonRetailAmount,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
}
terms.Apply(usage)
if carrierType == constants.AssetWalletResourceTypeIotCard {
usage.IotCardID = carrierID
} else {
usage.DeviceID = carrierID
}
if err := tx.Create(usage).Error; err != nil {
return err
}
// 加油包立即生效:以生效状态提交后追加优先轮询请求。
return packagepkg.AppendActivatedPriorityRequested(ctx, tx, h.priorityEvents, usage, carrierType, carrierID,
constants.PollingPriorityTriggerAddonActivated, now)
}
func (h *AutoPurchaseHandler) resolvePackageTerms(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) {
return packagepkg.ResolveTermsFromTx(ctx, tx, pkg, sellerShopID)
}
func parseLinkedPackageIDs(raw []byte) ([]uint, error) {
var packageIDs []uint
if len(raw) == 0 {
return nil, nil
}
if err := sonic.Unmarshal(raw, &packageIDs); err != nil {
return nil, err
}
return packageIDs, nil
}
func parseLinkedCarrier(linkedOrderType string, linkedCarrierType string, linkedCarrierID *uint) (string, *uint, *uint, error) {
if linkedCarrierID == nil || *linkedCarrierID == 0 {
return "", nil, nil, errors.New("关联载体ID为空")
}
if linkedOrderType == model.OrderTypeSingleCard || linkedCarrierType == "card" || linkedCarrierType == constants.AssetWalletResourceTypeIotCard {
id := *linkedCarrierID
return model.OrderTypeSingleCard, &id, nil, nil
}
if linkedOrderType == model.OrderTypeDevice || linkedCarrierType == "device" || linkedCarrierType == constants.AssetWalletResourceTypeDevice {
id := *linkedCarrierID
return model.OrderTypeDevice, nil, &id, nil
}
return "", nil, nil, errors.New("关联载体类型无效")
}
func validatePackageTypeMix(packages []*model.Package) error {
hasFormal := false
hasAddon := false
for _, pkg := range packages {
switch pkg.PackageType {
case constants.PackageTypeFormal:
hasFormal = true
case constants.PackageTypeAddon:
hasAddon = true
}
if hasFormal && hasAddon {
return errors.New("不允许在同一订单中同时购买正式套餐和加油包")
}
}
return nil
}