fix: 修正套餐激活和时间字段nullable问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
核心变更: 1. Model层时间字段改为*time.Time并设为nullable - PackageUsage.ActivatedAt/ExpiresAt - PersonalCustomerDevice/ICCID/Phone.LastUsedAt/VerifiedAt 2. 数据库迁移: - activated_at/expires_at列移除NOT NULL约束 - 清洗零值记录(status=0且activated_at<'2000-01-01') 3. 新增ActivateSpecificPackage方法:精准激活指定套餐, 修复HandlePackageQueueActivation从"查找过期包"改为直接激活payload指定套餐 4. 新增孤儿套餐恢复扫描:Worker启动或每次套餐检查时, 自动发现并恢复无status=1主套餐的孤儿载体 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -202,9 +202,13 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新过期主套餐状态失败")
|
||||
}
|
||||
|
||||
expiresAt := now
|
||||
if expiredMain.ExpiresAt != nil {
|
||||
expiresAt = *expiredMain.ExpiresAt
|
||||
}
|
||||
s.logger.Info("主套餐已过期",
|
||||
zap.Uint("usage_id", expiredMain.ID),
|
||||
zap.Time("expires_at", expiredMain.ExpiresAt))
|
||||
zap.Time("expires_at", expiresAt))
|
||||
|
||||
// 任务 9.7: 加油包级联失效
|
||||
if err := s.invalidateAddons(ctx, tx, expiredMain.ID); err != nil {
|
||||
@@ -221,6 +225,120 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
|
||||
})
|
||||
}
|
||||
|
||||
// ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录
|
||||
// 根据 PackageUsageID 精准激活目标套餐,而非重跑"查找过期包"流程
|
||||
func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, packageUsageID uint) 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 errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
|
||||
// 幂等检查:非待生效状态直接返回
|
||||
if usage.Status != constants.PackageUsageStatusPending {
|
||||
s.logger.Info("套餐无需激活(非待生效状态)",
|
||||
zap.Uint("usage_id", usage.ID),
|
||||
zap.Int("status", usage.Status))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 确定载体类型和 ID
|
||||
var carrierType string
|
||||
var carrierID uint
|
||||
if usage.IotCardID > 0 {
|
||||
carrierType = "iot_card"
|
||||
carrierID = usage.IotCardID
|
||||
} else if usage.DeviceID > 0 {
|
||||
carrierType = "device"
|
||||
carrierID = usage.DeviceID
|
||||
} else {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐使用记录缺少载体信息")
|
||||
}
|
||||
|
||||
// 获取分布式锁
|
||||
lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID)
|
||||
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, "获取分布式锁失败")
|
||||
}
|
||||
if !locked {
|
||||
s.logger.Warn("套餐激活正在进行中,跳过",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
return nil
|
||||
}
|
||||
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 s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 再次幂等检查(防止锁释放期间被其他操作激活)
|
||||
var currentUsage model.PackageUsage
|
||||
if err := tx.First(¤tUsage, packageUsageID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "重新查询套餐使用记录失败")
|
||||
}
|
||||
if currentUsage.Status != constants.PackageUsageStatusPending {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按 ExpiryBase 选择激活时间基准
|
||||
now := time.Now()
|
||||
var activatedAt time.Time
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
activatedAt = usage.CreatedAt
|
||||
} else {
|
||||
activatedAt = now
|
||||
}
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||
|
||||
// 更新套餐状态
|
||||
updates := map[string]interface{}{
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
"activated_at": activatedAt,
|
||||
"expires_at": expiresAt,
|
||||
}
|
||||
if nextResetAt != nil {
|
||||
updates["next_reset_at"] = *nextResetAt
|
||||
}
|
||||
if err := tx.Model(&usage).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
|
||||
}
|
||||
|
||||
// 同步载体状态
|
||||
s.syncCarrierStatusActivated(ctx, tx, &usage, carrierType, carrierID)
|
||||
|
||||
s.logger.Info("指定套餐已激活",
|
||||
zap.Uint("usage_id", usage.ID),
|
||||
zap.Uint("package_id", usage.PackageID),
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
|
||||
// 异步复机
|
||||
if s.resumeCallback != nil {
|
||||
go func(ct string, cid uint) {
|
||||
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
|
||||
s.logger.Error("激活后自动复机失败",
|
||||
zap.String("carrier_type", ct),
|
||||
zap.Uint("carrier_id", cid),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(carrierType, carrierID)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// invalidateAddons 任务 9.7: 加油包级联失效
|
||||
func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
|
||||
var addons []*model.PackageUsage
|
||||
@@ -280,7 +398,13 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
|
||||
}
|
||||
|
||||
// 激活套餐
|
||||
activatedAt := now
|
||||
// 按 ExpiryBase 选择激活计时基准:from_purchase-购买时起算用 CreatedAt,否则用当前时刻
|
||||
var activatedAt time.Time
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
activatedAt = nextMain.CreatedAt
|
||||
} else {
|
||||
activatedAt = now
|
||||
}
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
|
||||
// 计算下次重置时间
|
||||
|
||||
Reference in New Issue
Block a user