fix: 修正套餐激活和时间字段nullable问题
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:
2026-04-16 11:14:39 +08:00
parent 97d6319b64
commit 5065d925ad
28 changed files with 652 additions and 49 deletions

View File

@@ -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(&currentUsage, 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)
// 计算下次重置时间

View File

@@ -81,12 +81,12 @@ func (s *CustomerViewService) GetMyUsage(ctx context.Context, carrierType string
// 格式化时间
activatedAtStr := ""
if pkg.ActivatedAt.Year() > 1 {
if pkg.ActivatedAt != nil {
activatedAtStr = pkg.ActivatedAt.Format("2006-01-02 15:04:05")
}
expiresAtStr := ""
if pkg.ExpiresAt.Year() > 1 {
if pkg.ExpiresAt != nil {
expiresAtStr = pkg.ExpiresAt.Format("2006-01-02 15:04:05")
}

View File

@@ -138,9 +138,9 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
continue
}
activatedAt := usage.ActivatedAt
if activatedAt.IsZero() {
activatedAt = now
activatedAt := now
if usage.ActivatedAt != nil && !usage.ActivatedAt.IsZero() {
activatedAt = *usage.ActivatedAt
}
nextResetAt := CalculateNextResetTime(constants.PackageDataResetMonthly, pkg.CalendarType, now, activatedAt)
if nextResetAt == nil {