feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
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:
@@ -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_mb(data_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))
|
||||
}
|
||||
}
|
||||
|
||||
89
internal/service/polling/asset_polling_service.go
Normal file
89
internal/service/polling/asset_polling_service.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user