收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -13,10 +13,14 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// orphanPackageScanLimit 单轮最多恢复的真实孤儿载体数量。
|
||||
const orphanPackageScanLimit = 100
|
||||
|
||||
// TrafficSyncer 套餐失效前流量同步接口
|
||||
// 在主套餐标记为已过期之前,从 Gateway 拉取最新流量写入 DB
|
||||
type TrafficSyncer interface {
|
||||
@@ -156,17 +160,48 @@ func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Cont
|
||||
}
|
||||
|
||||
// findAndActivateOrphanPackages 任务 6.1-6.2: 查找孤儿载体并触发激活
|
||||
// 孤儿定义:存在 status=0 的主套餐,但不存在 status=1 的主套餐。
|
||||
// 孤儿定义:存在待生效主套餐,但不存在生效中或已用完的占位主套餐。
|
||||
func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Context) (int, error) {
|
||||
// 查询孤儿待生效主套餐(无生效主套餐但有待生效主套餐)
|
||||
var orphanUsages []*model.PackageUsage
|
||||
err := h.db.WithContext(ctx).
|
||||
Where("status = ?", constants.PackageUsageStatusPending).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("deleted_at IS NULL").
|
||||
Order("priority ASC, created_at ASC").
|
||||
Limit(100).
|
||||
Find(&orphanUsages).Error
|
||||
err := h.db.WithContext(ctx).Raw(`
|
||||
WITH pending_queue AS (
|
||||
SELECT pending.id,
|
||||
pending.priority,
|
||||
pending.created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY
|
||||
CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN 'iot_card' ELSE 'device' END,
|
||||
CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN pending.iot_card_id ELSE pending.device_id END
|
||||
ORDER BY pending.priority ASC, pending.created_at ASC, pending.id ASC
|
||||
) AS queue_position
|
||||
FROM tb_package_usage AS pending
|
||||
WHERE pending.status = ?
|
||||
AND pending.master_usage_id IS NULL
|
||||
AND pending.deleted_at IS NULL
|
||||
AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tb_package_usage AS occupied
|
||||
WHERE occupied.status IN (?, ?)
|
||||
AND occupied.master_usage_id IS NULL
|
||||
AND occupied.deleted_at IS NULL
|
||||
AND (
|
||||
(COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id)
|
||||
OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
SELECT usage.*
|
||||
FROM pending_queue AS candidate
|
||||
JOIN tb_package_usage AS usage ON usage.id = candidate.id
|
||||
WHERE candidate.queue_position = 1
|
||||
ORDER BY candidate.priority ASC, candidate.created_at ASC, candidate.id ASC
|
||||
LIMIT ?`,
|
||||
constants.PackageUsageStatusPending,
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
orphanPackageScanLimit,
|
||||
).Scan(&orphanUsages).Error
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询孤儿套餐失败")
|
||||
}
|
||||
@@ -175,78 +210,32 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 按载体分组,去重
|
||||
type carrierKey struct {
|
||||
carrierType string
|
||||
carrierID uint
|
||||
}
|
||||
carrierMap := make(map[carrierKey]*model.PackageUsage) // 保留 priority 最低的套餐
|
||||
|
||||
for _, usage := range orphanUsages {
|
||||
key := carrierKey{}
|
||||
if usage.IotCardID > 0 {
|
||||
key.carrierType = "iot_card"
|
||||
key.carrierID = usage.IotCardID
|
||||
} else if usage.DeviceID > 0 {
|
||||
key.carrierType = "device"
|
||||
key.carrierID = usage.DeviceID
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查该载体是否已有占位主套餐(生效中或已用完均视为占位,不允许激活待生效套餐)
|
||||
var activeCount int64
|
||||
var countErr error
|
||||
occupiedStatuses := []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}
|
||||
if key.carrierType == "iot_card" {
|
||||
countErr = h.db.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("status IN ?", occupiedStatuses).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("iot_card_id = ?", key.carrierID).
|
||||
Count(&activeCount).Error
|
||||
} else {
|
||||
countErr = h.db.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("status IN ?", occupiedStatuses).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("device_id = ?", key.carrierID).
|
||||
Count(&activeCount).Error
|
||||
}
|
||||
if countErr != nil {
|
||||
h.logger.Warn("检查载体生效套餐失败",
|
||||
zap.String("carrier_type", key.carrierType),
|
||||
zap.Uint("carrier_id", key.carrierID),
|
||||
zap.Error(countErr))
|
||||
continue
|
||||
}
|
||||
|
||||
// 已有生效或已用完(占位)套餐,跳过
|
||||
if activeCount > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 保留购买顺序最靠前的套餐,队首未满足实名条件时不跳过。
|
||||
if existing, ok := carrierMap[key]; ok {
|
||||
if usage.Priority < existing.Priority || (usage.Priority == existing.Priority && usage.CreatedAt.Before(existing.CreatedAt)) {
|
||||
carrierMap[key] = usage
|
||||
}
|
||||
} else {
|
||||
carrierMap[key] = usage
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个孤儿载体提交激活任务
|
||||
count := 0
|
||||
for key, usage := range carrierMap {
|
||||
if err := h.enqueueActivationTask(ctx, usage.ID, key.carrierType, key.carrierID, "orphan_recovery"); err != nil {
|
||||
h.logger.Warn("提交孤儿套餐激活任务失败",
|
||||
for _, usage := range orphanUsages {
|
||||
carrierType, carrierID := h.getCarrierInfo(usage)
|
||||
activated, activationErr := h.activationService.ActivateNextPendingMainPackage(ctx, carrierType, carrierID)
|
||||
if activationErr != nil {
|
||||
h.logger.Warn("孤儿套餐同步激活失败",
|
||||
zap.Uint("package_usage_id", usage.ID),
|
||||
zap.String("carrier_type", key.carrierType),
|
||||
zap.Uint("carrier_id", key.carrierID),
|
||||
zap.Error(err))
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.String("activation_source", "orphan_recovery"),
|
||||
zap.Error(activationErr))
|
||||
continue
|
||||
}
|
||||
if !activated {
|
||||
h.logger.Info("孤儿套餐本轮未激活",
|
||||
zap.Uint("package_usage_id", usage.ID),
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.String("activation_source", "orphan_recovery"))
|
||||
continue
|
||||
}
|
||||
h.logger.Info("孤儿套餐同步激活成功",
|
||||
zap.Uint("package_usage_id", usage.ID),
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.String("activation_source", "orphan_recovery"))
|
||||
count++
|
||||
}
|
||||
|
||||
@@ -269,7 +258,7 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
|
||||
}
|
||||
|
||||
// processExpiredPackage 处理单个过期套餐
|
||||
// 流程:先同步最新流量 → 事务内标记过期/失效/激活下一个 → 事务提交后触发停机
|
||||
// 流程:先同步最新流量 → 事务内标记过期和失效加油包 → 提交后同步接续并触发停机检查
|
||||
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
|
||||
carrierType, carrierID := h.getCarrierInfo(pkg)
|
||||
|
||||
@@ -278,11 +267,19 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
|
||||
h.syncTrafficBeforeExpiry(ctx, carrierType, carrierID)
|
||||
}
|
||||
|
||||
expired := false
|
||||
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
return err
|
||||
result := tx.Model(pkg).
|
||||
Where("status IN ? AND expires_at <= ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, time.Now()).
|
||||
Update("status", constants.PackageUsageStatusExpired)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
expired = true
|
||||
|
||||
expiresAt := time.Now()
|
||||
if pkg.ExpiresAt != nil {
|
||||
@@ -293,52 +290,85 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
|
||||
zap.Time("expires_at", expiresAt))
|
||||
|
||||
// 任务 19.4: 加油包级联失效
|
||||
if err := h.invalidateAddons(ctx, tx, pkg.ID); err != nil {
|
||||
h.logger.Warn("加油包级联失效失败",
|
||||
zap.Uint("master_usage_id", pkg.ID),
|
||||
zap.Error(err))
|
||||
addons, err := h.invalidateAddons(ctx, tx, pkg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 任务 19.5: 查询并激活下一个待生效主套餐
|
||||
if carrierType != "" && carrierID > 0 {
|
||||
if err := h.activateNextPackage(ctx, tx, carrierType, carrierID); err != nil {
|
||||
h.logger.Warn("激活下一个待生效套餐失败",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
if h.activationService == nil {
|
||||
return errors.New(errors.CodeInternalError, "套餐激活服务未注入")
|
||||
}
|
||||
if err := h.activationService.AppendExpirationAudit(ctx, tx, pkg, addons); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if h.activationService != nil {
|
||||
h.activationService.RecordUsageFailure(ctx, constants.AuditActionPackageUsageExpired, "套餐权益到期处理失败", pkg, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !expired {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 事务提交后再触发异步停机,确保 CheckAndStopCard 读到最新的套餐状态
|
||||
if carrierType != "" && carrierID > 0 {
|
||||
activated, activationErr := h.activationService.ActivateNextPendingMainPackage(ctx, carrierType, carrierID)
|
||||
if activationErr != nil {
|
||||
h.logger.Warn("过期后同步接续套餐失败",
|
||||
zap.Uint("expired_package_usage_id", pkg.ID),
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.String("activation_source", "expired_package"),
|
||||
zap.Error(activationErr))
|
||||
} else if activated {
|
||||
h.logger.Info("过期后同步接续套餐成功",
|
||||
zap.Uint("expired_package_usage_id", pkg.ID),
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.String("activation_source", "expired_package"))
|
||||
} else {
|
||||
h.logger.Info("过期后本轮未接续套餐",
|
||||
zap.Uint("expired_package_usage_id", pkg.ID),
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.String("activation_source", "expired_package"))
|
||||
}
|
||||
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
|
||||
return activationErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateAddons 任务 19.4: 加油包级联失效
|
||||
func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
|
||||
func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) ([]*model.PackageUsage, error) {
|
||||
// 查询主套餐下的所有加油包(status IN (0,1,2) 的加油包)
|
||||
result := tx.Model(&model.PackageUsage{}).
|
||||
var addons []*model.PackageUsage
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("master_usage_id = ?", masterUsageID).
|
||||
Where("status IN ?", []int{
|
||||
constants.PackageUsageStatusPending,
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}).
|
||||
}).Find(&addons).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(addons) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]uint, 0, len(addons))
|
||||
for _, addon := range addons {
|
||||
ids = append(ids, addon.ID)
|
||||
}
|
||||
result := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ? AND status IN ?", ids, []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Update("status", constants.PackageUsageStatusInvalidated)
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
if result.RowsAffected > 0 {
|
||||
@@ -347,7 +377,7 @@ func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gor
|
||||
zap.Int64("invalidated_count", result.RowsAffected))
|
||||
}
|
||||
|
||||
return nil
|
||||
return addons, nil
|
||||
}
|
||||
|
||||
// getCarrierInfo 获取载体信息
|
||||
@@ -361,34 +391,6 @@ func (h *PackageActivationHandler) getCarrierInfo(pkg *model.PackageUsage) (stri
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// activateNextPackage 任务 19.5: 激活下一个待生效主套餐
|
||||
func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||
// 查询下一个待生效主套餐
|
||||
// WHERE status=0 AND master_usage_id IS NULL ORDER BY priority ASC LIMIT 1
|
||||
var nextPkg model.PackageUsage
|
||||
query := tx.Where("status = ?", constants.PackageUsageStatusPending).
|
||||
Where("master_usage_id IS NULL"). // 主套餐
|
||||
Order("priority ASC").
|
||||
Limit(1)
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
}
|
||||
|
||||
if err := query.First(&nextPkg).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 没有待生效套餐,正常情况
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 提交 Asynq 任务进行激活(避免长事务)
|
||||
return h.enqueueActivationTask(ctx, nextPkg.ID, carrierType, carrierID, "queue")
|
||||
}
|
||||
|
||||
// triggerStopAfterExpiry 套餐过期后异步触发停机检查
|
||||
// 仅在确认无后续生效套餐时有效;CheckAndStopCard 内部有幂等保护,重复调用安全
|
||||
func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, carrierType string, carrierID uint) {
|
||||
@@ -472,6 +474,10 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
|
||||
h.logger.Error("解析套餐激活任务载荷失败", zap.Error(err))
|
||||
return nil // 不重试
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageQueueActivation,
|
||||
ActorName: "套餐排队激活任务", Source: constants.AuditSourceWorker,
|
||||
})
|
||||
|
||||
h.logger.Info("开始执行套餐激活",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID),
|
||||
@@ -525,6 +531,10 @@ func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Cont
|
||||
h.logger.Error("解析首次实名激活任务载荷失败", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageFirstActivation,
|
||||
ActorName: "套餐首次实名激活任务", Source: constants.AuditSourceWorker,
|
||||
})
|
||||
|
||||
if payload.CarrierType == "" || payload.CarrierID == 0 {
|
||||
h.logger.Error("首次实名激活任务 carrier 信息缺失",
|
||||
|
||||
Reference in New Issue
Block a user