package carrierthreshold import ( "context" "time" "go.uber.org/zap" "gorm.io/gorm" domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // cycleBatchSize 是单次周期处理扫描的锁行上限。 const cycleBatchSize = 200 // CycleStats 是一次周期处理的可观察结果。 // // Scanned 为扫到的待处理持锁数;Unlocked 为本次认领解锁成功的锁数;Resumed 为同时写出复机事件的锁数; // Anomaly 为因运营商配置缺失或重置日非法而解锁并转人工的锁数;Skipped 为本地事实缺失、被并发推进 // 或判定失败而未改动的锁数。 type CycleStats struct{ Scanned, Unlocked, Resumed, Anomaly, Skipped int } // ProcessDueLocks 扫描待处理的持锁锁行:认领解锁,并在新周期条件满足时同事务写复机事件。 // // 过期判断按锁行自身运营商的 data_reset_day(支持换运营商后旧锁仍按其归属处理)。解锁与复机事件 // 在同一事务提交:事务失败则解锁一起回滚,下一分钟重新处理,不会出现「已解锁但没有复机任务」的中间态。 // 任一复机条件不满足时只解锁、不调运营商,并把可观察原因写入锁行。 // // 锁行引用的运营商已不存在或重置日非法时无法计算周期归属:这类行按「新周期对仍持锁卡解除通道锁」 // 的语义解锁并标记异常转人工,绝不写复机事件、不调运营商,也绝不静默跳过(否则持锁卡会永久禁止复机)。 func (s *Service) ProcessDueLocks(ctx context.Context, now time.Time) (CycleStats, error) { stats := CycleStats{} if s == nil || s.db == nil || s.repository == nil { return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值周期处理能力未配置") } due, err := s.ScanDueLocks(ctx, now, cycleBatchSize) if err != nil { return stats, err } stats.Scanned = len(due) if len(due) == 0 { return stats, nil } if s.commander == nil { return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置") } var firstErr error for index := range due { item := due[index] if err := s.processDueLock(ctx, &item, &stats); err != nil { stats.Skipped++ s.logger.Warn("通道阈值跨期处理单条失败", zap.Uint("lock_id", item.Lock.ID), zap.Uint("card_id", item.Lock.CardID), zap.Error(err)) if firstErr == nil { firstErr = err } } } return stats, firstErr } // processDueLock 处理单条待处理锁行:周期归属不可判定时解锁并转人工,已跨期时认领解锁并条件复机。 func (s *Service) processDueLock(ctx context.Context, item *DueLock, stats *CycleStats) error { if item.Kind == DueLockUnresolvable { return s.processUnresolvableLock(ctx, item, stats) } lock := &item.Lock card, err := s.loadCard(ctx, lock.CardID) if err != nil { return err } if card == nil { stats.Skipped++ s.logger.Warn("通道阈值跨期锁对应卡不存在,本次不处理", zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID)) return nil } // 复机条件复用既有单一事实源(有效主套餐 + 流量未耗尽 + 实名满足 + 非风险扩展 + 无其他停因)。 ready, reason, err := s.commander.ResumeReady(ctx, lock.CardID) if err != nil { // 判定失败时不解锁:保留锁与拒绝复机的语义,下一分钟重试。 return err } unlocked := false err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason) if unlockErr != nil { return unlockErr } if !claimed { return nil } unlocked = true if !ready { return nil } return AppendResumeRequested(ctx, tx, s.repository, lock) }) if err != nil { return err } if !unlocked { stats.Skipped++ s.logger.Info("通道阈值跨期锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID)) return nil } stats.Unlocked++ if !ready { s.logger.Info("通道阈值新周期仅解锁,不调用运营商复机", zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.String("reason", reason)) return nil } stats.Resumed++ s.logger.Info("通道阈值新周期条件满足,已写复机事件", zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID)) return nil } // processUnresolvableLock 处理周期归属不可判定的锁行:同事务认领解锁并标记异常转人工。 // // 只解锁不写复机事件、不调运营商:配置缺失时无法判断是否已跨期,解除通道锁交由既有复机链路 // 与人工决定;anomaly_flag 与失败原因使运维可见并转人工核对。解锁与异常标记都是条件更新, // 重复执行不会产生第二次副作用。 func (s *Service) processUnresolvableLock(ctx context.Context, item *DueLock, stats *CycleStats) error { lock := &item.Lock s.logger.Warn("通道阈值锁行周期归属不可判定,解锁并转人工核对", zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Uint("carrier_id", lock.CarrierID), zap.String("reason", item.Reason)) unlocked := false err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, item.Reason) if unlockErr != nil { return unlockErr } if !claimed { return nil } unlocked = true _, anomalyErr := s.markAnomaly(ctx, lock.ID, item.Reason) return anomalyErr }) if err != nil { return err } if !unlocked { stats.Skipped++ s.logger.Info("通道阈值不可判定锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID)) return nil } stats.Unlocked++ stats.Anomaly++ return nil } // recoveryBatchSize 是单次恢复扫描的锁行上限。 const recoveryBatchSize = 200 // RecoveryStats 是一次恢复扫描的可观察结果。 // // Scanned 为扫到的待确认锁数;Confirmed 为本次回填为运营商已确认的锁数;Pending 为结果仍未知、 // 等待下次扫描的锁数;Anomaly 为超过查询窗口仍不可确认、本次标记转人工的锁数; // Skipped 为本地事实缺失或已被并发推进而未改动状态的锁数。 type RecoveryStats struct{ Scanned, Confirmed, Pending, Anomaly, Skipped int } // RecoverSubmitted 扫描存在已提交子任务的锁:只查询运营商状态回填,绝不重复发起停复机。 // // 每个子任务独立判断:查询确认到达目标状态即回填 confirmed 并补写卡状态(覆盖运营商调用成功但 // 本地回写失败的场景);仍不可确认则等到下一次扫描;自提交起超过查询窗口仍不可确认时标记异常 // 并退出自动扫描转人工,锁行与历史结果一律保留,绝不自动删除。 func (s *Service) RecoverSubmitted(ctx context.Context, now time.Time) (RecoveryStats, error) { stats := RecoveryStats{} if s == nil || s.db == nil { return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值恢复扫描能力未配置") } locks, err := s.ScanSubmittedLocks(ctx, recoveryBatchSize) if err != nil { return stats, err } stats.Scanned = len(locks) if len(locks) == 0 { return stats, nil } if s.commander == nil { return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置") } var firstErr error for index := range locks { lock := locks[index] if err := s.recoverSubmittedLock(ctx, &lock, now, &stats); err != nil { stats.Skipped++ s.logger.Warn("通道阈值恢复扫描单条失败", zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err)) if firstErr == nil { firstErr = err } } } return stats, firstErr } // recoverSubmittedLock 处理单条待确认锁:只查询状态回填,不发起任何停复机调用。 // // 入口只处理未决子任务(submitted/unknown/failed):调用失败或结果未知同样可能已在运营商侧生效, // 必须继续收敛;confirmed 是终态,pending 表示从未对外调用,都不在本扫描范围。 func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, now time.Time, stats *RecoveryStats) error { if !domain.IsUnresolvedTaskStatus(lock.StopStatus) && !domain.IsUnresolvedTaskStatus(lock.ResumeStatus) { return nil } card, err := s.loadCard(ctx, lock.CardID) if err != nil { return err } status, known := constants.NetworkStatusOffline, false if card != nil { // 查询失败按「仍未确认」处理,绝不误判为失败终态。 status, known, _, err = s.commander.CardNetworkStatus(ctx, lock.CardID) if err != nil { s.logger.Warn("查询运营商卡状态失败,按仍未确认处理", zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err)) known = false } } confirmed := 0 unconfirmed := 0 if domain.IsUnresolvedTaskStatus(lock.StopStatus) { switch { case known && status == constants.NetworkStatusOffline: confirmed++ if err := s.confirmStop(ctx, lock, card); err != nil { return err } default: unconfirmed++ } } if domain.IsUnresolvedTaskStatus(lock.ResumeStatus) { switch { case known && status == constants.NetworkStatusOnline: confirmed++ if err := s.confirmResume(ctx, lock, card); err != nil { return err } default: unconfirmed++ } } if confirmed == 0 { stats.Pending++ } else { stats.Confirmed++ } if unconfirmed == 0 { return nil } // 仍有子任务不可确认:窗口内继续等待,超期标记异常并退出自动扫描。 if !domain.SubmissionExpired(lock.StopSubmittedAt, now) && !domain.SubmissionExpired(lock.ResumeSubmittedAt, now) { return nil } marked, err := s.markAnomaly(ctx, lock.ID, "运营商停复机结果超过确认窗口仍不可查,请人工核对") if err != nil { return err } if marked { stats.Anomaly++ s.logger.Warn("通道阈值停复机结果超期不可确认,已标记异常转人工", zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Time("stop_submitted_at", valueOrZero(lock.StopSubmittedAt)), zap.Time("resume_submitted_at", valueOrZero(lock.ResumeSubmittedAt))) } return nil } // confirmStop 停机已被运营商确认:先补写卡停机状态,再把子任务回填为已确认。 // 顺序不可颠倒:先写卡状态才能保证「已确认」的锁不会掩盖未回写的卡事实。 func (s *Service) confirmStop(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error { if card != nil && card.NetworkStatus != constants.NetworkStatusOffline { if err := s.commander.ConfirmCardState(ctx, lock.CardID, true); err != nil { return err } } _, err := s.markTaskConfirmed(ctx, lock.ID, stopTask, lock.StopIntegrationID) return err } // confirmResume 复机已被运营商确认:先补写卡在线状态,再把子任务回填为已确认。 func (s *Service) confirmResume(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error { if card != nil && card.NetworkStatus != constants.NetworkStatusOnline { if err := s.commander.ConfirmCardState(ctx, lock.CardID, false); err != nil { return err } } _, err := s.markTaskConfirmed(ctx, lock.ID, resumeTask, lock.ResumeIntegrationID) return err } // valueOrZero 在日志中安全展开可空的提交时刻。 func valueOrZero(value *time.Time) time.Time { if value == nil { return time.Time{} } return *value }