feat(通道流量阈值): AUG26-011 运营商通道流量阈值达量停机与周期复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s

This commit is contained in:
2026-09-16 15:54:49 +08:00
parent 41722760b1
commit 59b3df868a
36 changed files with 2431 additions and 93 deletions

View File

@@ -0,0 +1,258 @@
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 为同时写出复机事件的锁数;
// Skipped 为本地事实缺失、被并发推进或判定失败而未改动的锁数。
type CycleStats struct{ Scanned, Unlocked, Resumed, 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, "通道流量阈值周期处理能力未配置")
}
locks, err := s.ScanExpiredLocks(ctx, now, cycleBatchSize)
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.processDueLock(ctx, &lock, &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
}
// processDueLock 处理单条跨期锁:认领解锁并在条件满足时同事务写复机事件。
func (s *Service) processDueLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, stats *CycleStats) error {
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
}
// 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 处理单条待确认锁:只查询状态回填,不发起任何停复机调用。
func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, now time.Time, stats *RecoveryStats) error {
if lock.StopStatus != domain.TaskStatusSubmitted && lock.ResumeStatus != domain.TaskStatusSubmitted {
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 lock.StopStatus == domain.TaskStatusSubmitted {
switch {
case known && status == constants.NetworkStatusOffline:
confirmed++
if err := s.confirmStop(ctx, lock, card); err != nil {
return err
}
default:
unconfirmed++
}
}
if lock.ResumeStatus == domain.TaskStatusSubmitted {
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
}