package packagepkg import ( "context" "time" "github.com/break/junhong_cmp_fiber/internal/model" "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" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" ) // ResumeCallback 任务 24.7: 复机回调接口 // 用于在套餐激活后触发自动复机 type ResumeCallback interface { // ResumeCardIfStopped 购买套餐后自动复机 ResumeCardIfStopped(ctx context.Context, cardID uint) error } type ActivationService struct { db *gorm.DB redis *redis.Client packageUsageStore *postgres.PackageUsageStore packageStore *postgres.PackageStore packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore logger *zap.Logger resumeCallback ResumeCallback // 复机回调,可选 } func NewActivationService( db *gorm.DB, redis *redis.Client, packageUsageStore *postgres.PackageUsageStore, packageStore *postgres.PackageStore, packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore, logger *zap.Logger, ) *ActivationService { return &ActivationService{ db: db, redis: redis, packageUsageStore: packageUsageStore, packageStore: packageStore, packageUsageDailyRecord: packageUsageDailyRecord, logger: logger, } } // SetResumeCallback 任务 24.7: 设置复机回调 // 在应用启动时由 bootstrap 调用,注入停复机服务 func (s *ActivationService) SetResumeCallback(callback ResumeCallback) { s.resumeCallback = callback } // ActivateByRealname 任务 9.2-9.3: 首次实名激活 // 当用户完成实名后,激活所有待实名激活的套餐 func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error { // 查询待实名激活的套餐 var pendingUsages []*model.PackageUsage query := s.db.WithContext(ctx). Where("pending_realname_activation = ?", true). Where("status = ?", constants.PackageUsageStatusPending) if carrierType == "iot_card" { query = query.Where("iot_card_id = ?", carrierID) } else if carrierType == "device" { query = query.Where("device_id = ?", carrierID) } else { return errors.New(errors.CodeInvalidParam, "无效的载体类型") } if err := query.Order("priority ASC").Find(&pendingUsages).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询待实名激活套餐失败") } if len(pendingUsages) == 0 { s.logger.Info("没有待实名激活的套餐", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return nil } now := time.Now() // 在事务中激活套餐 return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, usage := range pendingUsages { // 查询套餐信息 var pkg model.Package if err := tx.First(&pkg, usage.PackageID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } // 检查是否是主套餐 if usage.MasterUsageID == nil { // 主套餐:需要检查是否有已激活的主套餐 var activeMain model.PackageUsage err := tx.Where("status = ?", constants.PackageUsageStatusActive). Where("master_usage_id IS NULL"). Where(carrierType+"_id = ?", carrierID). Order("priority ASC"). First(&activeMain).Error if err == nil { // 已有激活的主套餐,保持排队状态 s.logger.Warn("已有激活主套餐,跳过激活", zap.Uint("usage_id", usage.ID), zap.Uint("active_main_id", activeMain.ID)) continue } if err != gorm.ErrRecordNotFound { return errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败") } } // 激活套餐 activatedAt := now expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) // 计算下次重置时间 billingDay := 1 // 默认1号计费 if carrierType == "iot_card" { var card model.IotCard if err := tx.First(&card, carrierID).Error; err == nil { var carrier model.Carrier if err := tx.First(&carrier, card.CarrierID).Error; err == nil { if carrier.CarrierType == "CUCC" { billingDay = 27 // 联通27号计费 } } } } nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, now, billingDay) // 更新套餐使用记录 updates := map[string]interface{}{ "status": constants.PackageUsageStatusActive, "pending_realname_activation": false, "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.logger.Info("套餐已激活", zap.Uint("usage_id", usage.ID), zap.Uint("package_id", usage.PackageID), zap.Time("activated_at", activatedAt), zap.Time("expires_at", expiresAt)) // 任务 24.7: 在套餐激活后触发自动复机 if s.resumeCallback != nil && carrierType == "iot_card" { go func(cardID uint) { resumeCtx := context.Background() if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, cardID); err != nil { s.logger.Error("自动复机失败", zap.Uint("card_id", cardID), zap.Error(err)) } }(carrierID) } } return nil }) } // ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活 // 当主套餐过期后,激活下一个待生效的主套餐 func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierType string, carrierID uint) error { // 使用 Redis 分布式锁避免并发 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) return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 任务 9.5: 检测并标记过期的主套餐 now := time.Now() var expiredMainUsages []*model.PackageUsage err := tx.Where("status = ?", constants.PackageUsageStatusActive). Where("master_usage_id IS NULL"). Where("expires_at <= ?", now). Where(carrierType+"_id = ?", carrierID). Find(&expiredMainUsages).Error if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询过期主套餐失败") } for _, expiredMain := range expiredMainUsages { // 更新主套餐状态为已过期 if err := tx.Model(expiredMain).Update("status", constants.PackageUsageStatusExpired).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新过期主套餐状态失败") } s.logger.Info("主套餐已过期", zap.Uint("usage_id", expiredMain.ID), zap.Time("expires_at", expiredMain.ExpiresAt)) // 任务 9.7: 加油包级联失效 if err := s.invalidateAddons(ctx, tx, expiredMain.ID); err != nil { return err } // 任务 9.6: 激活下一个待生效主套餐 if err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now); err != nil { return err } } return nil }) } // invalidateAddons 任务 9.7: 加油包级联失效 func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error { var addons []*model.PackageUsage if err := tx.Where("master_usage_id = ?", masterUsageID). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusPending}). Find(&addons).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败") } if len(addons) == 0 { return nil } addonIDs := make([]uint, len(addons)) for i, addon := range addons { addonIDs[i] = addon.ID } // 批量更新加油包状态为已失效 if err := tx.Model(&model.PackageUsage{}). Where("id IN ?", addonIDs). Update("status", constants.PackageUsageStatusInvalidated).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败") } s.logger.Info("加油包已级联失效", zap.Uint("master_usage_id", masterUsageID), zap.Int("addon_count", len(addons))) return nil } // activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐 func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error { // 查询下一个待生效主套餐 var nextMain model.PackageUsage err := tx.Where("status = ?", constants.PackageUsageStatusPending). Where("master_usage_id IS NULL"). Where(carrierType+"_id = ?", carrierID). Order("priority ASC"). First(&nextMain).Error if err == gorm.ErrRecordNotFound { s.logger.Info("没有待生效的主套餐", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return nil } if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询下一个待生效主套餐失败") } // 查询套餐信息 var pkg model.Package if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } // 激活套餐 activatedAt := now expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) // 计算下次重置时间 billingDay := 1 if carrierType == "iot_card" { var card model.IotCard if err := tx.First(&card, carrierID).Error; err == nil { var carrier model.Carrier if err := tx.First(&carrier, card.CarrierID).Error; err == nil { if carrier.CarrierType == "CUCC" { billingDay = 27 } } } } nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, now, billingDay) // 更新套餐使用记录 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(&nextMain).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败") } s.logger.Info("排队主套餐已激活", zap.Uint("usage_id", nextMain.ID), zap.Uint("package_id", nextMain.PackageID), zap.Time("activated_at", activatedAt), zap.Time("expires_at", expiresAt)) // 任务 24.7: 在套餐激活后触发自动复机 if s.resumeCallback != nil && carrierType == "iot_card" { go func(cardID uint) { resumeCtx := context.Background() if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, cardID); err != nil { s.logger.Error("排队激活后自动复机失败", zap.Uint("card_id", cardID), zap.Error(err)) } }(carrierID) } return nil }