收口审计治理与套餐任务进展

Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
2026-08-05 14:30:54 +08:00
parent b3499adfca
commit 5e552d99bc
178 changed files with 16797 additions and 5674 deletions

View File

@@ -6,6 +6,7 @@ import (
"time"
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/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -30,6 +31,12 @@ type ActivationService struct {
logger *zap.Logger
resumeCallback ResumeCallback // 复机回调,可选
observationSeriesEvents cardObservationApp.SeriesEventWriter
auditWriter *audit.Writer
}
// SetLifecycleAudit 注入套餐权益生命周期统一审计 Writer。
func (s *ActivationService) SetLifecycleAudit(writer *audit.Writer) {
s.auditWriter = writer
}
// SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。
@@ -90,9 +97,11 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
now := time.Now()
activated := false
var failedUsage *model.PackageUsage
// 在事务中激活套餐
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, usage := range pendingUsages {
failedUsage = usage
// 查询套餐信息
var pkg model.Package
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
@@ -150,12 +159,28 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
updates["next_reset_at"] = *nextResetAt
}
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
beforeData := packageUsageStateData(usage)
result := tx.Model(usage).Where("status = ?", constants.PackageUsageStatusPending).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "激活套餐失败")
}
if result.RowsAffected == 0 {
continue
}
activatedUsage := *usage
activatedUsage.Status = constants.PackageUsageStatusActive
activatedUsage.PendingRealnameActivation = false
activatedUsage.ActivatedAt = &activatedAt
activatedUsage.ExpiresAt = &expiresAt
activatedUsage.NextResetAt = nextResetAt
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, "实名后激活套餐权益", []packageUsageAuditChange{{
Usage: &activatedUsage, BeforeData: beforeData, AfterData: packageUsageStateData(&activatedUsage),
}}, nil, map[string]any{"activation_source": "realname"}); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -171,6 +196,7 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
return nil
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "实名后激活套餐权益失败", failedUsage, err)
return err
}
if activated {
@@ -198,6 +224,7 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
defer s.redis.Del(ctx, lockKey)
activated := false
var failedUsage *model.PackageUsage
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 9.5: 检测并标记过期的主套餐
now := time.Now()
@@ -213,9 +240,17 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
}
for _, expiredMain := range expiredMainUsages {
failedUsage = expiredMain
// 更新主套餐状态为已过期
if err := tx.Model(expiredMain).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新过期主套餐状态失败")
mainBeforeData := packageUsageStateData(expiredMain)
result := tx.Model(expiredMain).
Where("status = ?", constants.PackageUsageStatusActive).
Update("status", constants.PackageUsageStatusExpired)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新过期主套餐状态失败")
}
if result.RowsAffected == 0 {
continue
}
expiresAt := now
@@ -227,7 +262,13 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
zap.Time("expires_at", expiresAt))
// 任务 9.7: 加油包级联失效
if err := s.invalidateAddons(ctx, tx, expiredMain.ID); err != nil {
addons, err := s.invalidateAddons(ctx, tx, expiredMain.ID)
if err != nil {
return err
}
expiredUsage := *expiredMain
expiredUsage.Status = constants.PackageUsageStatusExpired
if err := s.appendExpirationAudit(ctx, tx, &expiredUsage, mainBeforeData, addons); err != nil {
return err
}
@@ -242,6 +283,7 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
return nil
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageExpired, "套餐权益到期处理失败", failedUsage, err)
return err
}
if activated {
@@ -341,13 +383,14 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
}
}
if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "specific", "指定套餐已激活"); err != nil {
return err
}
activated = true
return nil
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "激活指定套餐权益失败", &usage, err)
return err
}
if activated {
@@ -381,22 +424,30 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context,
defer s.redis.Del(ctx, lockKey)
activated := false
var failedUsage *model.PackageUsage
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID)
if err != nil {
return err
}
if hasActive {
s.logger.Info("载体已有占位主套餐,本轮不接续",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
if err == gorm.ErrRecordNotFound {
s.logger.Info("载体没有待生效主套餐,本轮不接续",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
if err != nil {
return err
}
failedUsage = nextMain
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
if err != nil {
@@ -416,13 +467,14 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context,
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "队首待生效套餐已激活"); err != nil {
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "queue", "队首待生效套餐已激活"); err != nil {
return err
}
activated = true
return nil
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "激活排队套餐权益失败", failedUsage, err)
return false, err
}
if activated {
@@ -438,16 +490,16 @@ func (s *ActivationService) HasActiveMainPackage(ctx context.Context, carrierTyp
}
// invalidateAddons 任务 9.7: 加油包级联失效
func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) ([]packageUsageAuditChange, error) {
var addons []*model.PackageUsage
if err := tx.Where("master_usage_id = ?", masterUsageID).
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusPending}).
Find(&addons).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败")
}
if len(addons) == 0 {
return nil
return nil, nil
}
addonIDs := make([]uint, len(addons))
@@ -459,14 +511,20 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m
if err := tx.Model(&model.PackageUsage{}).
Where("id IN ?", addonIDs).
Update("status", constants.PackageUsageStatusInvalidated).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败")
}
s.logger.Info("加油包已级联失效",
zap.Uint("master_usage_id", masterUsageID),
zap.Int("addon_count", len(addons)))
return nil
changes := make([]packageUsageAuditChange, 0, len(addons))
for _, addon := range addons {
beforeData := packageUsageStateData(addon)
addon.Status = constants.PackageUsageStatusInvalidated
changes = append(changes, packageUsageAuditChange{Usage: addon, BeforeData: beforeData, AfterData: packageUsageStateData(addon)})
}
return changes, nil
}
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
@@ -502,7 +560,7 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活"); err != nil {
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "queue", "排队主套餐已激活"); err != nil {
return false, err
}
return true, nil
@@ -577,7 +635,7 @@ func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB,
}
}
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error {
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, activationSource, logMessage string) error {
terms, err := ResolveUsageTerms(usage, pkg, s.logger)
if err != nil {
return err
@@ -608,12 +666,27 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
updates["next_reset_at"] = *nextResetAt
}
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
beforeData := packageUsageStateData(usage)
result := tx.Model(usage).Where("status = ?", constants.PackageUsageStatusPending).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "激活排队主套餐失败")
}
if result.RowsAffected == 0 {
return nil
}
usage.Status = constants.PackageUsageStatusActive
usage.PendingRealnameActivation = false
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, logMessage, []packageUsageAuditChange{{
Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage),
}}, nil, map[string]any{"activation_source": activationSource}); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -711,137 +784,139 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
constants.PackageUsageStatusDepleted,
}
// 换货会把套餐使用记录迁移到新资产,但原订单与套餐使用记录的关联保持不变。
// 因此退款必须以订单和套餐使用记录为权威定位键,不能再用旧资产 ID 缩小查询范围。
baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{})
var targets []model.PackageUsage
if packageUsageID != nil && *packageUsageID > 0 {
var usage model.PackageUsage
err := baseQuery.
Where("id = ? AND order_id = ?", *packageUsageID, orderID).
Where("status IN ?", validStatuses).
First(&usage).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
s.logger.Info("退款精准失效:未命中可失效套餐",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Uint("order_id", orderID),
zap.Uint("package_usage_id", *packageUsageID),
)
return nil
var failedUsage *model.PackageUsage
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 换货后权益仍保留原订单关系,退款必须按订单定位并使用权益当前资产快照。
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", validStatuses)
var targets []model.PackageUsage
if packageUsageID != nil && *packageUsageID > 0 {
var usage model.PackageUsage
if err := query.Where("id = ? AND order_id = ?", *packageUsageID, orderID).First(&usage).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败")
}
targets = append(targets, usage)
} else {
if err := baseQuery.
Where("order_id = ?", orderID).
Where("status IN ?", validStatuses).
Find(&targets).Error; err != nil {
targets = append(targets, usage)
} else if err := query.Where("order_id = ?", orderID).Find(&targets).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款订单套餐失败")
}
if len(targets) == 0 {
s.logger.Info("退款精准失效:订单无可失效套餐",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Uint("order_id", orderID),
)
return nil
}
}
targetIDSet := make(map[uint]struct{}, len(targets))
mainUsageIDs := make([]uint, 0, len(targets))
for _, usage := range targets {
targetIDSet[usage.ID] = struct{}{}
if usage.MasterUsageID == nil {
mainUsageIDs = append(mainUsageIDs, usage.ID)
mainUsageIDs := make([]uint, 0, len(targets))
for i := range targets {
if targets[i].MasterUsageID == nil {
mainUsageIDs = append(mainUsageIDs, targets[i].ID)
}
}
}
if len(mainUsageIDs) > 0 {
var addons []model.PackageUsage
if err := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
Where("master_usage_id IN ?", mainUsageIDs).
Where("status IN ?", validStatuses).
Find(&addons).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败")
if len(mainUsageIDs) > 0 {
var addons []model.PackageUsage
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("master_usage_id IN ? AND status IN ?", mainUsageIDs, validStatuses).Find(&addons).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败")
}
targets = append(targets, addons...)
}
for _, addon := range addons {
targetIDSet[addon.ID] = struct{}{}
failedUsage = &targets[0]
targetIDs := make([]uint, 0, len(targets))
changes := make([]packageUsageAuditChange, 0, len(targets))
for i := range targets {
targetIDs = append(targetIDs, targets[i].ID)
beforeData := packageUsageStateData(&targets[i])
targets[i].Status = constants.PackageUsageStatusInvalidated
if refundID > 0 {
targets[i].RefundID = &refundID
}
targets[i].RefundNo = refundNo
changes = append(changes, packageUsageAuditChange{Usage: &targets[i], BeforeData: beforeData, AfterData: packageUsageStateData(&targets[i])})
}
updates := map[string]any{"status": constants.PackageUsageStatusInvalidated}
if refundID > 0 {
updates["refund_id"] = refundID
}
if refundNo != "" {
updates["refund_no"] = refundNo
}
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id IN ? AND status IN ?", targetIDs, validStatuses).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败")
}
if result.RowsAffected != int64(len(targetIDs)) {
return errors.New(errors.CodeConflict, "退款套餐权益状态已变化")
}
var refund *model.RefundRequest
if refundID > 0 {
refund = &model.RefundRequest{}
if err := tx.WithContext(ctx).Where("id = ?", refundID).First(refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联退款单审计快照失败")
}
}
return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益", changes, refund, map[string]any{
"asset_type": assetType, "asset_id": assetID, "order_id": orderID, "refund_id": refundID, "refund_no": refundNo,
})
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益失败", failedUsage, err)
return err
}
targetIDs := make([]uint, 0, len(targetIDSet))
for id := range targetIDSet {
targetIDs = append(targetIDs, id)
}
if len(targetIDs) == 0 {
return nil
}
updates := map[string]any{
"status": constants.PackageUsageStatusInvalidated,
}
if refundID > 0 {
updates["refund_id"] = refundID
}
if refundNo != "" {
updates["refund_no"] = refundNo
}
result := s.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("id IN ?", targetIDs).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败")
}
s.logger.Info("退款精准失效套餐完成",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Uint("order_id", orderID),
zap.Uint("refund_id", refundID),
zap.String("refund_no", refundNo),
zap.Int("target_count", len(targetIDs)),
zap.Int64("affected", result.RowsAffected),
)
return nil
}
// InvalidateAllPackagesByAsset 批量失效资产关联的所有有效套餐
// 退款时调用:将该资产下状态为待生效(0)、生效中(1)、已用完(2)的套餐全部标记为已失效(4)
func (s *ActivationService) InvalidateAllPackagesByAsset(ctx context.Context, assetType string, assetID uint) error {
query := s.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status IN ?", []int{
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
})
switch assetType {
case "iot_card":
query = query.Where("iot_card_id = ?", assetID)
case "device":
query = query.Where("device_id = ?", assetID)
default:
validStatuses := []int{
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
}
if assetType != constants.AssetTypeIotCard && assetType != constants.AssetTypeDevice {
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
result := query.Update("status", constants.PackageUsageStatusInvalidated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败")
var failedUsage *model.PackageUsage
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", validStatuses)
query = query.Where(assetType+"_id = ?", assetID)
var targets []model.PackageUsage
if err := query.Find(&targets).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产关联套餐权益失败")
}
if len(targets) == 0 {
return nil
}
failedUsage = &targets[0]
ids := make([]uint, 0, len(targets))
changes := make([]packageUsageAuditChange, 0, len(targets))
for i := range targets {
ids = append(ids, targets[i].ID)
beforeData := packageUsageStateData(&targets[i])
targets[i].Status = constants.PackageUsageStatusInvalidated
changes = append(changes, packageUsageAuditChange{Usage: &targets[i], BeforeData: beforeData, AfterData: packageUsageStateData(&targets[i])})
}
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id IN ? AND status IN ?", ids, validStatuses).
Update("status", constants.PackageUsageStatusInvalidated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败")
}
if result.RowsAffected != int64(len(ids)) {
return errors.New(errors.CodeConflict, "资产套餐权益状态已变化")
}
return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益", changes, nil, map[string]any{
"asset_type": assetType, "asset_id": assetID,
})
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益失败", failedUsage, err)
return err
}
s.logger.Info("批量失效套餐完成",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Int64("affected", result.RowsAffected),
)
return nil

