收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -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, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
|
||||
if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &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
|
||||
|
||||
111
internal/service/package/audit.go
Normal file
111
internal/service/package/audit.go
Normal 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,
|
||||
}
|
||||
}
|
||||
221
internal/service/package/lifecycle_audit.go
Normal file
221
internal/service/package/lifecycle_audit.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 在事务提交后触发停机检查,避免回调读到未提交状态。
|
||||
|
||||
Reference in New Issue
Block a user