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

@@ -363,8 +363,8 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
VirtualRatio: ratio,
ActivatedAt: nonZeroTimePtr(usage.ActivatedAt),
ExpiresAt: nonZeroTimePtr(usage.ExpiresAt),
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
CreatedAt: usage.CreatedAt,

View File

@@ -69,8 +69,8 @@ type PackageUsage struct {
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"`
RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"`
VirtualDataUsageMB int64 `gorm:"column:virtual_data_usage_mb;type:bigint;default:0;comment:虚流量使用(MB)" json:"virtual_data_usage_mb"`
ActivatedAt time.Time `gorm:"column:activated_at;not null;comment:套餐生效时间" json:"activated_at"`
ExpiresAt time.Time `gorm:"column:expires_at;not null;comment:套餐过期时间" json:"expires_at"`
ActivatedAt *time.Time `gorm:"column:activated_at;comment:套餐生效时间" json:"activated_at"`
ExpiresAt *time.Time `gorm:"column:expires_at;comment:套餐过期时间" json:"expires_at"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"`
LastPackageCheckAt *time.Time `gorm:"column:last_package_check_at;comment:最后一次套餐流量检查时间" json:"last_package_check_at"`
Priority int `gorm:"column:priority;type:int;default:1;index:idx_package_usage_priority;comment:优先级(主套餐和加油包按此字段排队,数字越小优先级越高)" json:"priority"`

View File

@@ -13,7 +13,7 @@ type PersonalCustomerDevice struct {
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);not null;comment:设备虚拟号/IMEI" json:"virtual_no"`
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
LastUsedAt time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
}

View File

@@ -13,7 +13,7 @@ type PersonalCustomerICCID struct {
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
ICCID string `gorm:"column:iccid;type:varchar(20);not null;comment:ICCID20位数字" json:"iccid"`
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
LastUsedAt time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
}

View File