View File

@@ -0,0 +1,111 @@
package packagepkg
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SetAccessAudit 注入套餐商品统一审计 Writer。
func (s *Service) SetAccessAudit(db *gorm.DB, writer *audit.Writer) {
s.db = db
s.auditWriter = writer
}
func (s *Service) appendPackageAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, pkg *model.Package, beforeData, afterData map[string]any) error {
if s.auditWriter == nil || s.db == nil {
return errors.New(errors.CodeInvalidStatus, "套餐商品统一审计接缝未配置")
}
resources := []audit.ResourceInput{
audit.PackageResource(pkg, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageTarget, beforeData, afterData),
}
if pkg.SeriesID > 0 {
var series model.PackageSeries
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", pkg.SeriesID).First(&series).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列审计快照失败")
}
resources = append(resources, audit.PackageSeriesResource(&series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil))
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, Resources: resources,
})
}
func (s *Service) appendAllocationAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, allocation *model.ShopPackageAllocation, pkg *model.Package, beforeData, afterData map[string]any) error {
if s.auditWriter == nil || s.db == nil {
return errors.New(errors.CodeInvalidStatus, "店铺套餐统一审计接缝未配置")
}
resources := []audit.ResourceInput{
audit.ShopPackageAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, beforeData, afterData),
audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil),
}
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", allocation.ShopID).First(&shop).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺套餐审计快照失败")
}
resources = append(resources, audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop))
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop,
ScopeID: allocationShopID(allocation), Result: constants.AuditResultSuccess, Resources: resources,
})
}
func (s *Service) recordPackageFailure(ctx context.Context, actionCode, summary string, pkg *model.Package, beforeData map[string]any, businessErr error) {
if pkg == nil {
return
}
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Resources: []audit.ResourceInput{audit.PackageResource(
pkg, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageTarget, beforeData, nil,
)},
}, businessErr)
}
func (s *Service) recordAllocationFailure(ctx context.Context, actionCode, summary string, allocation *model.ShopPackageAllocation, pkg *model.Package, beforeData map[string]any, businessErr error) {
if allocation == nil || pkg == nil {
return
}
shop := &model.Shop{Model: gorm.Model{ID: allocation.ShopID}}
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop,
ScopeID: allocationShopID(allocation), Resources: []audit.ResourceInput{
audit.ShopPackageAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, beforeData, nil),
audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil),
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
},
}, businessErr)
}
func allocationShopID(allocation *model.ShopPackageAllocation) string {
if allocation == nil {
return ""
}
return uintString(allocation.ShopID)
}
func uintString(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
func packageData(pkg *model.Package) map[string]any {
if pkg == nil {
return nil
}
return map[string]any{
"package_name": pkg.PackageName, "series_id": pkg.SeriesID, "package_type": pkg.PackageType,
"duration_months": pkg.DurationMonths, "duration_days": pkg.DurationDays,
"real_data_mb": pkg.RealDataMB, "virtual_data_mb": pkg.VirtualDataMB,
"enable_virtual_data": pkg.EnableVirtualData, "cost_price": pkg.CostPrice,
"suggested_retail_price": pkg.SuggestedRetailPrice, "price_config_status": pkg.PriceConfigStatus,
"is_gift": pkg.IsGift, "status": pkg.Status, "shelf_status": pkg.ShelfStatus,
"calendar_type": pkg.CalendarType, "data_reset_cycle": pkg.DataResetCycle, "expiry_base": pkg.ExpiryBase,
}
}

View File

@@ -0,0 +1,221 @@
package packagepkg
import (
"context"
"sort"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type packageUsageAuditChange struct {
Usage *model.PackageUsage
BeforeData map[string]any
AfterData map[string]any
}
func appendPackageUsageAudit(ctx context.Context, tx *gorm.DB, writer *audit.Writer, actionCode, summary string, changes []packageUsageAuditChange, refund *model.RefundRequest, metadata map[string]any) error {
if writer == nil {
return errors.New(errors.CodeInvalidStatus, "套餐权益统一审计接缝未配置")
}
resources, err := packageUsageAuditResources(ctx, tx, changes, refund, summary)
if err != nil {
return err
}
return writer.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, Metadata: metadata, Resources: resources,
})
}
func (s *ActivationService) appendExpirationAudit(ctx context.Context, tx *gorm.DB, main *model.PackageUsage, mainBeforeData map[string]any, addons []packageUsageAuditChange) error {
changes := make([]packageUsageAuditChange, 0, 1+len(addons))
changes = append(changes, packageUsageAuditChange{Usage: main, BeforeData: mainBeforeData, AfterData: packageUsageStateData(main)})
changes = append(changes, addons...)
return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageExpired, "套餐权益到期并处理关联加油包", changes, nil, map[string]any{
"invalidated_addon_count": len(addons),
})
}
// AppendExpirationAudit 在调度器的既有过期事务内记录实际权益变化。
func (s *ActivationService) AppendExpirationAudit(ctx context.Context, tx *gorm.DB, main *model.PackageUsage, addons []*model.PackageUsage) error {
if main == nil {
return errors.New(errors.CodeInvalidParam, "过期套餐权益审计资源不完整")
}
mainAfter := *main
mainAfter.Status = constants.PackageUsageStatusExpired
addonChanges := make([]packageUsageAuditChange, 0, len(addons))
for _, addon := range addons {
if addon == nil {
continue
}
beforeData := packageUsageStateData(addon)
after := *addon
after.Status = constants.PackageUsageStatusInvalidated
addonChanges = append(addonChanges, packageUsageAuditChange{Usage: &after, BeforeData: beforeData, AfterData: packageUsageStateData(&after)})
}
return s.appendExpirationAudit(ctx, tx, &mainAfter, packageUsageStateData(main), addonChanges)
}
// RecordUsageFailure 在权益已定位且业务事务回滚后记录失败事实。
func (s *ActivationService) RecordUsageFailure(ctx context.Context, actionCode, summary string, usage *model.PackageUsage, businessErr error) {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, actionCode, summary, usage, businessErr)
}
func packageUsageAuditResources(ctx context.Context, tx *gorm.DB, changes []packageUsageAuditChange, refund *model.RefundRequest, subjectSummary string) ([]audit.ResourceInput, error) {
if len(changes) == 0 || changes[0].Usage == nil || changes[0].Usage.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "套餐权益审计资源不完整")
}
orderIDs, packageIDs, cardIDs, deviceIDs := packageUsageReferenceIDs(changes)
orders, packages, cards, devices, err := loadPackageUsageReferences(ctx, tx, orderIDs, packageIDs, cardIDs, deviceIDs)
if err != nil {
return nil, err
}
resources := make([]audit.ResourceInput, 0, len(changes)+len(orders)+len(packages)+len(cards)+len(devices)+1)
for index, change := range changes {
if change.Usage == nil || change.Usage.ID == 0 {
continue
}
relation := constants.AuditResourceRelationAffected
if index == 0 {
relation = constants.AuditResourceRelationPrimary
}
resource := audit.PackageUsageResource(change.Usage, relation, constants.AuditResourceRolePackageUsageTarget, change.BeforeData, change.AfterData)
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = subjectSummary
resources = append(resources, resource)
}
for i := range orders {
resources = append(resources, audit.OrderResource(&orders[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageOrder))
}
for i := range packages {
resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsagePackage, nil, nil))
}
for i := range cards {
id := strconv.FormatUint(uint64(cards[i].ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(&cards[i]), DisplayName: cards[i].ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePackageUsageAsset,
IdentitySnapshot: audit.IotCardIdentitySnapshot(&cards[i]), SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subjectSummary,
})
}
for i := range devices {
id := strconv.FormatUint(uint64(devices[i].ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(&devices[i]), DisplayName: devices[i].VirtualNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePackageUsageAsset,
IdentitySnapshot: audit.DeviceIdentitySnapshot(&devices[i]), SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subjectSummary,
})
}
if refund != nil && refund.ID > 0 {
resources = append(resources, audit.RefundResource(refund, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageRefund))
}
return resources, nil
}
func packageUsageReferenceIDs(changes []packageUsageAuditChange) ([]uint, []uint, []uint, []uint) {
orders, packages, cards, devices := map[uint]struct{}{}, map[uint]struct{}{}, map[uint]struct{}{}, map[uint]struct{}{}
for _, change := range changes {
if change.Usage == nil {
continue
}
orders[change.Usage.OrderID] = struct{}{}
packages[change.Usage.PackageID] = struct{}{}
if change.Usage.IotCardID > 0 {
cards[change.Usage.IotCardID] = struct{}{}
}
if change.Usage.DeviceID > 0 {
devices[change.Usage.DeviceID] = struct{}{}
}
}
return mapUintKeys(orders), mapUintKeys(packages), mapUintKeys(cards), mapUintKeys(devices)
}
func loadPackageUsageReferences(ctx context.Context, tx *gorm.DB, orderIDs, packageIDs, cardIDs, deviceIDs []uint) ([]model.Order, []model.Package, []model.IotCard, []model.Device, error) {
var orders []model.Order
if len(orderIDs) > 0 {
if err := tx.WithContext(ctx).Where("id IN ?", orderIDs).Order("id ASC").Find(&orders).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联订单审计快照失败")
}
}
var packages []model.Package
if len(packageIDs) > 0 {
if err := tx.WithContext(ctx).Where("id IN ?", packageIDs).Order("id ASC").Find(&packages).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联套餐审计快照失败")
}
}
var cards []model.IotCard
if len(cardIDs) > 0 {
if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Order("id ASC").Find(&cards).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联卡审计快照失败")
}
}
var devices []model.Device
if len(deviceIDs) > 0 {
if err := tx.WithContext(ctx).Where("id IN ?", deviceIDs).Order("id ASC").Find(&devices).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联设备审计快照失败")
}
}
return orders, packages, cards, devices, nil
}
func mapUintKeys(values map[uint]struct{}) []uint {
result := make([]uint, 0, len(values))
for value := range values {
if value > 0 {
result = append(result, value)
}
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}
func packageUsageStateData(usage *model.PackageUsage) map[string]any {
if usage == nil {
return nil
}
return map[string]any{
"status": usage.Status, "data_usage_mb": usage.DataUsageMB,
"pending_realname_activation": usage.PendingRealnameActivation,
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt,
"last_reset_at": usage.LastResetAt, "next_reset_at": usage.NextResetAt,
"refund_id": usage.RefundID, "refund_no": usage.RefundNo,
"iot_card_id": usage.IotCardID, "device_id": usage.DeviceID,
}
}
func normalizePackageUsageAuditChanges(changes []packageUsageAuditChange) []packageUsageAuditChange {
result := make([]packageUsageAuditChange, 0, len(changes))
positions := make(map[uint]int, len(changes))
for _, change := range changes {
if change.Usage == nil || change.Usage.ID == 0 {
continue
}
if position, ok := positions[change.Usage.ID]; ok {
result[position].Usage = change.Usage
result[position].AfterData = change.AfterData
continue
}
positions[change.Usage.ID] = len(result)
result = append(result, change)
}
return result
}
func recordPackageUsageFailure(ctx context.Context, db *gorm.DB, writer *audit.Writer, actionCode, summary string, usage *model.PackageUsage, businessErr error) {
if usage == nil || usage.ID == 0 || writer == nil || db == nil {
return
}
writer.RecordFailure(ctx, db, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Resources: []audit.ResourceInput{audit.PackageUsageResource(
usage, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageUsageTarget, packageUsageStateData(usage), nil,
)},
}, businessErr)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -11,6 +12,7 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ResetService struct {
@@ -19,6 +21,12 @@ type ResetService struct {
packageUsageStore *postgres.PackageUsageStore
logger *zap.Logger
resumeCallback ResumeCallback
auditWriter *audit.Writer
}
// SetLifecycleAudit 注入套餐权益流量重置统一审计 Writer。
func (s *ResetService) SetLifecycleAudit(writer *audit.Writer) {
s.auditWriter = writer
}
func NewResetService(
@@ -55,6 +63,7 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetDaily).
Where("next_reset_at <= ?", now).
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&packages).Error
if err != nil {
@@ -80,21 +89,40 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e
"status": constants.PackageUsageStatusActive,
}
if err := tx.Model(&model.PackageUsage{}).
Where("id IN ?", packageIDs).
Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "批量重置日流量失败")
result := tx.Model(&model.PackageUsage{}).
Where("id IN ? AND next_reset_at <= ? AND status IN ?", packageIDs, now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量重置日流量失败")
}
if result.RowsAffected != int64(len(packages)) {
return errors.New(errors.CodeConflict, "日流量重置目标状态已变化")
}
changes := make([]packageUsageAuditChange, 0, len(packages))
for _, usage := range packages {
beforeData := packageUsageStateData(usage)
usage.DataUsageMB = 0
usage.LastResetAt = &now
usage.NextResetAt = &nextReset
usage.Status = constants.PackageUsageStatusActive
changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)})
}
resetPackages = packages
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益日流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetDaily}); err != nil {
return err
}
s.logger.Info("日流量重置完成",
zap.Int("count", len(packages)),
zap.Time("next_reset_at", nextReset))
resetPackages = packages
return nil
})
if err != nil {
if len(resetPackages) > 0 {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益日流量失败", resetPackages[0], err)
}
return err
}
@@ -117,6 +145,7 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetMonthly).
Where("next_reset_at <= ?", now).
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&packages).Error
if err != nil {
@@ -128,6 +157,7 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
return nil
}
changes := make([]packageUsageAuditChange, 0, len(packages))
for _, usage := range packages {
var pkg model.Package
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
@@ -156,9 +186,22 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
"status": constants.PackageUsageStatusActive,
}
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重置月流量失败")
beforeData := packageUsageStateData(usage)
result := tx.Model(usage).
Where("next_reset_at <= ? AND status IN ?", now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "重置月流量失败")
}
if result.RowsAffected == 0 {
continue
}
usage.DataUsageMB = 0
usage.LastResetAt = &now
usage.NextResetAt = nextResetAt
usage.Status = constants.PackageUsageStatusActive
changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)})
resetPackages = append(resetPackages, usage)
s.logger.Info("月流量已重置",
zap.Uint("usage_id", usage.ID),
@@ -166,11 +209,18 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
zap.Time("next_reset_at", *nextResetAt))
}
resetPackages = packages
if len(changes) > 0 {
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益月流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetMonthly}); err != nil {
return err
}
}
return nil
})
if err != nil {
if len(resetPackages) > 0 {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益月流量失败", resetPackages[0], err)
}
return err
}
@@ -193,6 +243,7 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB)
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetYearly).
Where("next_reset_at <= ?", now).
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&packages).Error
if err != nil {
@@ -218,21 +269,40 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB)
"status": constants.PackageUsageStatusActive,
}
if err := tx.Model(&model.PackageUsage{}).
Where("id IN ?", packageIDs).
Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "批量重置年流量失败")
result := tx.Model(&model.PackageUsage{}).
Where("id IN ? AND next_reset_at <= ? AND status IN ?", packageIDs, now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量重置年流量失败")
}
if result.RowsAffected != int64(len(packages)) {
return errors.New(errors.CodeConflict, "年流量重置目标状态已变化")
}
changes := make([]packageUsageAuditChange, 0, len(packages))
for _, usage := range packages {
beforeData := packageUsageStateData(usage)
usage.DataUsageMB = 0
usage.LastResetAt = &now
usage.NextResetAt = &nextReset
usage.Status = constants.PackageUsageStatusActive
changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)})
}
resetPackages = packages
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益年流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetYearly}); err != nil {
return err
}
s.logger.Info("年流量重置完成",
zap.Int("count", len(packages)),
zap.Time("next_reset_at", nextReset))
resetPackages = packages
return nil
})
if err != nil {
if len(resetPackages) > 0 {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益年流量失败", resetPackages[0], err)
}
return err
}

