Files
junhong_cmp_fiber/internal/service/polling/monitoring_service.go
huang 434a8b0349
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
【核心变更】

1. 停复机逻辑统一(StopResumeService)
   - 新增 EvaluateAndAct 统一入口,封装三条件停复机判断
   - 停机条件:无套餐(no_package) / 流量耗尽(traffic_exhausted) / 未实名(not_realname)
   - 复机条件:stop_reason 合规 + 有套餐且未耗尽 + 已实名或行业卡
   - 修复设备套餐 Bug:hasValidPackage 按 device_id 查套餐,而非仅 iot_card_id
   - 设备维度停复机加幂等锁(Redis SetNX,TTL 30s),防止多卡并发重复调 Gateway

2. Redis 分片队列(PollingQueueManager)
   - 新建 queue_manager.go,封装所有轮询 Redis 操作
   - 16 分片 Sorted Set,Key 格式:polling:shard:{shardID}:queue:{taskType}
   - Lua 脚本原子出队(ZRANGEBYSCORE + 分批 ZREM),消除竞态窗口
   - 新增背压检测:队列深度超 50 万时 Scheduler 跳过该分片
   - RemoveFromAllQueues 覆盖 4 种任务类型(含 protect)

3. Handler 拆分(polling_handler.go 1360行 → 5个专注文件)
   - polling_base.go:共享基类(并发控制/卡缓存/重入队)
   - polling_realname_handler.go:实名采集,实名 0→1 时立即触发复机
   - polling_carddata_handler.go:流量采集,保留跨月边界检测逻辑
   - polling_package_handler.go:套餐采集,委托 EvaluateAndAct 决策
   - polling_protect_handler.go:保护期一致性检查,保护期内强制修正

4. 配置管理(PollingConfigManager)
   - 新建 config_manager.go,从 scheduler.go 提取配置职责
   - 内存缓存 + 5 分钟定时刷新,刷新失败保留原缓存
   - 修复 getCardCondition:停机卡返回 suspended,不再错配 activated 配置

5. 渐进式初始化(CardInitializer)
   - 新建 initializer.go,分批加载(每批 10 万),批次间 sleep 500ms
   - 过滤 enable_polling=false 的卡,初始化完成前 Scheduler 不出队

6. 卡生命周期服务(PollingLifecycleService)
   - 新建 lifecycle_service.go,替代已删除的 callbacks.go 和 api_callback.go
   - OnCardCreated/OnCardEnabled/OnCardStatusChanged 入队前检查 enable_polling

7. Scheduler 精简(1000+行 → 227行)
   - 保留纯调度循环:scheduleLoop + processShardSchedule + enqueueBatch
   - 保留每 10 秒触发套餐过期检测和流量重置
   - 移除所有 DB 操作、配置加载、卡初始化逻辑

8. 轮询管控 API(enable_polling)
   - 新增 PUT /api/admin/assets/:id/polling-status 接口
   - 支持对设备/卡维度开关轮询,关闭后从所有分片队列移除

9. 数据库迁移
   - 000103:tb_device 新增 enable_polling 字段(boolean, NOT NULL, DEFAULT true)
   - 000104:新增 suspended 轮询配置,为 activated 配置补全 protect_check_interval

【文件统计】
- 新增:19 个文件(handler × 5、polling 组件 × 4、迁移 × 3 等)
- 修改:20 个文件(bootstrap 注入、store 接口、monitoring 适配分片等)
- 删除:3 个文件(polling_handler.go、callbacks.go、api_callback.go)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-07 12:27:04 +08:00