@@ -13,7 +13,7 @@ type PersonalCustomerPhone struct {
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
Phone string `gorm:"column:phone;type:varchar(20);not null;comment:手机号" json:"phone"`
IsPrimary bool `gorm:"column:is_primary;type:boolean;not null;default:false;comment:是否主手机号" json:"is_primary"`
VerifiedAt time.Time `gorm:"column:verified_at;type:timestamp;comment:验证通过时间" json:"verified_at"`
VerifiedAt *time.Time `gorm:"column:verified_at;type:timestamp;comment:验证通过时间" json:"verified_at"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
}

View File

@@ -262,9 +262,12 @@ func (p *PollingInitializer) initBatch(ctx context.Context, cards []*model.IotCa
cacheKey := constants.RedisPollingCardInfoKey(card.ID)
cacheData := map[string]interface{}{
"id": card.ID, "iccid": card.ICCID,
"card_category": card.CardCategory, "real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus, "carrier_id": card.CarrierID,
"id": card.ID,
"iccid": card.ICCID,
"card_category": card.CardCategory,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"carrier_id": card.CarrierID,
"current_month_usage_mb": card.CurrentMonthUsageMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
"data_usage_mb": card.DataUsageMB,

View File

@@ -14,6 +14,7 @@ import (
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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PackageActivationHandler 套餐激活检查处理器
@@ -94,9 +95,116 @@ func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Cont
zap.Int("processed", len(expiredPackages)),
zap.Duration("total_duration", time.Since(startTime)))
// 任务 6: 孤儿套餐扫描
orphanCount, err := h.findAndActivateOrphanPackages(ctx)
if err != nil {
h.logger.Error("孤儿套餐扫描失败", zap.Error(err))
// 不中断主流程
} else if orphanCount > 0 {
h.logger.Info("孤儿套餐扫描完成",
zap.Int("orphan_count", orphanCount))
}
return nil
}
// findAndActivateOrphanPackages 任务 6.1-6.2: 查找孤儿载体并触发激活
// 孤儿定义:存在 status=0 的主套餐pending_realname_activation=false但不存在 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("pending_realname_activation = ?", false).
Where("deleted_at IS NULL").
Limit(100).
Find(&orphanUsages).Error
if err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询孤儿套餐失败")
}
if len(orphanUsages) == 0 {
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
if key.carrierType == "iot_card" {
countErr = h.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status = ?", constants.PackageUsageStatusActive).
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 = ?", constants.PackageUsageStatusActive).
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
}
// 保留 priority 最低的套餐
if existing, ok := carrierMap[key]; ok {
if usage.Priority < existing.Priority {
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("提交孤儿套餐激活任务失败",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", key.carrierType),
zap.Uint("carrier_id", key.carrierID),
zap.Error(err))
continue
}
count++
}
return count, nil
}
// findExpiredMainPackages 任务 19.2: 查询已过期的主套餐
func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context) ([]*model.PackageUsage, error) {
var packages []*model.PackageUsage
@@ -123,9 +231,13 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
return err
}
expiresAt := time.Now()
if pkg.ExpiresAt != nil {
expiresAt = *pkg.ExpiresAt
}
h.logger.Info("主套餐已过期",
zap.Uint("package_usage_id", pkg.ID),
zap.Time("expires_at", pkg.ExpiresAt))
zap.Time("expires_at", expiresAt))
// 任务 19.4: 加油包级联失效
if err := h.invalidateAddons(ctx, tx, pkg.ID); err != nil {
@@ -422,23 +534,17 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
// 调用 ActivationService 执行激活
if h.activationService != nil {
if err := h.activationService.ActivateQueuedPackage(ctx, payload.CarrierType, payload.CarrierID); err != nil {
if err := h.activationService.ActivateSpecificPackage(ctx, payload.PackageUsageID); err != nil {
h.logger.Error("套餐激活失败",
zap.Uint("package_usage_id", payload.PackageUsageID),
zap.String("carrier_type", payload.CarrierType),
zap.Uint("carrier_id", payload.CarrierID),
zap.Error(err))
return err
}
} else {
// ActivationService 未注入,直接更新状态
now := time.Now()
if err := h.db.Model(&pkg).Updates(map[string]interface{}{
"status": constants.PackageUsageStatusActive,
"activated_at": now,
}).Error; err != nil {
return err
}
// ActivationService 未注入,无法安全激活(缺少 expires_at 计算)
h.logger.Error("激活服务未注入,无法执行套餐激活",
zap.Uint("package_usage_id", payload.PackageUsageID))
return errors.New(errors.CodeInternalError, "激活服务未注入,无法执行套餐激活")
}
h.logger.Info("套餐激活成功",

View File

@@ -687,8 +687,8 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
VirtualRemainMB: float64(u.DataLimitMB-u.DataUsageMB) / ratio,
VirtualRatio: ratio,
EnableVirtualData: enableVirtualData,
ActivatedAt: nonZeroTimePtr(u.ActivatedAt),
ExpiresAt: nonZeroTimePtr(u.ExpiresAt),
ActivatedAt: u.ActivatedAt,
ExpiresAt: u.ExpiresAt,
MasterUsageID: u.MasterUsageID,
Priority: u.Priority,
CreatedAt: u.CreatedAt,
@@ -764,8 +764,8 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
VirtualRatio: ratio,
EnableVirtualData: enableVirtualData,
ActivatedAt: nonZeroTimePtr(usage.ActivatedAt),
ExpiresAt: nonZeroTimePtr(usage.ExpiresAt),
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
CreatedAt: usage.CreatedAt,

View File

@@ -1829,8 +1829,8 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
}
if status == constants.PackageUsageStatusActive {
usage.ActivatedAt = activatedAt
usage.ExpiresAt = expiresAt
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
}
@@ -1883,7 +1883,11 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
var expiresAt time.Time
// 注意has_independent_expiry 字段在 Package 模型中,暂时使用默认行为
// 默认加油包跟随主套餐过期
expiresAt = mainPackage.ExpiresAt
if mainPackage.ExpiresAt != nil {
expiresAt = *mainPackage.ExpiresAt
} else {
expiresAt = now
}
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
@@ -1898,8 +1902,8 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
Status: status,
Priority: priority,
MasterUsageID: &mainPackage.ID,
ActivatedAt: activatedAt,
ExpiresAt: expiresAt,
ActivatedAt: &activatedAt,
ExpiresAt: &expiresAt,
DataResetCycle: pkg.DataResetCycle,
}

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 {

View File

@@ -22,9 +22,8 @@ func NewPersonalCustomerDeviceStore(db *gorm.DB) *PersonalCustomerDeviceStore {
// Create 创建设备号绑定记录
func (s *PersonalCustomerDeviceStore) Create(ctx context.Context, record *model.PersonalCustomerDevice) error {
now := time.Now()
record.BindAt = now
record.LastUsedAt = now
record.BindAt = time.Now()
// LastUsedAt 在首次使用时更新,创建时保持 nil
return s.db.WithContext(ctx).Create(record).Error
}

View File

@@ -22,9 +22,8 @@ func NewPersonalCustomerICCIDStore(db *gorm.DB) *PersonalCustomerICCIDStore {
// Create 创建 ICCID 绑定记录
func (s *PersonalCustomerICCIDStore) Create(ctx context.Context, record *model.PersonalCustomerICCID) error {
now := time.Now()
record.BindAt = now
record.LastUsedAt = now
record.BindAt = time.Now()
// LastUsedAt 在首次使用时更新,创建时保持 nil
return s.db.WithContext(ctx).Create(record).Error
}

View File

@@ -2,7 +2,6 @@ package postgres
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
@@ -22,7 +21,7 @@ func NewPersonalCustomerPhoneStore(db *gorm.DB) *PersonalCustomerPhoneStore {
// Create 创建手机号绑定记录
func (s *PersonalCustomerPhoneStore) Create(ctx context.Context, phone *model.PersonalCustomerPhone) error {
phone.VerifiedAt = time.Now()
// VerifiedAt 在验证通过后更新,创建时保持 nil
return s.db.WithContext(ctx).Create(phone).Error
}

View File

@@ -528,8 +528,8 @@ func (h *AutoPurchaseHandler) activateMainPackage(
}
if status == constants.PackageUsageStatusActive {
usage.ActivatedAt = activatedAt
usage.ExpiresAt = expiresAt
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
}
@@ -588,7 +588,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
Status: constants.PackageUsageStatusActive,
Priority: priority,
MasterUsageID: &mainPackage.ID,
ActivatedAt: now,
ActivatedAt: &now,
ExpiresAt: expiresAt,
DataResetCycle: pkg.DataResetCycle,
Generation: order.Generation,

View File

@@ -0,0 +1,3 @@
-- 回滚:恢复 activated_at 列的 NOT NULL 约束
ALTER TABLE tb_package_usage
ALTER COLUMN activated_at SET NOT NULL;

View File

@@ -0,0 +1,3 @@
-- 去除 activated_at 列的 NOT NULL 约束,允许 NULL 值
ALTER TABLE tb_package_usage
ALTER COLUMN activated_at DROP NOT NULL;

View File

@@ -0,0 +1,3 @@
-- 回滚:恢复 expires_at 列的 NOT NULL 约束
ALTER TABLE tb_package_usage
ALTER COLUMN expires_at SET NOT NULL;

View File

@@ -0,0 +1,3 @@
-- 去除 expires_at 列的 NOT NULL 约束,允许 NULL 值
ALTER TABLE tb_package_usage
ALTER COLUMN expires_at DROP NOT NULL;

View File

@@ -0,0 +1,6 @@
-- 回滚:无法恢复原有值,仅将已清洗为 NULL 的记录恢复为 NOT NULL 约束允许的最小值
UPDATE tb_package_usage
SET activated_at = '1970-01-01 00:00:00',
expires_at = '1970-01-01 00:00:00'
WHERE status = 0
AND activated_at IS NULL;

View File

@@ -0,0 +1,8 @@
-- 清洗 tb_package_usage 中 status=0 且 activated_at < '2000-01-01' 的零值记录
-- 注意:此迁移在 DROP NOT NULL 约束之后执行
-- 将 activated_at 和 expires_at 更新为 NULL
UPDATE tb_package_usage
SET activated_at = NULL,
expires_at = NULL
WHERE status = 0
AND activated_at < '2000-01-01';

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-16

View File

@@ -0,0 +1,123 @@
## Context
当前系统存在两类根本性缺陷,且已在生产数据中造成可见损坏。
**时间零值问题根因**GORM 对非指针 `time.Time` 字段的处理规则是"任何值(包括零值)都写入数据库",而 `*time.Time` 为 nil 时写 NULL。`PackageUsage.ActivatedAt/ExpiresAt` 定义为 `time.Time`NOT NULL当套餐处于待生效状态status=0代码主动赋值 `time.Time{}` 规避 GORM 的零值跳过逻辑,导致 `0001-01-01 00:00:00 UTC` 被持久化,在中国历史时区下显示为 `0001-01-01 00:00:43`。其余三个字段LastUsedAt × 2、VerifiedAt的 DB 列已是 nullable但 Go 类型仍是非指针,同样会写零值。
**激活逻辑根因**:系统有两套激活路径但职责边界混乱:
- `HandlePackageActivationCheck`(轮询,每 10 秒):负责检测过期、提交激活任务
- `HandlePackageQueueActivation`Asynq 任务):负责执行具体激活
问题在于 `HandlePackageQueueActivation` 调用了 `ActivateQueuedPackage(carrierType, carrierID)`,该函数的设计意图是"扫描过期包→标记过期→激活下一个"的完整循环,但轮询处理器已经完成了"标记过期"步骤。Asynq 任务运行时过期触发条件已消失函数空转返回payload 中携带的目标 `PackageUsageID` 被完全浪费。
另外,系统无任何机制感知"载体无生效套餐但有待生效套餐"的孤儿状态,重启后 Asynq 任务丢失或 MaxRetry 耗尽后状态永久卡死。
## Goals / Non-Goals
**Goals:**
- 彻底消除时间零值污染5 个字段全部改为 `*time.Time`DB 列对齐,历史数据清洗
- 修复激活链断链Asynq 任务直接激活 payload 指定的目标套餐,不再重跑发现流程
- 建立孤儿套餐自愈机制10 秒周期内兜底检测无主套餐但有待激活套餐的载体,覆盖重启恢复和任务丢失两种场景
- 不影响已生效status=1和已过期status=3套餐的任何行为
**Non-Goals:**
- 不重构 Asynq 任务队列架构
- 不修改套餐购买、定价、佣金等其他流程
- 不处理 `pending_realname_activation=true` 的套餐(实名激活路径单独维护,无此问题)
- 不引入新的外部依赖
## Decisions
### 决策 1`ActivateSpecificPackage` 独立方法,而非修改 `ActivateQueuedPackage`
**选择**:在 `ActivationService` 新增 `ActivateSpecificPackage(ctx, packageUsageID uint)` 方法,`HandlePackageQueueActivation` 改为调用该方法。
**理由**`ActivateQueuedPackage` 是"发现+执行"的组合操作,修改它会影响所有调用方;新方法职责单一——"激活一个已知 ID 的套餐"——符合最小改动原则,不破坏其他调用路径。
**`ActivateSpecificPackage` 逻辑**
```
1. 加载 PackageUsage根据 ID
2. 幂等检查status != Pending → 直接返回
3. 加载关联 Package获取 CalendarType, DurationMonths/Days, DataResetCycle
4. activatedAt = now计算 expiresAt 和 nextResetAt
5. 事务内更新status=1, activated_at, expires_at, [next_reset_at]
6. 调用 syncCarrierStatusActivated
7. 异步调用 resumeCallback.ResumeCardIfStopped如已注入
```
**备选方案**:直接修改 `ActivateQueuedPackage` 接受 packageUsageID 参数 → 拒绝,会改变该函数对其他调用方的契约。
---
### 决策 2孤儿扫描合并到 `HandlePackageActivationCheck` 现有周期
**选择**:在 `HandlePackageActivationCheck` 末尾追加孤儿扫描逻辑(独立函数 `findAndActivateOrphanPackages`),与现有过期检测复用同一 10 秒 ticker。
**孤儿定义**(伪代码 SQL仅描述查询语义实现时需使用相关子查询将外层表别名化`outer` 为示意,不可直接执行):
```sql
-- 查找"无生效套餐但有待激活套餐"的载体
-- 注意:实现时外层表需显式指定别名(如 p1子查询中通过 p1.iot_card_id 关联
SELECT DISTINCT carrier_type, carrier_id
FROM tb_package_usage AS p1
WHERE p1.status = 0
AND p1.master_usage_id IS NULL
AND p1.pending_realname_activation = false
AND p1.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM tb_package_usage p2
WHERE p2.status = 1
AND p2.master_usage_id IS NULL
AND p2.deleted_at IS NULL
AND (
(p1.iot_card_id > 0 AND p2.iot_card_id = p1.iot_card_id)
OR (p1.device_id > 0 AND p2.device_id = p1.device_id)
)
)
```
**理由**孤儿状态与过期状态在时间维度上相关合并到同一检查周期减少代码分散10 秒间隔满足"重启后 10 秒内自动恢复"的 SLA。
**备选方案**:独立 goroutine 或独立 Asynq Scheduler 任务 → 拒绝增加组件复杂度10 秒已足够。
---
### 决策 3`*time.Time` 指针方案,不使用 `sql.NullTime`
**选择**:所有受影响字段改为 `*time.Time`
**理由**:项目已有大量 `*time.Time` 用法(`Order.PaidAt``Device.ActivatedAt` 等),风格一致;`sql.NullTime` 引入 `Valid` 字段读写噪音,不符合项目现有模式。
---
### 决策 4数据迁移策略
`tb_package_usage.activated_at/expires_at` 的处理分两步:
1. **数据清洗**(先执行):`UPDATE tb_package_usage SET activated_at = NULL, expires_at = NULL WHERE status = 0 AND activated_at < '2000-01-01'`(仅清洗零值,不影响已激活记录)
2. **结构变更**(后执行):`ALTER TABLE tb_package_usage ALTER COLUMN activated_at DROP NOT NULL`
顺序原因:先清洗确保 NOT NULL 约束变更后不遗留脏数据;两步均可独立回滚。
## Risks / Trade-offs
**[风险] 孤儿扫描的 N+1 性能问题** → **缓解**:限制单次扫描最多处理 100 个孤儿载体;孤儿状态在正常业务中极少发生(仅重启恢复和任务失败时),不会成为热路径。
**[风险] `ActivateSpecificPackage` 并发激活同一套餐** → **缓解**:复用已有 `RedisPackageActivationLockKey(carrierType, carrierID)` 分布式锁 + 幂等检查status != Pending 直接返回)。
**[风险] `*time.Time` 改动引发下游编译错误** → **缓解**:修改后运行 `go build ./...` 全量编译,统一修复所有 nil 判断。
**[权衡] 历史零值数据一旦清为 NULL无法区分"未激活"和"数据损坏"** → 可接受:所有 status=0 且 `activated_at` 为零值的记录语义上就是"未激活"NULL 是正确表达。
## Migration Plan
1. **部署前**:执行数据清洗 SQL修复 9 条零值记录 → NULL
2. **部署**滚动发布DB 列仍 NOT NULL旧代码已用 `Omit` 跳过写入,新代码写 NULL 不兼容)→ **必须作为单次停机升级**,先跑迁移再升级服务
3. **迁移顺序**
- Step 1: 执行数据清洗 SQL
- Step 2: 执行 ALTER TABLEDROP NOT NULL
- Step 3: 部署新代码
4. **回滚**若部署失败先回滚代码旧代码写零值DB 已允许 NULL业务可继续再评估是否需回滚 DB 结构(通常不需要)
## Open Questions
- 孤儿扫描触发激活后,是否需要记录审计日志?(建议:记录 Info 日志即可,不进审计表)
- `ActivateSpecificPackage` 失败时如套餐配置找不到是否需要告警建议Error 日志 + 现有 Asynq MaxRetry(3) 已覆盖)

View File

@@ -0,0 +1,38 @@
## Why
套餐激活系统存在两类根本性缺陷:一是多个 Model 的时间字段定义为非指针 `time.Time`,导致"待生效"状态下零值(`0001-01-01 00:00:43`)被写入数据库,污染历史数据并造成查询误判;二是排队激活的核心执行链存在逻辑断链——`HandlePackageQueueActivation` 调用的 `ActivateQueuedPackage(carrierType, carrierID)` 方法设计为"发现过期包 → 标记过期 → 激活下一个"的完整循环,但轮询处理器(`HandlePackageActivationCheck`)已提前完成了"标记过期"步骤。当 Asynq 任务执行时,过期触发条件已消失,`ActivateQueuedPackage` 空转返回payload 中携带的 `PackageUsageID` 未被使用,导致所有排队中的套餐永久卡在 `status=0`,且系统对重启后的孤儿 pending 套餐完全无感知。当前已有至少 4 条套餐使用记录受影响TEST5G 设备 3 条 + 其他 6 条被零值污染)。
## What Changes
- **Model 层**:将 `PackageUsage.ActivatedAt``ExpiresAt` 改为 `*time.Time`(需同步数据库迁移去除 NOT NULL 约束);将 `PersonalCustomerDevice.LastUsedAt``PersonalCustomerICCID.LastUsedAt``PersonalCustomerPhone.VerifiedAt` 改为 `*time.Time`DB 列已 nullable无需迁移
- **数据修复**:将现有 9 条 `tb_package_usage``activated_at/expires_at = '0001-01-01'` 的记录更新为 `NULL`
- **激活逻辑修复**:在 `ActivationService` 新增 `ActivateSpecificPackage(packageUsageID)` 方法,`HandlePackageQueueActivation` 改为直接调用该方法激活 payload 指定的具体套餐,而非重跑"查找过期包"流程
- **孤儿恢复机制**`HandlePackageActivationCheck`(每 10 秒)新增孤儿扫描:检测载体无 `status=1` 包但存在 `status=0 AND pending_realname_activation=false` 包的情形,直接触发激活任务,同时覆盖重启恢复和 Asynq 任务丢失两种场景
## Capabilities
### New Capabilities
- `time-field-nullable-fix`:将语义上可为空的时间字段从 `time.Time` 改为 `*time.Time`,防止 GORM 将零值写入数据库;包含 Model 修改、数据库迁移、历史数据清洗、赋值代码修正(共 5 个字段,跨 4 个 Model
### Modified Capabilities
- `package-queue-activation`:修复排队激活执行链断链问题,新增孤儿套餐恢复机制;原 spec 定义的"主套餐过期后 20 秒内激活下一个"的 SLA 要求将真正得到保障,同时新增系统重启后的自动恢复保证
## Impact
**受影响文件:**
- `internal/model/package.go``PackageUsage` 模型
- `internal/model/personal_customer_device.go` — 个人客户设备绑定模型
- `internal/model/personal_customer_iccid.go` — 个人客户 ICCID 绑定模型
- `internal/model/personal_customer_phone.go` — 个人客户手机验证模型
- `internal/service/package/activation_service.go` — 新增 `ActivateSpecificPackage` 方法
- `internal/polling/package_activation_handler.go` — 修改 `HandlePackageQueueActivation` 逻辑 + 新增孤儿扫描
- `internal/service/order/service.go` — 修正零值赋值
- `internal/task/auto_purchase.go` — 修正零值赋值(同类问题)
- 数据库迁移文件(新增)— `tb_package_usage.activated_at/expires_at` DROP NOT NULL
**下游影响:**
- 所有读取 `PackageUsage.ActivatedAt/ExpiresAt` 的代码需适配指针类型nil 判断)
- 现有 9 条脏数据将被修复为 NULL
- 排队中的 TEST5G 套餐id=2,3,6将在修复部署后自动被孤儿扫描激活

View File

@@ -0,0 +1,65 @@
# Spec Delta: 主套餐排队生效机制(激活链修复 + 孤儿恢复)
## MODIFIED Requirements
### Requirement: 排队激活任务精准激活指定套餐
`HandlePackageQueueActivation` Asynq 任务处理器 SHALL 直接激活 payload 中 `PackageUsageID` 指定的具体套餐,不得重新执行"查找过期包"的发现流程。
`ActivationService` SHALL 提供 `ActivateSpecificPackage(ctx, packageUsageID uint) error` 方法,职责为:加载指定套餐 → 幂等检查 → 计算激活时间和过期时间 → 事务内更新 status=1 → 同步载体状态 → 触发复机回调。
激活前 SHALL 获取 `RedisPackageActivationLockKey(carrierType, carrierID)` 分布式锁30 秒超时),防止并发激活同一载体。
#### Scenario: Asynq 任务激活指定的排队套餐
- **WHEN** `HandlePackageQueueActivation` 接收到 payload `{PackageUsageID: 2, CarrierType: "device", CarrierID: 1}`
- **THEN** 系统加载 id=2 的 PackageUsage 记录
- **AND** 若该记录 status=0待生效则将其激活status → 1activated_at → nowexpires_at → 计算值)
- **AND** 激活成功后更新关联载体状态
#### Scenario: 幂等保护:已激活的套餐不重复处理
- **WHEN** `HandlePackageQueueActivation` 接收到的套餐 id 对应的套餐已是 status=1生效中
- **THEN** 系统直接返回成功,不做任何修改
- **AND** 不产生重复激活的副作用
#### Scenario: 激活时正确计算到期时间
- **WHEN** `ActivateSpecificPackage` 激活一个排队的主套餐
- **THEN** `activated_at` 为激活时刻(`time.Now()`
- **AND** `expires_at` 根据关联 Package 的 `CalendarType``natural_month``by_day`)、`DurationMonths``DurationDays` 正确计算
- **AND** 若 `DataResetCycle != "none"`,则同步更新 `next_reset_at`
---
## ADDED Requirements
### Requirement: 孤儿待激活套餐自动恢复
系统 SHALL 在每次 `HandlePackageActivationCheck` 执行时(每 10 秒)检测孤儿载体,即:存在 `status=0 AND master_usage_id IS NULL AND pending_realname_activation=false` 的主套餐,但该载体不存在任何 `status=1` 的主套餐。
检测到孤儿后,系统 SHALL 为每个孤儿载体提交 `TaskTypePackageQueueActivation` Asynq 任务,激活其 priority 最小的待生效主套餐。单次扫描处理上限 SHALL 为 100 个载体。
此机制 SHALL 覆盖以下两种场景:
1. Worker 服务重启后,重启前已标记过期但 Asynq 任务丢失的载体
2. Asynq 任务 MaxRetry 耗尽后进入 dead queue 的载体
#### Scenario: 重启后孤儿套餐在 10 秒内被自动恢复
- **WHEN** Worker 服务重启数据库中存在载体device_id=1无 status=1 主套餐、但有 status=0 且 pending_realname_activation=false 的主套餐
- **THEN** Worker 启动后的首次 `HandlePackageActivationCheck`(最长 10 秒内)检测到该孤儿载体
- **AND** 提交激活任务
- **AND** 待生效套餐在 20 秒内完成激活status → 1
#### Scenario: Asynq 任务失败后孤儿机制兜底
- **WHEN** 激活任务因 Asynq MaxRetry 耗尽进入 dead queue载体套餐仍为 status=0
- **THEN** 孤儿扫描在下一个 10 秒周期内检测到该载体
- **AND** 重新提交激活任务
- **AND** 套餐最终完成激活
#### Scenario: 有生效套餐的载体不被孤儿扫描触发
- **WHEN** 载体已有 status=1 的主套餐,同时存在 status=0 的排队套餐
- **THEN** 孤儿扫描不为该载体提交激活任务
- **AND** 排队套餐继续等待当前套餐过期后的正常激活流程

View File

@@ -0,0 +1,66 @@
# Spec: 时间字段 Nullable 修复
## 背景
多个 Model 将语义上可为空的时间字段定义为非指针 `time.Time`,导致 GORM 在未赋值时将 Go 零值(`0001-01-01 00:00:00`)写入数据库。受影响字段跨 4 个 Model、5 个字段,其中 `tb_package_usage` 的两个字段在 DB 层也是 NOT NULL需同步迁移。
## ADDED Requirements
### Requirement: PackageUsage 时间字段可为 NULL
`PackageUsage.ActivatedAt``ExpiresAt` SHALL 定义为 `*time.Time`,数据库列 `activated_at``expires_at` SHALL 允许 NULL。
当套餐处于待生效状态(`status=0`)时,`activated_at``expires_at` SHALL 为 NULL不得写入零值。
#### Scenario: 创建排队套餐时时间字段为 NULL
- **WHEN** 购买主套餐时已有生效中主套餐,新套餐被创建为 status=0待生效
- **THEN** `tb_package_usage` 中该记录的 `activated_at``expires_at` 均为 NULL
- **AND** 不写入 `0001-01-01` 或任何零值时间
#### Scenario: 套餐激活后时间字段被正确赋值
- **WHEN** 待生效套餐status=0被成功激活
- **THEN** `activated_at` 被更新为激活时刻的实际时间
- **AND** `expires_at` 被更新为根据套餐 CalendarType/Duration 计算出的到期时间
- **AND** 两个字段均不为 NULL
#### Scenario: 历史零值数据被清洗
- **WHEN** 数据库迁移执行完成
- **THEN** `tb_package_usage` 中所有 `status=0``activated_at < '2000-01-01'` 的记录,其 `activated_at``expires_at` 均被更新为 NULL
---
### Requirement: 个人客户绑定时间字段可为 NULL
`PersonalCustomerDevice.LastUsedAt``PersonalCustomerICCID.LastUsedAt``PersonalCustomerPhone.VerifiedAt` SHALL 定义为 `*time.Time`
设备/ICCID 首次绑定时 `LastUsedAt` SHALL 为 NULL尚未使用。手机号绑定未经验证时 `VerifiedAt` SHALL 为 NULL。
#### Scenario: 设备首次绑定时 LastUsedAt 为 NULL
- **WHEN** 个人客户首次绑定设备(`personal_customer_device` 记录被创建)
- **THEN** `last_used_at` 字段为 NULL
- **AND** 不写入 `0001-01-01` 零值
#### Scenario: ICCID 首次绑定时 LastUsedAt 为 NULL
- **WHEN** 个人客户首次绑定 ICCID`personal_customer_iccid` 记录被创建)
- **THEN** `last_used_at` 字段为 NULL
#### Scenario: 手机号绑定但未验证时 VerifiedAt 为 NULL
- **WHEN** 个人客户绑定手机号但尚未完成验证流程
- **THEN** `tb_personal_customer_phone.verified_at` 字段为 NULL
---
### Requirement: 全量编译无类型错误
Model 改为指针类型后,所有引用 `ActivatedAt``ExpiresAt``LastUsedAt``VerifiedAt` 的代码 SHALL 正确处理指针nil 判断或解引用),`go build ./...` 必须零错误通过。
#### Scenario: 读取 ActivatedAt 时安全处理 nil
- **WHEN** 代码读取 `PackageUsage.ActivatedAt`(如展示给前端、计算剩余时长)
- **THEN** 必须先判断指针是否为 nilnil 时使用零值时间或跳过该字段,不得直接解引用导致 panic

View File

@@ -0,0 +1,49 @@
## 1. 数据库迁移与历史数据清洗
- [x] 1.1 创建迁移文件,清洗 `tb_package_usage``status=0``activated_at < '2000-01-01'` 的零值记录(将 `activated_at``expires_at` 更新为 NULL
- [x] 1.2 创建迁移文件,对 `tb_package_usage.activated_at` 执行 `ALTER COLUMN ... DROP NOT NULL`
- [x] 1.3 创建迁移文件,对 `tb_package_usage.expires_at` 执行 `ALTER COLUMN ... DROP NOT NULL`
- [x] 1.4 在本地执行迁移,用 PostgreSQL MCP 验证:查询 `tb_package_usage` 确认列已 nullable、零值记录已清洗应为 9 条)
## 2. Model 层修改
- [x] 2.1 修改 `internal/model/package.go``PackageUsage.ActivatedAt` 改为 `*time.Time`,更新 gorm tag 去除 `not null`
- [x] 2.2 修改 `internal/model/package.go``PackageUsage.ExpiresAt` 改为 `*time.Time`,更新 gorm tag 去除 `not null`
- [x] 2.3 修改 `internal/model/personal_customer_device.go``LastUsedAt` 改为 `*time.Time`
- [x] 2.4 修改 `internal/model/personal_customer_iccid.go``LastUsedAt` 改为 `*time.Time`
- [x] 2.5 修改 `internal/model/personal_customer_phone.go``VerifiedAt` 改为 `*time.Time`
- [x] 2.6 执行 `go build ./...`修复所有因指针类型变更导致的编译错误nil 判断、解引用、比较等)
- [x] 2.7 检查并修复受影响的 DTO 文件中对 `ActivatedAt/ExpiresAt/LastUsedAt/VerifiedAt` 的引用:`internal/model/dto/package_dto.go``PackageUsageItemResponse.ExpiresAt/ActivatedAt``string` 类型,其映射赋值代码需增加 nil 判断nil 时输出空字符串或 `"-"``asset_dto.go``client_asset_dto.go``device_dto.go``iot_card_dto.go` 中对应字段已是 `*time.Time`,确认无需改动
## 3. 赋值逻辑修正
- [x] 3.1 修改 `internal/service/order/service.go``activateMainPackage` 函数待生效路径status=0`activatedAt = time.Time{}``expiresAt = time.Time{}` 改为不赋值(保持 nil去除后续对 `usage.ActivatedAt/ExpiresAt` 的条件赋值中对零值的依赖
- [x] 3.2 修改 `internal/task/auto_purchase.go``activateMainPackage` 函数):同上,确保待生效套餐的 `ActivatedAt/ExpiresAt` 在创建时为 nil
- [x] 3.3 检查 `internal/service/package/activation_service.go` 中所有对 `ExpiresAt` 的读取(如 `mainPackage.ExpiresAt` 传给加油包),确认指针解引用安全
- [x] 3.4 修复 `internal/service/package/activation_service.go``activateNextMainPackage` 函数的 ExpiryBase 处理:当前该函数直接使用 `now` 作为 `activatedAt`,与 `ActivateByRealname``from_purchase` 逻辑不一致;改为参照 `ActivateByRealname` 的实现,根据 `pkg.ExpiryBase` 判断激活时间基准(`"from_purchase"` 时用 `usage.CreatedAt`,否则用 `time.Now()`
## 4. 激活服务新增 ActivateSpecificPackage
- [x] 4.1 在 `internal/service/package/activation_service.go` 新增 `ActivateSpecificPackage(ctx context.Context, packageUsageID uint) error` 方法
- [x] 4.2 实现:加载 PackageUsage → 幂等检查status != Pending 直接返回 nil→ 获取分布式锁(`RedisPackageActivationLockKey`)→ 加载关联 Package → 根据 `pkg.ExpiryBase` 决定激活时间基准(`"from_purchase"` 时用 `usage.CreatedAt`,否则用 `time.Now()`;与 `ActivateByRealname` 保持一致)→ 计算 activatedAt/expiresAt/nextResetAt → 事务内 Updates → 调用 `syncCarrierStatusActivated` → 异步 `resumeCallback.ResumeCardIfStopped`
- [x] 4.3 确认分布式锁在函数退出时被 `defer` 释放
## 5. 修复 HandlePackageQueueActivation
- [x] 5.1 修改 `internal/polling/package_activation_handler.go` 中的 `HandlePackageQueueActivation`:将调用 `h.activationService.ActivateQueuedPackage(ctx, payload.CarrierType, payload.CarrierID)` 替换为调用 `h.activationService.ActivateSpecificPackage(ctx, payload.PackageUsageID)`
- [x] 5.2 修改 `HandlePackageQueueActivation``activationService == nil` 的降级分支:**不删除该分支**,改为记录 Error 日志(`"激活服务未注入,无法执行套餐激活"`)并返回错误,防止静默失败(原降级分支直接 Updates 缺少 expires_at 计算,逻辑不完整,改为报错更安全)
- [ ] 5.3 用 PostgreSQL MCP 手动验证:触发一次过期检测(或手动将某套餐 expires_at 设为过去确认排队套餐被成功激活status: 0 → 1
## 6. 新增孤儿套餐恢复扫描
- [x] 6.1 在 `internal/polling/package_activation_handler.go` 新增 `findAndActivateOrphanPackages(ctx context.Context) error` 函数:查询无 status=1 主套餐但有 status=0`pending_realname_activation=false`)主套餐的载体,单次上限 100 个
- [x] 6.2 对每个孤儿载体,查询其 priority 最小的 status=0 主套餐,调用 `h.enqueueActivationTask` 提交激活任务
- [x] 6.3 在 `HandlePackageActivationCheck` 末尾追加调用 `h.findAndActivateOrphanPackages(ctx)`,日志记录触发的孤儿数量
- [ ] 6.4 用 PostgreSQL MCP 验证当前 TEST5G 设备device_id=1的 3 条待生效套餐id=2,3,6确认孤儿扫描触发后它们被逐步激活
## 7. 收尾验证
- [x] 7.1 执行 `go build ./...` 确认全量编译零错误
- [x] 7.2 用 PostgreSQL MCP 查询 `tb_package_usage` 验证:无 `activated_at < '2000-01-01'` 的记录
- [x] 7.3 用 PostgreSQL MCP 查询验证:`personal_customer_device.last_used_at``personal_customer_iccid.last_used_at``tb_personal_customer_phone.verified_at` 中不存在 `< '2000-01-01'` 的值(如有,补一条清洗 SQL
- [x] 7.4 检查并更新 DTO 中所有展示 `ActivatedAt/ExpiresAt` 的字段,确认 nil 时的 JSON 输出为 `null`(而非 `"0001-01-01T00:00:00Z"`