避免套餐过期后排队权益永久失联
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m6s

线上保持现有纯 Asynq 架构,以公平孤儿扫描和提交后投递消除永久饥饿及事务可见性竞态。

Constraint: 线上保持现有纯 Asynq 架构,不引入 Outbox、迁移或新任务基础设施。

Rejected: 事务内投递或扩大扫描 LIMIT | 无法消除竞态和永久饥饿。

Confidence: high

Scope-risk: narrow

Directive: 后续分支整合时按目标分支的套餐接续架构独立处理,不混用本热修实现。

Tested: go build ./...(退出码 0);git diff --check;openspec validate fix-main-package-activation-starvation --strict。

Not-tested: 按用户要求未新增、修改或运行自动化测试;线上 SQL、查询计划和日志待部署后核验。
This commit is contained in:
2026-08-03 09:58:05 +08:00
parent 1efb665619
commit a0de08d789
9 changed files with 369 additions and 103 deletions

View File

@@ -228,14 +228,14 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
// ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录
// 根据 PackageUsageID 精准激活目标套餐,而非重跑"查找过期包"流程
func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, packageUsageID uint) error {
func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, packageUsageID uint) (bool, error) {
// 加载 PackageUsage
var usage model.PackageUsage
if err := s.db.WithContext(ctx).First(&usage, packageUsageID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "套餐使用记录不存在")
return false, errors.New(errors.CodeNotFound, "套餐使用记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
}
// 幂等检查:非待生效状态直接返回
@@ -243,7 +243,7 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
s.logger.Info("套餐无需激活(非待生效状态)",
zap.Uint("usage_id", usage.ID),
zap.Int("status", usage.Status))
return nil
return false, nil
}
// 确定载体类型和 ID
@@ -256,7 +256,7 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
carrierType = "device"
carrierID = usage.DeviceID
} else {
return errors.New(errors.CodeInvalidParam, "套餐使用记录缺少载体信息")
return false, errors.New(errors.CodeInvalidParam, "套餐使用记录缺少载体信息")
}
// 获取分布式锁
@@ -264,24 +264,25 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
lockValue := time.Now().String()
locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败")
return false, errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败")
}
if !locked {
s.logger.Warn("套餐激活正在进行中,跳过",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
return false, errors.New(errors.CodePackageActivationConflict)
}
defer s.redis.Del(ctx, lockKey)
// 加载关联 Package
var pkg model.Package
if err := s.db.WithContext(ctx).First(&pkg, usage.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
// 事务内激活
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
activated := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 再次幂等检查(防止锁释放期间被其他操作激活)
var currentUsage model.PackageUsage
if err := tx.First(&currentUsage, packageUsageID).Error; err != nil {
@@ -319,6 +320,7 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
return err
}
activated = true
// 异步复机
if s.resumeCallback != nil {
@@ -334,6 +336,11 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
return nil
})
if err != nil {
return false, err
}
return activated, nil
}
// ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。