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

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