View File

@@ -7,6 +7,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
@@ -18,10 +19,12 @@ import (
)
type Service struct {
db *gorm.DB
packageStore *postgres.PackageStore
packageSeriesStore *postgres.PackageSeriesStore
packageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
auditWriter *audit.Writer
}
func New(
@@ -38,7 +41,7 @@ func New(
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*dto.PackageResponse, error) {
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (_ *dto.PackageResponse, retErr error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -114,6 +117,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
Status: constants.StatusEnabled,
ShelfStatus: 2,
}
defer func() {
if retErr != nil {
failedPackage := *pkg
failedPackage.ID = 0
s.recordPackageFailure(ctx, constants.AuditActionPackageCreated, "创建套餐商品失败 "+pkg.PackageCode, &failedPackage, nil, retErr)
}
}()
if req.SeriesID != nil {
pkg.SeriesID = *req.SeriesID
}
@@ -140,8 +150,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
pkg.Creator = currentUserID
if err := s.packageStore.Create(ctx, pkg); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐失败")
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewPackageStore(tx).Create(ctx, pkg); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建套餐失败")
}
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageCreated, "创建套餐商品 "+pkg.PackageCode, pkg, nil, packageData(pkg))
}); err != nil {
return nil, err
}
resp := s.toResponse(ctx, pkg)
@@ -157,7 +172,6 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
resp := s.toResponse(ctx, pkg)
// 查询系列名称
if pkg.SeriesID > 0 {
@@ -191,7 +205,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error
return resp, nil
}
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (*dto.PackageResponse, error) {
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (_ *dto.PackageResponse, retErr error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -204,6 +218,12 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
before := *pkg
defer func() {
if retErr != nil {
s.recordPackageFailure(ctx, constants.AuditActionPackageUpdated, "更新套餐商品失败 "+before.PackageCode, &before, packageData(&before), retErr)
}
}()
var seriesName *string
if req.SeriesID != nil && *req.SeriesID > 0 {
@@ -305,8 +325,13 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
pkg.Updater = currentUserID
if err := s.packageStore.Update(ctx, pkg); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "更新套餐失败")
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新套餐失败")
}
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageUpdated, "更新套餐商品 "+pkg.PackageCode, pkg, packageData(&before), packageData(pkg))
}); err != nil {
return nil, err
}
resp := s.toResponse(ctx, pkg)
@@ -314,20 +339,26 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
return resp, nil
}
func (s *Service) Delete(ctx context.Context, id uint) error {
_, err := s.packageStore.GetByID(ctx, id)
func (s *Service) Delete(ctx context.Context, id uint) (retErr error) {
pkg, err := s.packageStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "套餐不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
defer func() {
if retErr != nil {
s.recordPackageFailure(ctx, constants.AuditActionPackageDeleted, "删除套餐商品失败 "+pkg.PackageCode, pkg, packageData(pkg), retErr)
}
}()
if err := s.packageStore.Delete(ctx, id); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除套餐失败")
}
return nil
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewPackageStore(tx).Delete(ctx, id); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除套餐失败")
}
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageDeleted, "删除套餐商品 "+pkg.PackageCode, pkg, packageData(pkg), map[string]any{"deleted": true})
})
}
func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto.PackageResponse, int64, error) {
@@ -444,7 +475,7 @@ func (s *Service) batchGetSeriesAllocationsForShop(ctx context.Context, shopID u
return result
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) (retErr error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -457,6 +488,12 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
}
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
before := *pkg
defer func() {
if retErr != nil {
s.recordPackageFailure(ctx, constants.AuditActionPackageStatusUpdated, "更新套餐商品状态失败 "+before.PackageCode, &before, map[string]any{"status": before.Status, "shelf_status": before.ShelfStatus}, retErr)
}
}()
pkg.Status = status
pkg.Updater = currentUserID
@@ -465,14 +502,16 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
pkg.ShelfStatus = 2
}
if err := s.packageStore.Update(ctx, pkg); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新套餐状态失败")
}
return nil
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新套餐状态失败")
}
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageStatusUpdated, "更新套餐商品状态 "+pkg.PackageCode, pkg,
map[string]any{"status": before.Status, "shelf_status": before.ShelfStatus}, map[string]any{"status": pkg.Status, "shelf_status": pkg.ShelfStatus})
})
}
func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) error {
func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) (retErr error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -493,6 +532,12 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in
}
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
before := *pkg
defer func() {
if retErr != nil {
s.recordPackageFailure(ctx, constants.AuditActionPackageShelfStatusUpdated, "更新套餐上架状态失败 "+before.PackageCode, &before, map[string]any{"shelf_status": before.ShelfStatus}, retErr)
}
}()
if shelfStatus == constants.ShelfStatusOn && pkg.Status == constants.StatusDisabled {
return errors.New(errors.CodeInvalidStatus, "禁用的套餐不能上架,请先启用")
@@ -501,15 +546,17 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in
pkg.ShelfStatus = shelfStatus
pkg.Updater = currentUserID
if err := s.packageStore.Update(ctx, pkg); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新套餐上架状态失败")
}
return nil
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新套餐上架状态失败")
}
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageShelfStatusUpdated, "更新套餐上架状态 "+pkg.PackageCode, pkg,
map[string]any{"shelf_status": before.ShelfStatus}, map[string]any{"shelf_status": pkg.ShelfStatus})
})
}
// UpdateRetailPrice 代理修改自己店铺的套餐零售价
func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error {
func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) (retErr error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -540,6 +587,14 @@ func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailP
}
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
beforePrice, beforeStatus := allocation.RetailPrice, allocation.RetailPriceConfigStatus
beforeAllocation := *allocation
defer func() {
if retErr != nil {
s.recordAllocationFailure(ctx, constants.AuditActionPackageRetailPriceUpdated, "更新店铺套餐零售价失败 "+pkg.PackageCode, &beforeAllocation, pkg,
map[string]any{"retail_price": beforePrice, "retail_price_config_status": beforeStatus}, retErr)
}
}()
if pkg.IsGift {
return errors.New(errors.CodeForbidden, "赠送套餐不允许代理修改零售价")
}
@@ -550,16 +605,19 @@ func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailP
if retailPrice < allocation.CostPrice {
return errors.New(errors.CodeInvalidParam, "零售价不能低于成本价")
}
if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, storedRetailPrice, priceConfigStatus, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
}
return nil
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewShopPackageAllocationStore(tx).UpdateRetailPrice(ctx, allocation.ID, storedRetailPrice, priceConfigStatus, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
}
allocation.RetailPrice, allocation.RetailPriceConfigStatus = storedRetailPrice, priceConfigStatus
return s.appendAllocationAudit(ctx, tx, constants.AuditActionPackageRetailPriceUpdated, "更新店铺套餐零售价 "+pkg.PackageCode, allocation, pkg,
map[string]any{"retail_price": beforePrice, "retail_price_config_status": beforeStatus},
map[string]any{"retail_price": storedRetailPrice, "retail_price_config_status": priceConfigStatus})
})
}
// updateAgentShelfStatus 代理上下架路径:更新分配记录的 shelf_status
func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) error {
func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) (retErr error) {
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
@@ -573,26 +631,34 @@ func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, sh
}
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
beforeShelfStatus := allocation.ShelfStatus
beforeAllocation := *allocation
pkg, err := s.packageStore.GetByID(ctx, packageID)
if err != nil {
return errors.New(errors.CodeNotFound, "套餐不存在")
}
defer func() {
if retErr != nil {
s.recordAllocationFailure(ctx, constants.AuditActionShopPackageShelfStatusUpdated, "更新店铺套餐上架状态失败 "+pkg.PackageCode, &beforeAllocation, pkg,
map[string]any{"shelf_status": beforeShelfStatus}, retErr)
}
}()
// 上架时检查套餐全局禁用状态
if shelfStatus == constants.ShelfStatusOn {
pkg, err := s.packageStore.GetByID(ctx, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "套餐不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
if pkg.Status == constants.StatusDisabled {
return errors.New(errors.CodeInvalidStatus, "套餐已禁用,无法上架")
}
}
if err := s.packageAllocationStore.UpdateShelfStatus(ctx, allocation.ID, shelfStatus, updaterID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新上下架状态失败")
}
return nil
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewShopPackageAllocationStore(tx).UpdateShelfStatus(ctx, allocation.ID, shelfStatus, updaterID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新上下架状态失败")
}
allocation.ShelfStatus = shelfStatus
return s.appendAllocationAudit(ctx, tx, constants.AuditActionShopPackageShelfStatusUpdated, "更新店铺套餐上架状态 "+pkg.PackageCode, allocation, pkg,
map[string]any{"shelf_status": beforeShelfStatus}, map[string]any{"shelf_status": shelfStatus})
})
}
func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.PackageResponse {
@@ -607,29 +673,29 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
}
resp := &dto.PackageResponse{
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
IsGift: pkg.IsGift,
DurationMonths: pkg.DurationMonths,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
EnableVirtualData: pkg.EnableVirtualData,
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
CostPrice: pkg.CostPrice,
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
PriceConfigStatus: pkg.PriceConfigStatus,
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
CalendarType: pkg.CalendarType,
DurationDays: durationDays,
DataResetCycle: pkg.DataResetCycle,
ExpiryBase: pkg.ExpiryBase,
Status: pkg.Status,
ShelfStatus: pkg.ShelfStatus,
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
IsGift: pkg.IsGift,
DurationMonths: pkg.DurationMonths,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
EnableVirtualData: pkg.EnableVirtualData,
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
CostPrice: pkg.CostPrice,
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
PriceConfigStatus: pkg.PriceConfigStatus,
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
CalendarType: pkg.CalendarType,
DurationDays: durationDays,
DataResetCycle: pkg.DataResetCycle,
ExpiryBase: pkg.ExpiryBase,
Status: pkg.Status,
ShelfStatus: pkg.ShelfStatus,
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
}
initPackageExpiryBaseFields(resp, pkg)
@@ -686,29 +752,29 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
}
resp := &dto.PackageResponse{
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
IsGift: pkg.IsGift,
DurationMonths: pkg.DurationMonths,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
EnableVirtualData: pkg.EnableVirtualData,
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
CostPrice: pkg.CostPrice,
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
PriceConfigStatus: pkg.PriceConfigStatus,
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
CalendarType: pkg.CalendarType,
DurationDays: durationDays,
DataResetCycle: pkg.DataResetCycle,
ExpiryBase: pkg.ExpiryBase,
Status: pkg.Status,
ShelfStatus: pkg.ShelfStatus,
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
IsGift: pkg.IsGift,
DurationMonths: pkg.DurationMonths,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
EnableVirtualData: pkg.EnableVirtualData,
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
CostPrice: pkg.CostPrice,
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
PriceConfigStatus: pkg.PriceConfigStatus,
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
CalendarType: pkg.CalendarType,
DurationDays: durationDays,
DataResetCycle: pkg.DataResetCycle,
ExpiryBase: pkg.ExpiryBase,
Status: pkg.Status,
ShelfStatus: pkg.ShelfStatus,
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
}
initPackageExpiryBaseFields(resp, pkg)

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -38,6 +39,12 @@ type UsageService struct {
deviceSimBindingStore *postgres.DeviceSimBindingStore
logger *zap.Logger
stopResumeCallback StopResumeCallback // 停复机回调,可选
auditWriter *audit.Writer
}
// SetLifecycleAudit 注入套餐权益流量扣减统一审计 Writer。
func (s *UsageService) SetLifecycleAudit(writer *audit.Writer) {
s.auditWriter = writer
}
func NewUsageService(
@@ -81,6 +88,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
shouldSuspend := false
suspendCarrierType := ""
var suspendCarrierID uint
var auditChanges []packageUsageAuditChange
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID)
@@ -116,9 +124,12 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
isLastPackage := index == len(packages)-1
if remainingQuota <= 0 && !isLastPackage {
// 套餐已用完,标记为已用完
beforeData := packageUsageStateData(pkg)
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
}
pkg.Status = constants.PackageUsageStatusDepleted
auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
continue
}
@@ -132,9 +143,12 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
deductFromPkg = remainingQuota
}
if deductFromPkg <= 0 {
beforeData := packageUsageStateData(pkg)
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
}
pkg.Status = constants.PackageUsageStatusDepleted
auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
continue
}
@@ -149,14 +163,20 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
updates["status"] = constants.PackageUsageStatusDepleted
}
beforeData := packageUsageStateData(pkg)
if err := tx.Model(pkg).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐使用量失败")
}
pkg.DataUsageMB = newUsage
if status, ok := updates["status"].(int); ok {
pkg.Status = status
}
// 任务 10.6: 写入日记录
if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, deductFromPkg, newUsage); err != nil {
return err
}
auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
remainingUsage -= deductFromPkg
@@ -171,17 +191,28 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
}
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
shouldSuspendCurrent, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID)
shouldSuspendCurrent, suspensionChanges, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID)
if err != nil {
return err
}
auditChanges = append(auditChanges, suspensionChanges...)
shouldSuspend = shouldSuspendCurrent
suspendCarrierType = targetCarrierType
suspendCarrierID = targetCarrierID
if len(auditChanges) > 0 {
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量", normalizePackageUsageAuditChanges(auditChanges), nil, map[string]any{
"carrier_type": targetCarrierType, "carrier_id": targetCarrierID, "usage_mb": deductUsageMB,
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if len(auditChanges) > 0 {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量失败", auditChanges[0].Usage, err)
}
return err
}
@@ -360,7 +391,7 @@ func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packa
}
// checkAndTriggerSuspension 任务 10.5: 检查停机条件
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, []packageUsageAuditChange, error) {
query := tx.Model(&model.PackageUsage{}).
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
@@ -369,24 +400,28 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
} else if carrierType == constants.AssetTypeDevice {
query = query.Where("device_id = ?", carrierID)
} else {
return false, errors.New(errors.CodeInvalidParam, "无效的载体类型")
return false, nil, errors.New(errors.CodeInvalidParam, "无效的载体类型")
}
var packages []*model.PackageUsage
if err := query.Find(&packages).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败")
return false, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败")
}
hasAvailablePackage := false
var changes []packageUsageAuditChange
for _, pkg := range packages {
if pkg == nil {
continue
}
if pkg.IsTrafficDepleted() {
if pkg.Status != constants.PackageUsageStatusDepleted {
beforeData := packageUsageStateData(pkg)
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败")
return false, nil, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败")
}
pkg.Status = constants.PackageUsageStatusDepleted
changes = append(changes, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
}
continue
}
@@ -398,10 +433,10 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
s.logger.Warn("所有套餐已用完,触发停机",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return true, nil
return true, changes, nil
}
return false, nil
return false, changes, nil
}
// triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。