diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 232c577..3f61fe2 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -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, diff --git a/internal/model/package.go b/internal/model/package.go index 4ef6dbd..c4f1483 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -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"` diff --git a/internal/model/personal_customer_device.go b/internal/model/personal_customer_device.go index 53b7a1f..c184483 100644 --- a/internal/model/personal_customer_device.go +++ b/internal/model/personal_customer_device.go @@ -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"` } diff --git a/internal/model/personal_customer_iccid.go b/internal/model/personal_customer_iccid.go index 341592f..5627cb5 100644 --- a/internal/model/personal_customer_iccid.go +++ b/internal/model/personal_customer_iccid.go @@ -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:ICCID(20位数字)" 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"` } diff --git a/internal/model/personal_customer_phone.go b/internal/model/personal_customer_phone.go index f85eac0..349345b 100644 --- a/internal/model/personal_customer_phone.go +++ b/internal/model/personal_customer_phone.go @@ -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"` } diff --git a/internal/polling/initializer.go b/internal/polling/initializer.go index 5507046..061f6ed 100644 --- a/internal/polling/initializer.go +++ b/internal/polling/initializer.go @@ -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, diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index dba0892..2d72b8d 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -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("套餐激活成功", diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 69e17e3..da24bee 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -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, diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 60f02d1..f6456d2 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -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, } diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index 1900e0a..2050de6 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -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) // 计算下次重置时间 diff --git a/internal/service/package/customer_view_service.go b/internal/service/package/customer_view_service.go index 09db83e..1d6527a 100644 --- a/internal/service/package/customer_view_service.go +++ b/internal/service/package/customer_view_service.go @@ -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") } diff --git a/internal/service/package/reset_service.go b/internal/service/package/reset_service.go index 3f93111..93b9a88 100644 --- a/internal/service/package/reset_service.go +++ b/internal/service/package/reset_service.go @@ -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 { diff --git a/internal/store/postgres/personal_customer_device_store.go b/internal/store/postgres/personal_customer_device_store.go index a58d46f..6ead19e 100644 --- a/internal/store/postgres/personal_customer_device_store.go +++ b/internal/store/postgres/personal_customer_device_store.go @@ -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 } diff --git a/internal/store/postgres/personal_customer_iccid_store.go b/internal/store/postgres/personal_customer_iccid_store.go index 3781196..cf223c7 100644 --- a/internal/store/postgres/personal_customer_iccid_store.go +++ b/internal/store/postgres/personal_customer_iccid_store.go @@ -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 } diff --git a/internal/store/postgres/personal_customer_phone_store.go b/internal/store/postgres/personal_customer_phone_store.go index 7c42f6d..2ee65b6 100644 --- a/internal/store/postgres/personal_customer_phone_store.go +++ b/internal/store/postgres/personal_customer_phone_store.go @@ -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 } diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index b86f2a3..d10e393 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -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, diff --git a/migrations/000122_drop_not_null_activated_at.down.sql b/migrations/000122_drop_not_null_activated_at.down.sql new file mode 100644 index 0000000..fc3f557 --- /dev/null +++ b/migrations/000122_drop_not_null_activated_at.down.sql @@ -0,0 +1,3 @@ +-- 回滚:恢复 activated_at 列的 NOT NULL 约束 +ALTER TABLE tb_package_usage +ALTER COLUMN activated_at SET NOT NULL; diff --git a/migrations/000122_drop_not_null_activated_at.up.sql b/migrations/000122_drop_not_null_activated_at.up.sql new file mode 100644 index 0000000..bba0d22 --- /dev/null +++ b/migrations/000122_drop_not_null_activated_at.up.sql @@ -0,0 +1,3 @@ +-- 去除 activated_at 列的 NOT NULL 约束,允许 NULL 值 +ALTER TABLE tb_package_usage +ALTER COLUMN activated_at DROP NOT NULL; diff --git a/migrations/000123_drop_not_null_expires_at.down.sql b/migrations/000123_drop_not_null_expires_at.down.sql new file mode 100644 index 0000000..ea5feda --- /dev/null +++ b/migrations/000123_drop_not_null_expires_at.down.sql @@ -0,0 +1,3 @@ +-- 回滚:恢复 expires_at 列的 NOT NULL 约束 +ALTER TABLE tb_package_usage +ALTER COLUMN expires_at SET NOT NULL; diff --git a/migrations/000123_drop_not_null_expires_at.up.sql b/migrations/000123_drop_not_null_expires_at.up.sql new file mode 100644 index 0000000..8c49603 --- /dev/null +++ b/migrations/000123_drop_not_null_expires_at.up.sql @@ -0,0 +1,3 @@ +-- 去除 expires_at 列的 NOT NULL 约束,允许 NULL 值 +ALTER TABLE tb_package_usage +ALTER COLUMN expires_at DROP NOT NULL; diff --git a/migrations/000124_clean_zero_time_package_usage.down.sql b/migrations/000124_clean_zero_time_package_usage.down.sql new file mode 100644 index 0000000..daef309 --- /dev/null +++ b/migrations/000124_clean_zero_time_package_usage.down.sql @@ -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; diff --git a/migrations/000124_clean_zero_time_package_usage.up.sql b/migrations/000124_clean_zero_time_package_usage.up.sql new file mode 100644 index 0000000..4d0d9d5 --- /dev/null +++ b/migrations/000124_clean_zero_time_package_usage.up.sql @@ -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'; diff --git a/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/.openspec.yaml b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/.openspec.yaml new file mode 100644 index 0000000..3a54a17 --- /dev/null +++ b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-16 diff --git a/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/design.md b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/design.md new file mode 100644 index 0000000..4527c4b --- /dev/null +++ b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/design.md @@ -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 TABLE(DROP NOT NULL) + - Step 3: 部署新代码 +4. **回滚**:若部署失败,先回滚代码(旧代码写零值,DB 已允许 NULL,业务可继续),再评估是否需回滚 DB 结构(通常不需要) + +## Open Questions + +- 孤儿扫描触发激活后,是否需要记录审计日志?(建议:记录 Info 日志即可,不进审计表) +- `ActivateSpecificPackage` 失败时(如套餐配置找不到),是否需要告警?(建议:Error 日志 + 现有 Asynq MaxRetry(3) 已覆盖) diff --git a/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/proposal.md b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/proposal.md new file mode 100644 index 0000000..abb2ee2 --- /dev/null +++ b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/proposal.md @@ -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)将在修复部署后自动被孤儿扫描激活 diff --git a/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/specs/package-queue-activation/spec.md b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/specs/package-queue-activation/spec.md new file mode 100644 index 0000000..6c710b0 --- /dev/null +++ b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/specs/package-queue-activation/spec.md @@ -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 → 1,activated_at → now,expires_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** 排队套餐继续等待当前套餐过期后的正常激活流程 diff --git a/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/specs/time-field-nullable-fix/spec.md b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/specs/time-field-nullable-fix/spec.md new file mode 100644 index 0000000..a2d2fa5 --- /dev/null +++ b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/specs/time-field-nullable-fix/spec.md @@ -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** 必须先判断指针是否为 nil,nil 时使用零值时间或跳过该字段,不得直接解引用导致 panic diff --git a/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/tasks.md b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/tasks.md new file mode 100644 index 0000000..a713a38 --- /dev/null +++ b/openspec/changes/archive/2026-04-16-fix-package-activation-and-time-fields/tasks.md @@ -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"`)