307 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package polling
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// MonitoringService 轮询监控服务
type MonitoringService struct {
redis *redis.Client
queueMgr *polling.PollingQueueManager
}
// NewMonitoringService 创建轮询监控服务实例
// queueMgr 可选nil 时降级到旧非分片 Key 兼容模式)
func NewMonitoringService(redis *redis.Client, opts ...interface{}) *MonitoringService {
svc := &MonitoringService{redis: redis}
for _, opt := range opts {
if qm, ok := opt.(*polling.PollingQueueManager); ok {
svc.queueMgr = qm
}
}
return svc
}
// NewMonitoringServiceWithQueueMgr 创建注入了 PollingQueueManager 的监控服务(推荐)
func NewMonitoringServiceWithQueueMgr(redis *redis.Client, queueMgr *polling.PollingQueueManager, _ *zap.Logger) *MonitoringService {
return &MonitoringService{redis: redis, queueMgr: queueMgr}
}
// OverviewStats 总览统计
type OverviewStats struct {
TotalCards int64 `json:"total_cards"` // 总卡数
InitializedCards int64 `json:"initialized_cards"` // 已初始化卡数
InitProgress float64 `json:"init_progress"` // 初始化进度 (0-100)
IsInitializing bool `json:"is_initializing"` // 是否正在初始化
RealnameQueueSize int64 `json:"realname_queue_size"` // 实名检查队列大小
CarddataQueueSize int64 `json:"carddata_queue_size"` // 流量检查队列大小
PackageQueueSize int64 `json:"package_queue_size"` // 套餐检查队列大小
}
// QueueStatus 队列状态
type QueueStatus struct {
TaskType string `json:"task_type"` // 任务类型
TaskTypeName string `json:"task_type_name"` // 任务类型名称
QueueSize int64 `json:"queue_size"` // 队列大小
ManualPending int64 `json:"manual_pending"` // 手动触发待处理数
DueCount int64 `json:"due_count"` // 到期待处理数
AvgWaitTime float64 `json:"avg_wait_time_s"` // 平均等待时间(秒)
}
// TaskStats 任务统计
type TaskStats struct {
TaskType string `json:"task_type"` // 任务类型
TaskTypeName string `json:"task_type_name"` // 任务类型名称
SuccessCount1h int64 `json:"success_count_1h"` // 1小时成功数
FailureCount1h int64 `json:"failure_count_1h"` // 1小时失败数
TotalCount1h int64 `json:"total_count_1h"` // 1小时总数
SuccessRate float64 `json:"success_rate"` // 成功率 (0-100)
AvgDurationMs float64 `json:"avg_duration_ms"` // 平均耗时(毫秒)
}
// InitProgress 初始化进度
type InitProgress struct {
TotalCards int64 `json:"total_cards"` // 总卡数
InitializedCards int64 `json:"initialized_cards"` // 已初始化卡数
Progress float64 `json:"progress"` // 进度百分比 (0-100)
IsComplete bool `json:"is_complete"` // 是否完成
StartedAt time.Time `json:"started_at"` // 开始时间
EstimatedETA string `json:"estimated_eta"` // 预计完成时间
}
// GetOverview 获取总览统计
func (s *MonitoringService) GetOverview(ctx context.Context) (*OverviewStats, error) {
stats := &OverviewStats{}
// 获取初始化进度
progressKey := constants.RedisPollingInitProgressKey()
progressData, err := s.redis.HGetAll(ctx, progressKey).Result()
if err != nil && err != redis.Nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "获取初始化进度失败")
}
if total, ok := progressData["total"]; ok {
stats.TotalCards = parseInt64(total)
}
if initialized, ok := progressData["initialized"]; ok {
stats.InitializedCards = parseInt64(initialized)
}
if stats.TotalCards > 0 {
stats.InitProgress = float64(stats.InitializedCards) / float64(stats.TotalCards) * 100
}
stats.IsInitializing = stats.InitializedCards < stats.TotalCards && stats.TotalCards > 0
if s.queueMgr != nil {
stats.RealnameQueueSize, _ = s.queueMgr.GetTotalQueueDepth(ctx, constants.TaskTypePollingRealname)
stats.CarddataQueueSize, _ = s.queueMgr.GetTotalQueueDepth(ctx, constants.TaskTypePollingCarddata)
stats.PackageQueueSize, _ = s.queueMgr.GetTotalQueueDepth(ctx, constants.TaskTypePollingPackage)
} else {
stats.RealnameQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueueRealnameKey()).Result()
stats.CarddataQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueueCarddataKey()).Result()
stats.PackageQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueuePackageKey()).Result()
}
return stats, nil
}
// GetQueueStatuses 获取所有队列状态
func (s *MonitoringService) GetQueueStatuses(ctx context.Context) ([]*QueueStatus, error) {
taskTypes := []string{
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
}
result := make([]*QueueStatus, 0, len(taskTypes))
now := time.Now().Unix()
for _, taskType := range taskTypes {
status := &QueueStatus{
TaskType: taskType,
TaskTypeName: s.getTaskTypeName(taskType),
}
// 获取队列 key
var queueKey string
switch taskType {
case constants.TaskTypePollingRealname:
queueKey = constants.RedisPollingQueueRealnameKey()
case constants.TaskTypePollingCarddata:
queueKey = constants.RedisPollingQueueCarddataKey()
case constants.TaskTypePollingPackage:
queueKey = constants.RedisPollingQueuePackageKey()
}
if s.queueMgr != nil {
status.QueueSize, _ = s.queueMgr.GetTotalQueueDepth(ctx, taskType)
} else {
status.QueueSize, _ = s.redis.ZCard(ctx, queueKey).Result()
}
// 获取到期数量score <= now
status.DueCount, _ = s.redis.ZCount(ctx, queueKey, "-inf", formatInt64(now)).Result()
// 获取手动触发队列待处理数
manualKey := constants.RedisPollingManualQueueKey(taskType)
status.ManualPending, _ = s.redis.LLen(ctx, manualKey).Result()
// 计算平均等待时间取最早的10个任务的平均等待时间
earliest, err := s.redis.ZRangeWithScores(ctx, queueKey, 0, 9).Result()
if err == nil && len(earliest) > 0 {
var totalWait float64
for _, z := range earliest {
waitTime := float64(now) - z.Score
if waitTime > 0 {
totalWait += waitTime
}
}
status.AvgWaitTime = totalWait / float64(len(earliest))
}
result = append(result, status)
}
return result, nil
}
// GetTaskStatuses 获取所有任务统计
func (s *MonitoringService) GetTaskStatuses(ctx context.Context) ([]*TaskStats, error) {
taskTypes := []string{
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
}
result := make([]*TaskStats, 0, len(taskTypes))
for _, taskType := range taskTypes {
stats := &TaskStats{
TaskType: taskType,
TaskTypeName: s.getTaskTypeName(taskType),
}
// 获取统计数据
statsKey := constants.RedisPollingStatsKey(taskType)
data, err := s.redis.HGetAll(ctx, statsKey).Result()
if err != nil && err != redis.Nil {
continue
}
if success, ok := data["success_count_1h"]; ok {
stats.SuccessCount1h = parseInt64(success)
}
if failure, ok := data["failure_count_1h"]; ok {
stats.FailureCount1h = parseInt64(failure)
}
stats.TotalCount1h = stats.SuccessCount1h + stats.FailureCount1h
if stats.TotalCount1h > 0 {
stats.SuccessRate = float64(stats.SuccessCount1h) / float64(stats.TotalCount1h) * 100
if duration, ok := data["total_duration_1h"]; ok {
totalDuration := parseInt64(duration)
stats.AvgDurationMs = float64(totalDuration) / float64(stats.TotalCount1h)
}
}
result = append(result, stats)
}
return result, nil
}
// GetInitProgress 获取初始化进度详情
func (s *MonitoringService) GetInitProgress(ctx context.Context) (*InitProgress, error) {
progressKey := constants.RedisPollingInitProgressKey()
data, err := s.redis.HGetAll(ctx, progressKey).Result()
if err != nil && err != redis.Nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "获取初始化进度失败")
}
progress := &InitProgress{}
if total, ok := data["total"]; ok {
progress.TotalCards = parseInt64(total)
}
if initialized, ok := data["initialized"]; ok {
progress.InitializedCards = parseInt64(initialized)
}
if startedAt, ok := data["started_at"]; ok {
if t, err := time.Parse(time.RFC3339, startedAt); err == nil {
progress.StartedAt = t
}
}
if progress.TotalCards > 0 {
progress.Progress = float64(progress.InitializedCards) / float64(progress.TotalCards) * 100
}
progress.IsComplete = progress.InitializedCards >= progress.TotalCards && progress.TotalCards > 0
// 估算完成时间
if !progress.IsComplete && progress.InitializedCards > 0 && !progress.StartedAt.IsZero() {
elapsed := time.Since(progress.StartedAt)
remaining := progress.TotalCards - progress.InitializedCards
rate := float64(progress.InitializedCards) / elapsed.Seconds()
if rate > 0 {
etaSeconds := float64(remaining) / rate
eta := time.Now().Add(time.Duration(etaSeconds) * time.Second)
progress.EstimatedETA = eta.Format("15:04:05")
}
}
return progress, nil
}
// getTaskTypeName 获取任务类型的中文名称
func (s *MonitoringService) getTaskTypeName(taskType string) string {
switch taskType {
case constants.TaskTypePollingRealname:
return "实名检查"
case constants.TaskTypePollingCarddata:
return "流量检查"
case constants.TaskTypePollingPackage:
return "套餐检查"
default:
return taskType
}
}
// parseInt64 解析字符串为 int64
func parseInt64(s string) int64 {
var result int64
for _, c := range s {
if c >= '0' && c <= '9' {
result = result*10 + int64(c-'0')
}
}
return result
}
// formatInt64 格式化 int64 为字符串
func formatInt64(n int64) string {
if n == 0 {
return "0"
}
var result []byte
negative := n < 0
if negative {
n = -n
}
for n > 0 {
result = append([]byte{byte('0' + n%10)}, result...)
n /= 10
}
if negative {
result = append([]byte{'-'}, result...)
}
return string(result)
}