feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s

【核心变更】

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>
This commit is contained in:
2026-04-07 12:27:04 +08:00
parent 10fcc0b3c9
commit 434a8b0349
62 changed files with 7496 additions and 3023 deletions

View File

@@ -17,12 +17,22 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// StopResumeServiceInterface 停复机服务接口
// 供 4 个 Task Handler 通过接口注入,避免循环依赖
type StopResumeServiceInterface interface {
// EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
}
// 编译时验证 StopResumeService 实现了 StopResumeServiceInterface
var _ StopResumeServiceInterface = (*StopResumeService)(nil)
// StopResumeService 停复机服务
// 处理 IoT 卡的自动停机、复机和手动停复机逻辑
type StopResumeService struct {
db *gorm.DB
redis *redis.Client
iotCardStore *postgres.IotCardStore
packageUsageStore *postgres.PackageUsageStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
gatewayClient *gateway.Client
logger *zap.Logger
@@ -33,17 +43,17 @@ type StopResumeService struct {
// NewStopResumeService 创建停复机服务
func NewStopResumeService(
db *gorm.DB,
redis *redis.Client,
iotCardStore *postgres.IotCardStore,
packageUsageStore *postgres.PackageUsageStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *StopResumeService {
return &StopResumeService{
db: db,
redis: redis,
iotCardStore: iotCardStore,
packageUsageStore: packageUsageStore,
deviceSimBindingStore: deviceSimBindingStore,
gatewayClient: gatewayClient,
logger: logger,
@@ -52,79 +62,297 @@ func NewStopResumeService(
}
}
// CheckAndStopCard 任务 24.3: 检查流量耗尽并停机
// 当所有套餐流量用完时,调用运营商接口停机
// EvaluateAndAct 停复机统一入口
// 根据卡的当前网络状态,自动判断并执行停机或复机操作
// 设备/单卡维度通过 card.IsStandalone 推导false 则为设备维度,停/复机覆盖设备下所有卡)
func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotCard) error {
switch card.NetworkStatus {
case constants.NetworkStatusOnline:
// 卡在线,检查是否需要停机
reasons, err := s.checkStopReasons(ctx, card)
if err != nil {
return err
}
if len(reasons) == 0 {
return nil
}
// 取最高优先级原因(第一个)
primaryReason := reasons[0]
if !card.IsStandalone {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil {
s.logger.Error("查询设备绑定关系失败,降级为单卡停机",
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
// 降级:查不到绑定时按单卡处理
} else if bound {
s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
case constants.NetworkStatusOffline:
// 卡停机,检查是否可以复机
// 只处理轮询系统引起的停机原因(手动停机等不自动复机)
if !isPollingStopReason(card.StopReason) {
return nil
}
shouldResume, err := s.shouldResume(ctx, card)
if err != nil {
return err
}
if !shouldResume {
return nil
}
if !card.IsStandalone {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil {
s.logger.Error("查询设备绑定关系失败,降级为单卡复机",
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
} else if bound {
s.resumeDeviceCards(ctx, deviceID)
return nil
}
}
return s.resumeSingleCard(ctx, card.ID)
default:
return nil
}
}
// isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机)
func isPollingStopReason(reason string) bool {
switch reason {
case constants.StopReasonTrafficExhausted,
constants.StopReasonNoPackage,
constants.StopReasonNotRealname:
return true
}
return false
}
// getCardDeviceID 获取卡所属设备ID非独立卡时有效
// 返回deviceID, isDeviceBound, error
func (s *StopResumeService) getCardDeviceID(ctx context.Context, card *model.IotCard) (uint, bool, error) {
if card.IsStandalone {
return 0, false, nil
}
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return 0, false, nil
}
return 0, false, err
}
if binding == nil {
return 0, false, nil
}
return binding.DeviceID, true, nil
}
// hasValidPackage 检查卡是否有有效套餐status IN 0,1
// 修复Bug1绑定设备的卡查 device_id独立卡查 iot_card_id
func (s *StopResumeService) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error) {
if !card.IsStandalone {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil {
return false, lookupErr
}
if bound {
return s.packageUsageStore.HasValidByCarrier(ctx, "device", deviceID)
}
}
return s.packageUsageStore.HasValidByCarrier(ctx, "iot_card", card.ID)
}
// isTrafficExhausted 检查卡的流量是否已耗尽
// 条件:活跃套餐 status=2已用完或 data_usage_mb >= data_limit_mbdata_limit_mb > 0
// 业务约定data_limit_mb=0 表示无限流量套餐,永远不判定为耗尽
func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model.IotCard) (bool, error) {
carrierType := "iot_card"
carrierID := card.ID
if !card.IsStandalone {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil {
return false, lookupErr
}
if bound {
carrierType = "device"
carrierID = deviceID
}
}
pkg, err := s.packageUsageStore.GetActiveOrDepletedByCarrier(ctx, carrierType, carrierID)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
if pkg.Status == constants.PackageUsageStatusDepleted {
return true, nil
}
return pkg.DataLimitMB > 0 && pkg.DataUsageMB >= pkg.DataLimitMB, nil
}
// isRealnameOK 检查卡是否满足实名要求
// 行业卡card_category='industry')无需实名;其他卡需 real_name_status=1
func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool {
return card.CardCategory == constants.CardCategoryIndustry ||
card.RealNameStatus == constants.RealNameStatusVerified
}
// checkStopReasons 检查卡的停机原因列表,按优先级排序
// 优先级no_package > traffic_exhausted > not_realname
func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error) {
var reasons []string
// 条件A无有效套餐优先级最高
hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil {
return nil, err
}
if !hasPackage {
reasons = append(reasons, constants.StopReasonNoPackage)
}
// 条件B虚流量耗尽
exhausted, err := s.isTrafficExhausted(ctx, card)
if err != nil {
return nil, err
}
if exhausted {
reasons = append(reasons, constants.StopReasonTrafficExhausted)
}
// 条件C非行业卡且未实名
if !s.isRealnameOK(card) {
reasons = append(reasons, constants.StopReasonNotRealname)
}
return reasons, nil
}
// shouldResume 检查停机卡是否满足复机条件
// 须同时满足:有有效套餐 + 流量未耗尽 + 实名OK
func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCard) (bool, error) {
hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil {
return false, err
}
if !hasPackage {
return false, nil
}
exhausted, err := s.isTrafficExhausted(ctx, card)
if err != nil {
return false, err
}
if exhausted {
return false, nil
}
return s.isRealnameOK(card), nil
}
// stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁)
// 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 停机调用
func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) {
// 设备维度幂等锁:防止多协程同时对同一设备停机
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if !locked {
s.logger.Debug("设备停机操作已在进行中,跳过重复调用",
zap.Uint("device_id", deviceID))
return
}
defer s.redis.Del(ctx, lockKey)
// 查询设备下所有在线卡
cards, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOnline)
if err != nil {
s.logger.Error("查询设备在线卡失败",
zap.Uint("device_id", deviceID), zap.Error(err))
return
}
for _, card := range cards {
if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
s.logger.Warn("设备卡停机失败,继续处理其他卡",
zap.Uint("device_id", deviceID),
zap.Uint("card_id", card.ID),
zap.Error(stopErr))
}
}
}
// resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁)
// 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) {
// 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if !locked {
s.logger.Debug("设备复机操作已在进行中,跳过重复调用",
zap.Uint("device_id", deviceID))
return
}
defer s.redis.Del(ctx, lockKey)
// 查询设备下因轮询原因停机的卡
cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, deviceID)
if err != nil {
s.logger.Error("查询设备停机卡失败",
zap.Uint("device_id", deviceID), zap.Error(err))
return
}
for _, card := range cards {
if !s.isRealnameOK(card) {
if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil {
s.logger.Warn("更新未实名卡停机原因失败",
zap.Uint("card_id", card.ID), zap.Error(updateErr))
}
continue
}
if resumeErr := s.resumeSingleCard(ctx, card.ID); resumeErr != nil {
s.logger.Warn("设备卡复机失败,继续处理其他卡",
zap.Uint("device_id", deviceID),
zap.Uint("card_id", card.ID),
zap.Error(resumeErr))
}
}
}
// CheckAndStopCard 检查流量耗尽并停机(旧入口,保留兼容性)
// 新代码应优先使用 EvaluateAndAct
func (s *StopResumeService) CheckAndStopCard(ctx context.Context, cardID uint) error {
// 查询卡信息
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return err
}
// 如果已经是停机状态,跳过
if card.NetworkStatus == constants.NetworkStatusOffline {
s.logger.Debug("卡已处于停机状态,跳过",
zap.Uint("card_id", cardID))
return nil
}
// 检查是否有可用套餐status=1 生效中 或 status=0 待生效)
hasAvailablePackage, err := s.hasAvailablePackage(ctx, cardID)
if err != nil {
return err
}
// 如果还有可用套餐,不停机
if hasAvailablePackage {
return nil
}
// 任务 24.5: 调用运营商停机接口(带重试机制)
if err := s.stopCardWithRetry(ctx, card); err != nil {
s.logger.Error("调用运营商停机接口失败",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Error(err))
return err
}
// 更新卡状态
now := time.Now()
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonTrafficExhausted,
}).Error; err != nil {
return err
}
// 失效轮询缓存,确保下次轮询从 DB 读取最新状态
s.invalidatePollingCache(cardID)
s.logger.Info("卡因流量耗尽已停机",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID))
return nil
return s.EvaluateAndAct(ctx, card)
}
// ResumeCardIfStopped 套餐激活或流量重置后自动复机入口
// 支持 iot_card 和 device 两种载体类型
// - iot_card对单张卡执行实名检查 → 停机原因检查 → 分布式锁 → Gateway → 更新 DB
// - device查询设备下所有已绑定卡逐一执行单卡复机逻辑
// 支持 iot_card 和 device 两种载体类型
func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error {
switch carrierType {
case "iot_card":
return s.resumeSingleCard(ctx, carrierID)
case "device":
return s.resumeDeviceCards(ctx, carrierID)
s.resumeDeviceCards(ctx, carrierID)
return nil
default:
return nil
}
}
// resumeSingleCard 对单张卡执行复机逻辑
// 依次检查:已开机则跳过 → 未实名则跳过WARN→ 非流量耗尽停机则跳过 → 加锁 → 调 Gateway → 更新 DB
// 依次检查:已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
@@ -135,17 +363,20 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
return nil
}
if card.RealNameStatus != constants.RealNameStatusVerified {
s.logger.Warn("未实名卡跳过自动复机",
// 只处理轮询系统引起的停机
if !isPollingStopReason(card.StopReason) {
s.logger.Debug("卡非轮询停机原因,不自动复机",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID))
zap.String("stop_reason", card.StopReason))
return nil
}
if card.StopReason != constants.StopReasonTrafficExhausted {
s.logger.Debug("卡非流量耗尽停机,不自动复机",
zap.Uint("card_id", cardID),
zap.String("stop_reason", card.StopReason))
// 检查复机条件
resume, err := s.shouldResume(ctx, card)
if err != nil {
return err
}
if !resume {
return nil
}
@@ -173,12 +404,11 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
if err := s.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}).Error; err != nil {
// Gateway 成功但 DB 更新失败:记录 ERROR 供人工排查,不阻断流程
}); err != nil {
s.logger.Error("复机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
@@ -186,7 +416,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
return nil
}
s.invalidatePollingCache(cardID)
s.invalidatePollingCache(ctx, card.ID)
s.logger.Info("卡已自动复机",
zap.Uint("card_id", cardID),
@@ -195,56 +425,16 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
return nil
}
// resumeDeviceCards 对设备绑定的所有已绑定卡逐一执行复机逻辑
// 部分卡实名、部分未实名时:已实名卡复机,未实名卡跳过,互不影响
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error {
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
s.logger.Warn("查询设备绑定卡失败",
zap.Uint("device_id", deviceID),
zap.Error(err))
return nil
}
for _, binding := range bindings {
if resumeErr := s.resumeSingleCard(ctx, binding.IotCardID); resumeErr != nil {
s.logger.Warn("设备绑定卡复机失败,继续处理其他卡",
zap.Uint("device_id", deviceID),
zap.Uint("card_id", binding.IotCardID),
zap.Error(resumeErr))
}
}
return nil
}
// hasAvailablePackage 检查是否有可用套餐
func (s *StopResumeService) hasAvailablePackage(ctx context.Context, cardID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
Where("iot_card_id = ?", cardID).
Where("status IN ?", []int{
constants.PackageUsageStatusPending, // 待生效
constants.PackageUsageStatusActive, // 生效中
}).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// stopCardWithRetry 任务 24.5: 调用运营商停机接口(带重试机制)
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard) error {
// stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error {
if s.gatewayClient == nil {
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
}
s.logger.Info("调用网关停机",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID))
zap.String("iccid", card.ICCID),
zap.String("stop_reason", stopReason))
var lastErr error
for i := 0; i < s.maxRetries; i++ {
@@ -262,6 +452,20 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
s.logger.Info("网关停机成功",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID))
now := time.Now()
if updateErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": stopReason,
}); updateErr != nil {
s.logger.Error("停机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Error(updateErr))
}
s.invalidatePollingCache(ctx, card.ID)
return nil
}
@@ -275,7 +479,7 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
return lastErr
}
// resumeCardWithRetry 任务 24.5: 调用运营商复机接口(带重试机制)
// resumeCardWithRetry 调用运营商复机接口(带重试机制)
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard) error {
if s.gatewayClient == nil {
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
@@ -338,21 +542,10 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
}
}
if err := s.stopCardWithRetry(ctx, card); err != nil {
if err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试")
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
}
s.invalidatePollingCache(card.ID)
return nil
}
@@ -385,26 +578,26 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}).Error; err != nil {
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
}
s.invalidatePollingCache(card.ID)
s.invalidatePollingCache(ctx, card.ID)
return nil
}
// invalidatePollingCache 删除轮询卡缓存,下次轮询自动从 DB 重建
func (s *StopResumeService) invalidatePollingCache(cardID uint) {
func (s *StopResumeService) invalidatePollingCache(ctx context.Context, cardID uint) {
if s.redis == nil {
return
}
key := constants.RedisPollingCardInfoKey(cardID)
if err := s.redis.Del(context.Background(), key).Err(); err != nil {
s.logger.Warn("删除轮询卡缓存失败", zap.Uint("card_id", cardID), zap.Error(err))
if err := s.redis.Del(ctx, key).Err(); err != nil {
s.logger.Warn("删除轮询卡缓存失败", zap.Uint("card_id", cardID))
}
}

View File

@@ -0,0 +1,89 @@
package polling
import (
"context"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/polling"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"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"
)
// AssetPollingService 资产轮询管控服务
// 管理 IoT 卡和设备的轮询启用状态
// S2 修复card 类型委托给 IotCardService含 DB 写入 + callback 通知),避免绕过生命周期
type AssetPollingService struct {
deviceStore *postgres.DeviceStore
deviceBindingStore *postgres.DeviceSimBindingStore
iotCardService *iotCardSvc.Service
queueMgr *polling.PollingQueueManager
logger *zap.Logger
}
// NewAssetPollingService 创建资产轮询管控服务
func NewAssetPollingService(
deviceStore *postgres.DeviceStore,
deviceBindingStore *postgres.DeviceSimBindingStore,
iotCardService *iotCardSvc.Service,
queueMgr *polling.PollingQueueManager,
logger *zap.Logger,
) *AssetPollingService {
return &AssetPollingService{
deviceStore: deviceStore,
deviceBindingStore: deviceBindingStore,
iotCardService: iotCardService,
queueMgr: queueMgr,
logger: logger,
}
}
// UpdatePollingStatus 更新资产轮询状态
// assetType: "card" 或 "device"
// assetID: 资产ID
// enablePolling: 是否启用轮询
func (s *AssetPollingService) UpdatePollingStatus(ctx context.Context, assetType string, assetID uint, enablePolling bool) error {
switch assetType {
case constants.AssetTypeIotCard:
// S2 修复:委托给 IotCardService确保 DB 写入 + PollingCallback 回调一并触发
return s.iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling)
case constants.AssetTypeDevice:
// 1. 更新设备的 enable_polling 字段
if err := s.deviceStore.UpdatePollingStatus(ctx, assetID, enablePolling); err != nil {
return err
}
bindings, err := s.deviceBindingStore.ListByDeviceID(ctx, assetID)
if err != nil {
s.logger.Warn("查询设备绑定卡失败,绑定卡轮询状态同步可能不完整",
zap.Uint("device_id", assetID), zap.Error(err))
return nil
}
if len(bindings) == 0 {
return nil
}
cardIDs := make([]uint, len(bindings))
for i, b := range bindings {
cardIDs[i] = b.IotCardID
}
// 2. M3 修复:级联同步绑定卡的 enable_polling防止生命周期事件绕过设备级设置
if syncErr := s.iotCardService.BatchUpdatePollingStatus(ctx, cardIDs, enablePolling); syncErr != nil {
s.logger.Warn("批量同步绑定卡轮询状态失败",
zap.Uint("device_id", assetID), zap.Error(syncErr))
}
if !enablePolling {
for _, b := range bindings {
if rmErr := s.queueMgr.RemoveFromAllQueues(ctx, b.IotCardID); rmErr != nil {
s.logger.Warn("从队列移除卡失败",
zap.Uint("card_id", b.IotCardID), zap.Error(rmErr))
}
}
}
return nil
default:
return errors.New(errors.CodeInvalidParam, "资产类型无效,支持 card 或 device")
}
}

View File

@@ -5,19 +5,34 @@ import (
"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
redis *redis.Client
queueMgr *polling.PollingQueueManager
}
// NewMonitoringService 创建轮询监控服务实例
func NewMonitoringService(redis *redis.Client) *MonitoringService {
return &MonitoringService{redis: redis}
// 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 总览统计
@@ -84,10 +99,15 @@ func (s *MonitoringService) GetOverview(ctx context.Context) (*OverviewStats, er
}
stats.IsInitializing = stats.InitializedCards < stats.TotalCards && stats.TotalCards > 0
// 获取队列大小
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()
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
}
@@ -120,8 +140,11 @@ func (s *MonitoringService) GetQueueStatuses(ctx context.Context) ([]*QueueStatu
queueKey = constants.RedisPollingQueuePackageKey()
}
// 获取队列大小
status.QueueSize, _ = s.redis.ZCard(ctx, queueKey).Result()
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()