Compare commits

11 Commits

Author SHA1 Message Date
8bf1282378 归档: add-polling-card-status-with-verbose-log,同步主规范
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m24s
change 目录归档至 openspec/changes/archive/2026-04-15-add-polling-card-status-with-verbose-log/
新建主规范 openspec/specs/polling-card-status-task/spec.md(6个需求)
追加 verbose log 需求至 openspec/specs/polling-task-handlers/spec.md(4个新需求)
protect handler 规范更新字段名:network_status → network_status_at_check + action_taken

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:23:05 +08:00
854330a4b4 docs: 更新 OpenAPI 文档,新增卡状态检查间隔字段
PollingConfig 请求/响应结构中添加 card_status_check_interval 字段说明

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:55 +08:00
e40ad462e1 feat: 注册卡状态轮询 Worker Handler
registerPollingHandlers 创建并注册 PollingCardStatusHandler
日志更新为 realname/carddata/package/protect/card_status

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:49 +08:00
647d78309d feat: 将卡状态任务类型接入队列管理、生命周期和初始化器
queue_manager.go: allTaskTypes 追加 TaskTypePollingCardStatus,注释更正为5个队列
lifecycle_service.go: getEnabledTaskTypes 和 calcInitialDelay 新增 card_status 条件
initializer.go: initBatch 新增 CardStatusCheckInterval 块,以 LastCardStatusCheckAt 为基准写入分片队列

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:42 +08:00
c7f486ae09 feat: 新增 PollingCardStatusHandler 及工具函数卡状态支持
polling_cardstatus_handler.go: 新文件,实现卡开停机状态轮询
  - Gateway QueryCardStatus → 停机/0,其他/1
  - 状态变化时写 DB + 更新缓存 + 触发 EvaluateAndAct
  - verbose log 位于 gateway 块内
polling_utils.go: getIntervalByTaskType 新增 TaskTypePollingCardStatus case

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:34 +08:00
71559547b6 feat: 为四种现有轮询 Handler 添加 verbose log 输出
realname: verbose log 移入 gateway 块内,避免 gateway=nil 时输出零值
carddata: 添加 && h.gateway != nil 门卫,确保仅 gateway 查询成功时输出
package: verbose log 在 freshCard 加载后、EvaluateAndAct 之前输出
protect: 引入 actionTaken 变量,字段名 network_status → network_status_at_check,新增 action_taken

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:25 +08:00
6292443f69 feat: PollingBase 新增 verboseLog 支持,Worker 传入配置
polling_base.go: PollingBase.verboseLog bool 字段,NewPollingBase 新增参数
main.go: cfg.Polling.VerboseLog 作为第6个参数传入 NewPollingBase

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:16 +08:00
97c196b7c6 feat: PollingConfig DTO 和 Service 支持卡状态检查间隔
dto: 新增 CardStatusCheckInterval 字段(validate min=30,description 含枚举说明)
config_service.go: 创建/更新/响应映射均支持 CardStatusCheckInterval

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:09 +08:00
cb328f3ec2 feat: PollingConfig 和 IotCard 模型新增卡状态检查字段
polling.go: CardStatusCheckInterval *int(card_status_check_interval)
iot_card.go: LastCardStatusCheckAt *time.Time(last_card_status_check_at)

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:01 +08:00
9992e82a3c feat: 新增卡状态轮询任务类型常量及 verbose_log 配置
constants.go: 新增 TaskTypePollingCardStatus = "polling:card_status"
config.go: 新增 PollingConfig struct 含 VerboseLog bool
config.yaml: polling.verbose_log 默认 false

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:21:43 +08:00
e2003eb9e3 feat: 新增卡状态检查轮询数据库迁移
tb_polling_config 新增 card_status_check_interval INT NULL
tb_iot_card 新增 last_card_status_check_at TIMESTAMPTZ NULL
含 IF NOT EXISTS 保证幂等,含完整回滚文件

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:21:21 +08:00
30 changed files with 540 additions and 113 deletions

View File

@@ -151,7 +151,7 @@ func main() {
pollingQueueMgr := polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger)
pollingIotCardStore := postgres.NewIotCardStore(db, redisClient)
pollingBase := task.NewPollingBase(redisClient, pollingQueueMgr, pollingConfigMgr, pollingIotCardStore, appLogger)
pollingBase := task.NewPollingBase(redisClient, pollingQueueMgr, pollingConfigMgr, pollingIotCardStore, appLogger, cfg.Polling.VerboseLog)
// 后台渐进式初始化(将全量卡数据写入分片 Sorted Set
pollingInitializer := polling.NewPollingInitializer(pollingIotCardStore, redisClient, pollingConfigMgr, pollingQueueMgr, appLogger)

View File

@@ -2695,6 +2695,11 @@ components:
card_condition:
description: 卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)
type: string
card_status_check_interval:
description: 卡状态检查间隔NULL表示不检查最小30秒
minimum: 30
nullable: true
type: integer
carddata_check_interval:
description: 流量检查间隔NULL表示不检查最小60秒
minimum: 60
@@ -2724,6 +2729,11 @@ components:
maximum: 1000
minimum: 1
type: integer
protect_check_interval:
description: 保护期一致性检查间隔NULL表示不检查最小30秒
minimum: 30
nullable: true
type: integer
realname_check_interval:
description: 实名检查间隔NULL表示不检查最小30秒
minimum: 30
@@ -5499,6 +5509,10 @@ components:
card_condition:
description: 卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)
type: string
card_status_check_interval:
description: 卡状态检查间隔NULL表示不检查
nullable: true
type: integer
carddata_check_interval:
description: 流量检查间隔NULL表示不检查
nullable: true
@@ -5528,6 +5542,10 @@ components:
priority:
description: 优先级(数字越小优先级越高)
type: integer
protect_check_interval:
description: 保护期一致性检查间隔NULL表示不检查
nullable: true
type: integer
realname_check_interval:
description: 实名检查间隔NULL表示不检查
nullable: true
@@ -7286,6 +7304,11 @@ components:
description: 卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)
nullable: true
type: string
card_status_check_interval:
description: 卡状态检查间隔NULL表示不检查最小30秒
minimum: 30
nullable: true
type: integer
carddata_check_interval:
description: 流量检查间隔NULL表示不检查最小60秒
minimum: 60
@@ -7318,6 +7341,11 @@ components:
minimum: 1
nullable: true
type: integer
protect_check_interval:
description: 保护期一致性检查间隔NULL表示不检查最小30秒
minimum: 30
nullable: true
type: integer
realname_check_interval:
description: 实名检查间隔NULL表示不检查最小30秒
minimum: 30
@@ -9577,7 +9605,7 @@ paths:
- 资产管理
/api/admin/assets/{identifier}/realtime-status:
get:
description: 读取 DB/Redis 中的持久化状态,不调网关
description: 查询资产实时状态。卡类型:读取 DB/Redis 持久化状态;设备类型:实时调用 Gateway sync-info 接口获取最新数据Gateway 失败不阻断主流程DeviceRealtime.gateway_msg 返回失败原因)
parameters:
- description: 资产标识符ICCID 或 VirtualNo
in: path

View File

@@ -2,28 +2,32 @@ package dto
// CreatePollingConfigRequest 创建轮询配置请求
type CreatePollingConfigRequest struct {
ConfigName string `json:"config_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"`
CardCondition string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID可选精确匹配"`
Priority int `json:"priority" validate:"required,min=1,max=1000" required:"true" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=30" minimum:"30" description:"实名检查间隔NULL表示不检查最小30秒"`
CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=60" minimum:"60" description:"流量检查间隔NULL表示不检查最小60秒"`
PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=60" minimum:"60" description:"套餐检查间隔NULL表示不检查最小60秒"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"`
ConfigName string `json:"config_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"`
CardCondition string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID可选精确匹配"`
Priority int `json:"priority" validate:"required,min=1,max=1000" required:"true" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=30" minimum:"30" description:"实名检查间隔NULL表示不检查最小30秒"`
CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=60" minimum:"60" description:"流量检查间隔NULL表示不检查最小60秒"`
PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=60" minimum:"60" description:"套餐检查间隔NULL表示不检查最小60秒"`
ProtectCheckInterval *int `json:"protect_check_interval" validate:"omitempty,min=30" minimum:"30" description:"保护期一致性检查间隔NULL表示不检查最小30秒"`
CardStatusCheckInterval *int `json:"card_status_check_interval" validate:"omitempty,min=30" minimum:"30" description:"卡状态检查间隔NULL表示不检查最小30秒"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"`
}
// UpdatePollingConfigRequest 更新轮询配置请求
type UpdatePollingConfigRequest struct {
ConfigName *string `json:"config_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"`
CardCondition *string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory *string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID可选精确匹配"`
Priority *int `json:"priority" validate:"omitempty,min=1,max=1000" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=30" minimum:"30" description:"实名检查间隔NULL表示不检查最小30秒"`
CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=60" minimum:"60" description:"流量检查间隔NULL表示不检查最小60秒"`
PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=60" minimum:"60" description:"套餐检查间隔NULL表示不检查最小60秒"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"`
ConfigName *string `json:"config_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"`
CardCondition *string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory *string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID可选精确匹配"`
Priority *int `json:"priority" validate:"omitempty,min=1,max=1000" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=30" minimum:"30" description:"实名检查间隔NULL表示不检查最小30秒"`
CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=60" minimum:"60" description:"流量检查间隔NULL表示不检查最小60秒"`
PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=60" minimum:"60" description:"套餐检查间隔NULL表示不检查最小60秒"`
ProtectCheckInterval *int `json:"protect_check_interval" validate:"omitempty,min=30" minimum:"30" description:"保护期一致性检查间隔NULL表示不检查最小30秒"`
CardStatusCheckInterval *int `json:"card_status_check_interval" validate:"omitempty,min=30" minimum:"30" description:"卡状态检查间隔NULL表示不检查最小30秒"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"`
}
// PollingConfigListRequest 轮询配置列表请求
@@ -44,19 +48,21 @@ type UpdatePollingConfigStatusRequest struct {
// PollingConfigResponse 轮询配置响应
type PollingConfigResponse struct {
ID uint `json:"id" description:"配置ID"`
ConfigName string `json:"config_name" description:"配置名称"`
CardCondition string `json:"card_condition" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" description:"运营商ID"`
Priority int `json:"priority" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" description:"实名检查间隔NULL表示不检查"`
CarddataCheckInterval *int `json:"carddata_check_interval" description:"流量检查间隔NULL表示不检查"`
PackageCheckInterval *int `json:"package_check_interval" description:"套餐检查间隔NULL表示不检查"`
Status int16 `json:"status" description:"状态 (1:启用, 0:禁用)"`
Description string `json:"description" description:"配置说明"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
ID uint `json:"id" description:"配置ID"`
ConfigName string `json:"config_name" description:"配置名称"`
CardCondition string `json:"card_condition" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" description:"运营商ID"`
Priority int `json:"priority" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" description:"实名检查间隔NULL表示不检查"`
CarddataCheckInterval *int `json:"carddata_check_interval" description:"流量检查间隔NULL表示不检查"`
PackageCheckInterval *int `json:"package_check_interval" description:"套餐检查间隔NULL表示不检查"`
ProtectCheckInterval *int `json:"protect_check_interval" description:"保护期一致性检查间隔NULL表示不检查"`
CardStatusCheckInterval *int `json:"card_status_check_interval" description:"卡状态检查间隔NULL表示不检查"`
Status int16 `json:"status" description:"状态 (1:启用, 0:禁用)"`
Description string `json:"description" description:"配置说明"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// UpdatePollingConfigParams 更新轮询配置参数

View File

@@ -37,6 +37,7 @@ type IotCard struct {
LastDataCheckAt *time.Time `gorm:"column:last_data_check_at;comment:最后一次流量检查时间" json:"last_data_check_at"`
LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"`
LastProtectCheckAt *time.Time `gorm:"column:last_protect_check_at;comment:上次保护期一致性检查时间" json:"last_protect_check_at"`
LastCardStatusCheckAt *time.Time `gorm:"column:last_card_status_check_at;comment:最后一次卡状态检查时间" json:"last_card_status_check_at"`
LastSyncTime *time.Time `gorm:"column:last_sync_time;comment:最后一次与Gateway同步时间" json:"last_sync_time"`
SeriesID *uint `gorm:"column:series_id;index;comment:套餐系列ID(关联PackageSeries)" json:"series_id,omitempty"`
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的累计充值金额" json:"-"`

View File

@@ -6,22 +6,23 @@ import (
// PollingConfig 轮询配置表
type PollingConfig struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ConfigName string `gorm:"column:config_name;type:varchar(100);not null;comment:配置名称" json:"config_name"`
CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件not_real_name/real_name/activated/suspended" json:"card_condition"`
CardCategory string `gorm:"column:card_category;type:varchar(50);comment:卡业务类型normal/industry" json:"card_category"`
CarrierID *uint `gorm:"column:carrier_id;comment:运营商ID可选精确匹配" json:"carrier_id"`
Priority int `gorm:"column:priority;not null;default:100;comment:优先级(数字越小优先级越高)" json:"priority"`
RealnameCheckInterval *int `gorm:"column:realname_check_interval;comment:实名检查间隔NULL表示不检查" json:"realname_check_interval"`
CarddataCheckInterval *int `gorm:"column:carddata_check_interval;comment:流量检查间隔NULL表示不检查" json:"carddata_check_interval"`
PackageCheckInterval *int `gorm:"column:package_check_interval;comment:套餐检查间隔NULL表示不检查" json:"package_check_interval"`
ProtectCheckInterval *int `gorm:"column:protect_check_interval;comment:保护期一致性检查间隔NULL=不参与" json:"protect_check_interval"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1;comment:状态0-禁用1-启用" json:"status"`
Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"`
CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"`
UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"`
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ConfigName string `gorm:"column:config_name;type:varchar(100);not null;comment:配置名称" json:"config_name"`
CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件not_real_name/real_name/activated/suspended" json:"card_condition"`
CardCategory string `gorm:"column:card_category;type:varchar(50);comment:卡业务类型normal/industry" json:"card_category"`
CarrierID *uint `gorm:"column:carrier_id;comment:运营商ID可选精确匹配" json:"carrier_id"`
Priority int `gorm:"column:priority;not null;default:100;comment:优先级(数字越小优先级越高)" json:"priority"`
RealnameCheckInterval *int `gorm:"column:realname_check_interval;comment:实名检查间隔NULL表示不检查" json:"realname_check_interval"`
CarddataCheckInterval *int `gorm:"column:carddata_check_interval;comment:流量检查间隔NULL表示不检查" json:"carddata_check_interval"`
PackageCheckInterval *int `gorm:"column:package_check_interval;comment:套餐检查间隔NULL表示不检查" json:"package_check_interval"`
ProtectCheckInterval *int `gorm:"column:protect_check_interval;comment:保护期一致性检查间隔NULL=不参与" json:"protect_check_interval"`
CardStatusCheckInterval *int `gorm:"column:card_status_check_interval;comment:状态检查间隔NULL表示不启用卡状态轮询" json:"card_status_check_interval"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1;comment:状态0-禁用1-启用" json:"status"`
Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"`
CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"`
UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"`
}
// TableName 指定表名

View File

@@ -252,6 +252,13 @@ func (p *PollingInitializer) initBatch(ctx context.Context, cards []*model.IotCa
})
cmdCount++
}
if cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastCardStatusCheckAt, *cfg.CardStatusCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingCardStatus), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
cacheKey := constants.RedisPollingCardInfoKey(card.ID)
cacheData := map[string]interface{}{

View File

@@ -162,6 +162,9 @@ func getEnabledTaskTypes(cfg *model.PollingConfig) []string {
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
types = append(types, constants.TaskTypePollingProtect)
}
if cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0 {
types = append(types, constants.TaskTypePollingCardStatus)
}
return types
}
@@ -188,6 +191,10 @@ func calcInitialDelay(_ *model.IotCard, cfg *model.PollingConfig, taskType strin
if cfg.ProtectCheckInterval != nil {
interval = *cfg.ProtectCheckInterval
}
case constants.TaskTypePollingCardStatus:
if cfg.CardStatusCheckInterval != nil {
interval = *cfg.CardStatusCheckInterval
}
}
if interval <= 0 {
return now

View File

@@ -18,6 +18,7 @@ var allTaskTypes = []string{
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
constants.TaskTypePollingProtect,
constants.TaskTypePollingCardStatus,
}
// dequeueScript Lua 脚本原子出队ZRANGEBYSCORE + ZREM 服务端原子执行)
@@ -97,7 +98,7 @@ func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType
}).Err()
}
// RemoveFromAllQueues 从所有分片的所有4个队列(含protect)移除指定卡
// RemoveFromAllQueues 从所有分片的所有5个队列(realname/carddata/package/protect/card_status)移除指定卡
// 修复 Bug3旧实现漏掉 protect 队列
func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error {
member := fmt.Sprintf("%d", cardID)

View File

@@ -39,23 +39,27 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig
}
// 验证检查间隔(至少一个不为空)
if req.RealnameCheckInterval == nil && req.CarddataCheckInterval == nil && req.PackageCheckInterval == nil {
if req.RealnameCheckInterval == nil && req.CarddataCheckInterval == nil &&
req.PackageCheckInterval == nil && req.ProtectCheckInterval == nil &&
req.CardStatusCheckInterval == nil {
return nil, errors.New(errors.CodeInvalidParam, "至少需要配置一种检查间隔")
}
config := &model.PollingConfig{
ConfigName: req.ConfigName,
CardCondition: req.CardCondition,
CardCategory: req.CardCategory,
CarrierID: req.CarrierID,
Priority: req.Priority,
RealnameCheckInterval: req.RealnameCheckInterval,
CarddataCheckInterval: req.CarddataCheckInterval,
PackageCheckInterval: req.PackageCheckInterval,
Status: 1, // 默认启用
Description: req.Description,
CreatedBy: &currentUserID,
UpdatedBy: &currentUserID,
ConfigName: req.ConfigName,
CardCondition: req.CardCondition,
CardCategory: req.CardCategory,
CarrierID: req.CarrierID,
Priority: req.Priority,
RealnameCheckInterval: req.RealnameCheckInterval,
CarddataCheckInterval: req.CarddataCheckInterval,
PackageCheckInterval: req.PackageCheckInterval,
ProtectCheckInterval: req.ProtectCheckInterval,
CardStatusCheckInterval: req.CardStatusCheckInterval,
Status: 1,
Description: req.Description,
CreatedBy: &currentUserID,
UpdatedBy: &currentUserID,
}
if err := s.configStore.Create(ctx, config); err != nil {
@@ -122,6 +126,12 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll
if req.PackageCheckInterval != nil {
config.PackageCheckInterval = req.PackageCheckInterval
}
if req.ProtectCheckInterval != nil {
config.ProtectCheckInterval = req.ProtectCheckInterval
}
if req.CardStatusCheckInterval != nil {
config.CardStatusCheckInterval = req.CardStatusCheckInterval
}
if req.Description != nil {
config.Description = *req.Description
}
@@ -235,18 +245,20 @@ func (s *ConfigService) ListEnabled(ctx context.Context) ([]*dto.PollingConfigRe
// toResponse 转换为响应 DTO
func (s *ConfigService) toResponse(c *model.PollingConfig) *dto.PollingConfigResponse {
return &dto.PollingConfigResponse{
ID: c.ID,
ConfigName: c.ConfigName,
CardCondition: c.CardCondition,
CardCategory: c.CardCategory,
CarrierID: c.CarrierID,
Priority: c.Priority,
RealnameCheckInterval: c.RealnameCheckInterval,
CarddataCheckInterval: c.CarddataCheckInterval,
PackageCheckInterval: c.PackageCheckInterval,
Status: c.Status,
Description: c.Description,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
ID: c.ID,
ConfigName: c.ConfigName,
CardCondition: c.CardCondition,
CardCategory: c.CardCategory,
CarrierID: c.CarrierID,
Priority: c.Priority,
RealnameCheckInterval: c.RealnameCheckInterval,
CarddataCheckInterval: c.CarddataCheckInterval,
PackageCheckInterval: c.PackageCheckInterval,
ProtectCheckInterval: c.ProtectCheckInterval,
CardStatusCheckInterval: c.CardStatusCheckInterval,
Status: c.Status,
Description: c.Description,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
}

View File

@@ -29,13 +29,14 @@ var acquireConcurrencyScript = redis.NewScript(`
`)
// PollingBase 轮询共享基类
// 封装并发控制、卡缓存、重入队、配置间隔查询等公共方法,4 个 Handler 共享
// 封装并发控制、卡缓存、重入队、配置间隔查询等公共方法,所有 Handler 共享
type PollingBase struct {
redis *redis.Client
queueMgr *polling.PollingQueueManager
configMgr *polling.PollingConfigManager
iotCardStore *postgres.IotCardStore
logger *zap.Logger
verboseLog bool
}
// NewPollingBase 创建轮询共享基类
@@ -45,6 +46,7 @@ func NewPollingBase(
configMgr *polling.PollingConfigManager,
iotCardStore *postgres.IotCardStore,
logger *zap.Logger,
verboseLog bool,
) *PollingBase {
return &PollingBase{
redis: redisClient,
@@ -52,6 +54,7 @@ func NewPollingBase(
configMgr: configMgr,
iotCardStore: iotCardStore,
logger: logger,
verboseLog: verboseLog,
}
}

View File

@@ -91,6 +91,15 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, t *asynq.Task) erro
updates, flowIncrementMB, isCrossMonth := h.calculateFlowUpdates(card, gatewayFlowMB, now, resetDay)
updates["last_data_check_at"] = now
if h.base.verboseLog && h.gateway != nil {
h.base.logger.Info("流量轮询详情",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Float64("gateway_flow_mb", gatewayFlowMB),
zap.Float64("increment_mb", flowIncrementMB),
zap.Bool("is_cross_month", isCrossMonth))
}
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, updates); updateErr != nil {
h.base.logger.Error("更新卡流量信息失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
}

View File

@@ -0,0 +1,125 @@
package task
import (
"context"
"time"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
iot_card_svc "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"
)
// PollingCardStatusHandler 卡开停机状态轮询任务处理器
// 职责:调 Gateway 查卡状态(准备/正常/停机)→ 映射为 network_status → 写 DB → 触发停复机评估
// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct
type PollingCardStatusHandler struct {
base *PollingBase
gateway *gateway.Client
iotCardStore *postgres.IotCardStore
stopResumeSvc iot_card_svc.StopResumeServiceInterface
}
// NewPollingCardStatusHandler 创建卡状态轮询任务处理器
func NewPollingCardStatusHandler(
base *PollingBase,
gw *gateway.Client,
iotCardStore *postgres.IotCardStore,
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
) *PollingCardStatusHandler {
return &PollingCardStatusHandler{
base: base,
gateway: gw,
iotCardStore: iotCardStore,
stopResumeSvc: stopResumeSvc,
}
}
// Handle 处理卡状态轮询任务
func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) error {
startTime := time.Now()
cardID, ok := parseTaskPayload(t.Payload(), h.base.logger)
if !ok {
return nil
}
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingCardStatus) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCardStatus)
card, err := h.base.getCardWithCache(ctx, cardID)
if err != nil {
if isNotFound(err) {
return nil
}
h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
var newNetworkStatus int
if h.gateway != nil {
result, gwErr := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if gwErr != nil {
h.base.logger.Warn("查询卡状态失败",
zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(gwErr))
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
// "停机" → 0停机"准备"/"正常" → 1开机
if result.CardStatus == "停机" {
newNetworkStatus = constants.NetworkStatusOffline
} else {
newNetworkStatus = constants.NetworkStatusOnline
}
if h.base.verboseLog {
h.base.logger.Info("卡状态轮询详情",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.String("card_status", result.CardStatus),
zap.Int("new_network_status", newNetworkStatus),
zap.Bool("changed", newNetworkStatus != card.NetworkStatus))
}
} else {
newNetworkStatus = card.NetworkStatus
}
statusChanged := newNetworkStatus != card.NetworkStatus
now := time.Now()
// 无论状态是否变化,都更新最后一次检查时间
fields := map[string]any{"last_card_status_check_at": now}
if statusChanged {
fields["network_status"] = newNetworkStatus
}
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, fields); updateErr != nil {
h.base.logger.Warn("更新卡状态信息失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
}
if statusChanged {
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": newNetworkStatus})
h.base.logger.Info("卡状态已变化",
zap.Uint("card_id", cardID),
zap.Int("old_status", card.NetworkStatus),
zap.Int("new_status", newNetworkStatus))
if h.stopResumeSvc != nil {
freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID)
if loadErr == nil {
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil {
h.base.logger.Warn("卡状态变化后停复机评估失败",
zap.Uint("card_id", cardID), zap.Error(evalErr))
}
}
}
}
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}

View File

@@ -67,6 +67,13 @@ func (h *PollingPackageHandler) Handle(ctx context.Context, t *asynq.Task) error
zap.Uint("card_id", cardID),
zap.Int("network_status", freshCard.NetworkStatus),
zap.String("stop_reason", freshCard.StopReason))
if h.base.verboseLog {
h.base.logger.Info("套餐检查详情",
zap.Uint("card_id", cardID),
zap.String("iccid", freshCard.ICCID),
zap.Int("network_status", freshCard.NetworkStatus),
zap.String("stop_reason", freshCard.StopReason))
}
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil {
h.base.logger.Warn("套餐检查后停复机评估失败",
zap.Uint("card_id", cardID), zap.Error(evalErr))

View File

@@ -85,6 +85,8 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
stopProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0
startProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0
actionTaken := "no_action"
switch {
case stopProtect && card.NetworkStatus == constants.NetworkStatusOnline:
// 保护期内:停机保护期发现开机卡 → 强制停机(绕过 EvaluateAndAct
@@ -107,6 +109,7 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
h.base.logger.Warn("保护期停机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
}
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOffline})
actionTaken = "forced_stop"
case startProtect && card.NetworkStatus == constants.NetworkStatusOffline:
// 保护期内:复机保护期发现停机卡 → 强制复机(绕过 EvaluateAndAct
@@ -129,6 +132,7 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
h.base.logger.Warn("保护期复机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
}
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOnline})
actionTaken = "forced_resume"
case !stopProtect && !startProtect:
// 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件
@@ -138,6 +142,17 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
zap.Uint("card_id", cardID), zap.Error(evalErr))
}
}
actionTaken = "evaluate"
}
if h.base.verboseLog {
h.base.logger.Info("保护期检查详情",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Bool("stop_protect", stopProtect),
zap.Bool("start_protect", startProtect),
zap.Int("network_status_at_check", card.NetworkStatus),
zap.String("action_taken", actionTaken))
}
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, map[string]any{

View File

@@ -72,6 +72,7 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, t *asynq.Task) erro
}
var newStatus int
var realStatusBool bool
if h.gateway != nil {
result, gwErr := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if gwErr != nil {
@@ -80,11 +81,20 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, t *asynq.Task) erro
h.base.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
}
realStatusBool = result.RealStatus
if result.RealStatus {
newStatus = constants.RealNameStatusVerified
} else {
newStatus = constants.RealNameStatusNotVerified
}
if h.base.verboseLog {
h.base.logger.Info("实名状态轮询详情",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Bool("real_status", realStatusBool),
zap.Int("new_status", newStatus),
zap.Bool("changed", newStatus != card.RealNameStatus))
}
} else {
newStatus = card.RealNameStatus
}

View File

@@ -31,6 +31,10 @@ func getIntervalByTaskType(cfg *model.PollingConfig, taskType string) int {
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
return *cfg.ProtectCheckInterval
}
case constants.TaskTypePollingCardStatus:
if cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0 {
return *cfg.CardStatusCheckInterval
}
}
return 0
}

View File

@@ -0,0 +1,5 @@
ALTER TABLE tb_polling_config
DROP COLUMN IF EXISTS card_status_check_interval;
ALTER TABLE tb_iot_card
DROP COLUMN IF EXISTS last_card_status_check_at;

View File

@@ -0,0 +1,13 @@
-- 新增卡状态轮询相关列
-- tb_polling_config 新增 card_status_check_interval配置卡状态检查间隔NULL 表示不启用
-- tb_iot_card 新增 last_card_status_check_at记录最后一次卡状态检查时间与其他 last_*_check_at 字段保持一致
ALTER TABLE tb_polling_config
ADD COLUMN IF NOT EXISTS card_status_check_interval INT NULL;
COMMENT ON COLUMN tb_polling_config.card_status_check_interval IS '卡状态检查间隔NULL 表示不启用卡状态轮询';
ALTER TABLE tb_iot_card
ADD COLUMN IF NOT EXISTS last_card_status_check_at TIMESTAMPTZ NULL;
COMMENT ON COLUMN tb_iot_card.last_card_status_check_at IS '最后一次卡状态检查时间';

View File

@@ -1,57 +1,57 @@
## 1. 数据库迁移
- [ ] 1.1 创建迁移文件 `000120_add_polling_card_status.up.sql``tb_polling_config` 新增 `card_status_check_interval INT NULL``tb_iot_card` 新增 `last_card_status_check_at TIMESTAMPTZ NULL`
- [ ] 1.2 创建对应回滚文件 `000120_add_polling_card_status.down.sql`DROP COLUMN 两列
- [ ] 1.3 执行 `make migrate-up` 并验证:`make migrate-version` 确认 dirty=false使用 PostgreSQL MCP 验证两列已创建
- [x] 1.1 创建迁移文件 `000120_add_polling_card_status.up.sql``tb_polling_config` 新增 `card_status_check_interval INT NULL``tb_iot_card` 新增 `last_card_status_check_at TIMESTAMPTZ NULL`
- [x] 1.2 创建对应回滚文件 `000120_add_polling_card_status.down.sql`DROP COLUMN 两列
- [x] 1.3 执行 `make migrate-up` 并验证:`make migrate-version` 确认 dirty=false使用 PostgreSQL MCP 验证两列已创建
## 2. 常量与配置
- [ ] 2.1 `pkg/constants/constants.go`:轮询任务类型常量块新增 `TaskTypePollingCardStatus = "polling:card_status"`
- [ ] 2.2 `pkg/config/config.go`:新增 `PollingConfig struct { VerboseLog bool }`,在 `Config` 中加入 `Polling PollingConfig` 字段
- [ ] 2.3 `pkg/config/defaults/config.yaml`:在 polling 配置区新增 `verbose_log: false`
- [ ] 2.4 验证:`LspDiagnostics` 检查 `pkg/constants/constants.go``pkg/config/config.go` 无报错
- [x] 2.1 `pkg/constants/constants.go`:轮询任务类型常量块新增 `TaskTypePollingCardStatus = "polling:card_status"`
- [x] 2.2 `pkg/config/config.go`:新增 `PollingConfig struct { VerboseLog bool }`,在 `Config` 中加入 `Polling PollingConfig` 字段
- [x] 2.3 `pkg/config/defaults/config.yaml`:在 polling 配置区新增 `verbose_log: false`
- [x] 2.4 验证:`LspDiagnostics` 检查 `pkg/constants/constants.go``pkg/config/config.go` 无报错
## 3. 数据模型更新
- [ ] 3.1 `internal/model/polling.go``PollingConfig` 结构体新增 `CardStatusCheckInterval *int`GORM column: `card_status_check_interval`
- [ ] 3.2 `internal/model/iot_card.go``IotCard` 结构体新增 `LastCardStatusCheckAt *time.Time`GORM column: `last_card_status_check_at`
- [ ] 3.3 验证:`LspDiagnostics` 检查两个 model 文件无报错
- [x] 3.1 `internal/model/polling.go``PollingConfig` 结构体新增 `CardStatusCheckInterval *int`GORM column: `card_status_check_interval`
- [x] 3.2 `internal/model/iot_card.go``IotCard` 结构体新增 `LastCardStatusCheckAt *time.Time`GORM column: `last_card_status_check_at`
- [x] 3.3 验证:`LspDiagnostics` 检查两个 model 文件无报错
## 4. PollingBase 新增 verboseLog 支持
- [ ] 4.1 `internal/task/polling_base.go``PollingBase` 结构体新增 `verboseLog bool` 字段;`NewPollingBase` 函数签名新增 `verboseLog bool` 参数并赋值
- [ ] 4.2 `cmd/worker/main.go``task.NewPollingBase(...)` 调用新增 `cfg.Polling.VerboseLog` 参数
- [ ] 4.3 验证:`LspDiagnostics` 检查 `polling_base.go``main.go` 无报错
- [x] 4.1 `internal/task/polling_base.go``PollingBase` 结构体新增 `verboseLog bool` 字段;`NewPollingBase` 函数签名新增 `verboseLog bool` 参数并赋值
- [x] 4.2 `cmd/worker/main.go``task.NewPollingBase(...)` 调用新增 `cfg.Polling.VerboseLog` 参数
- [x] 4.3 验证:`LspDiagnostics` 检查 `polling_base.go``main.go` 无报错
## 5. 现有 Handler 添加 verbose 日志
- [ ] 5.1 `internal/task/polling_realname_handler.go`:在 `QueryRealnameStatus` 成功返回后,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、real_status、new_status、changed
- [ ] 5.2 `internal/task/polling_carddata_handler.go`:在 `QueryFlow` 成功返回后,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、gateway_flow_mb、increment_mb、is_cross_month
- [ ] 5.3 `internal/task/polling_package_handler.go`:在 `freshCard` 加载成功后、调用 `EvaluateAndAct` 之前,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、network_status、stop_reason
- [ ] 5.4 `internal/task/polling_protect_handler.go`:在 switch 块执行完毕、`UpdateFields(last_protect_check_at)` 之前,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、stop_protect、start_protect、network_status
- [ ] 5.5 验证:`LspDiagnostics` 检查上述 4 个 handler 文件无报错
- [x] 5.1 `internal/task/polling_realname_handler.go`:在 `QueryRealnameStatus` 成功返回后,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、real_status、new_status、changed
- [x] 5.2 `internal/task/polling_carddata_handler.go`:在 `QueryFlow` 成功返回后,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、gateway_flow_mb、increment_mb、is_cross_month
- [x] 5.3 `internal/task/polling_package_handler.go`:在 `freshCard` 加载成功后、调用 `EvaluateAndAct` 之前,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、network_status、stop_reason
- [x] 5.4 `internal/task/polling_protect_handler.go`:在 switch 块执行完毕、`UpdateFields(last_protect_check_at)` 之前,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、stop_protect、start_protect、network_status
- [x] 5.5 验证:`LspDiagnostics` 检查上述 4 个 handler 文件无报错
## 6. 卡状态轮询 Handler 实现
- [ ] 6.1 新建 `internal/task/polling_cardstatus_handler.go`:实现 `PollingCardStatusHandler`字段base、gateway、iotCardStore、stopResumeSvc完整实现 `Handle` 方法
- [ ] 6.2 `Handle` 方法逻辑:获取并发信号量 → 获取卡信息(带缓存)→ 调用 `gateway.QueryCardStatus` → 映射状态("停机"→0其他→1→ verbose log 判断 → 比较状态是否变化 → 写 DB`last_card_status_check_at` + 可选 `network_status`)→ 状态变化时更新缓存并调用 `EvaluateAndAct` → 更新统计 → 重入队
- [ ] 6.3 验证:`LspDiagnostics` 检查新文件无报错
- [x] 6.1 新建 `internal/task/polling_cardstatus_handler.go`:实现 `PollingCardStatusHandler`字段base、gateway、iotCardStore、stopResumeSvc完整实现 `Handle` 方法
- [x] 6.2 `Handle` 方法逻辑:获取并发信号量 → 获取卡信息(带缓存)→ 调用 `gateway.QueryCardStatus` → 映射状态("停机"→0其他→1→ verbose log 判断 → 比较状态是否变化 → 写 DB`last_card_status_check_at` + 可选 `network_status`)→ 状态变化时更新缓存并调用 `EvaluateAndAct` → 更新统计 → 重入队
- [x] 6.3 验证:`LspDiagnostics` 检查新文件无报错
## 7. 轮询系统基础设施集成
- [ ] 7.1 `internal/task/polling_utils.go``getIntervalByTaskType` 新增 `case constants.TaskTypePollingCardStatus`,返回 `cfg.CardStatusCheckInterval`
- [ ] 7.2 `internal/polling/queue_manager.go``allTaskTypes` slice 追加 `constants.TaskTypePollingCardStatus`
- [ ] 7.3 `internal/polling/lifecycle_service.go``getEnabledTaskTypes` 新增 card_status 条件(`CardStatusCheckInterval != nil && *CardStatusCheckInterval > 0``calcInitialDelay` 新增对应 case
- [ ] 7.4 `internal/polling/initializer.go`:在 `initBatch()` 的 if-block 链中新增一段,当 `cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0` 时,以 `card.LastCardStatusCheckAt`(新增字段)为基准调用 `calculateNextCheckTime`,向 `TaskTypePollingCardStatus` 分片队列写入 ZAdd 条目;位置紧跟 ProtectCheckInterval 块之后,格式与其他 4 种类型一致
- [ ] 7.5 验证:`LspDiagnostics` 检查上述 4 个文件无报错
- [x] 7.1 `internal/task/polling_utils.go``getIntervalByTaskType` 新增 `case constants.TaskTypePollingCardStatus`,返回 `cfg.CardStatusCheckInterval`
- [x] 7.2 `internal/polling/queue_manager.go``allTaskTypes` slice 追加 `constants.TaskTypePollingCardStatus`
- [x] 7.3 `internal/polling/lifecycle_service.go``getEnabledTaskTypes` 新增 card_status 条件(`CardStatusCheckInterval != nil && *CardStatusCheckInterval > 0``calcInitialDelay` 新增对应 case
- [x] 7.4 `internal/polling/initializer.go`:在 `initBatch()` 的 if-block 链中新增一段,当 `cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0` 时,以 `card.LastCardStatusCheckAt`(新增字段)为基准调用 `calculateNextCheckTime`,向 `TaskTypePollingCardStatus` 分片队列写入 ZAdd 条目;位置紧跟 ProtectCheckInterval 块之后,格式与其他 4 种类型一致
- [x] 7.5 验证:`LspDiagnostics` 检查上述 4 个文件无报错
## 8. Worker Handler 注册
- [ ] 8.1 `pkg/queue/handler.go`:在 `RegisterHandlers` 中创建 `cardStatusHandler := task.NewPollingCardStatusHandler(...)` 并注册 `h.mux.HandleFunc(constants.TaskTypePollingCardStatus, cardStatusHandler.Handle)`
- [ ] 8.2 验证:`LspDiagnostics` 检查 `handler.go` 无报错;`go build ./cmd/worker/...` 成功编译
- [x] 8.1 `pkg/queue/handler.go`:在 `RegisterHandlers` 中创建 `cardStatusHandler := task.NewPollingCardStatusHandler(...)` 并注册 `h.mux.HandleFunc(constants.TaskTypePollingCardStatus, cardStatusHandler.Handle)`
- [x] 8.2 验证:`LspDiagnostics` 检查 `handler.go` 无报错;`go build ./cmd/worker/...` 成功编译
## 9. 整体验证
- [ ] 9.1 执行 `go build ./...` 确认整个项目编译通过
- [ ] 9.2 使用 PostgreSQL MCP 验证迁移列已正确创建类型、NULL 约束)
- [ ] 9.3 检查 `make migrate-down && make migrate-up` 回滚和重新迁移均成功
- [x] 9.1 执行 `go build ./...` 确认整个项目编译通过
- [x] 9.2 使用 PostgreSQL MCP 验证迁移列已正确创建类型、NULL 约束)
- [x] 9.3 检查 `make migrate-down && make migrate-up` 回滚和重新迁移均成功

View File

@@ -0,0 +1,75 @@
### Requirement: 注册卡状态轮询任务类型常量
系统 SHALL 在 `pkg/constants/constants.go` 中定义常量 `TaskTypePollingCardStatus = "polling:card_status"`,与其他轮询类型常量在同一常量块内。
#### Scenario: 常量可被引用
- **WHEN** 其他包引用 `constants.TaskTypePollingCardStatus`
- **THEN** 编译通过,值为 `"polling:card_status"`
---
### Requirement: 轮询配置支持卡状态检查间隔
`PollingConfig` model`tb_polling_config`SHALL 包含 `CardStatusCheckInterval *int` 字段GORM column: `card_status_check_interval`NULL 表示该配置不启用卡状态轮询。
#### Scenario: 配置间隔为正整数时卡状态轮询启用
- **WHEN** `PollingConfig.CardStatusCheckInterval` 不为 NULL 且值 > 0
- **THEN** `getEnabledTaskTypes` 返回的列表中包含 `TaskTypePollingCardStatus`
#### Scenario: 配置间隔为 NULL 时卡状态轮询不启用
- **WHEN** `PollingConfig.CardStatusCheckInterval` 为 NULL
- **THEN** `getEnabledTaskTypes` 返回的列表中不包含 `TaskTypePollingCardStatus`
---
### Requirement: IotCard 记录最后一次卡状态检查时间
`IotCard` model`tb_iot_card`SHALL 包含 `LastCardStatusCheckAt *time.Time` 字段GORM column: `last_card_status_check_at`),每次卡状态轮询成功执行后更新。
#### Scenario: 每次执行卡状态轮询后更新时间戳
- **WHEN** `PollingCardStatusHandler.Handle` 成功执行(无论状态是否变化)
- **THEN** `tb_iot_card.last_card_status_check_at` 更新为当前时间
---
### Requirement: PollingCardStatusHandler 查询 Gateway 卡状态并同步 DB
系统 SHALL 实现 `PollingCardStatusHandler`,遵循与 `PollingRealnameHandler` 相同的结构:获取并发信号量 → 从缓存/DB 获取卡信息 → 调用 `gateway.QueryCardStatus` → 解析状态 → 更新 DB → 触发停复机评估 → 更新统计 → 重入队。
#### Scenario: Gateway 返回"正常"时卡 network_status 为 1
- **WHEN** `gateway.QueryCardStatus` 返回 `CardStatus = "正常"``"准备"`
- **THEN** `newNetworkStatus = 1``NetworkStatusOnline`
#### Scenario: Gateway 返回"停机"时卡 network_status 为 0
- **WHEN** `gateway.QueryCardStatus` 返回 `CardStatus = "停机"`
- **THEN** `newNetworkStatus = 0``NetworkStatusOffline`
#### Scenario: 状态未变化时不触发停复机评估
- **WHEN** Gateway 返回的 `newNetworkStatus``card.NetworkStatus` 相同
- **THEN** 不调用 `stopResumeSvc.EvaluateAndAct`
#### Scenario: 状态发生变化时更新 DB + 缓存 + 触发评估
- **WHEN** Gateway 返回的 `newNetworkStatus``card.NetworkStatus` 不同
- **THEN** `tb_iot_card.network_status` 更新为新值Redis 缓存同步更新,调用 `stopResumeSvc.EvaluateAndAct`
#### Scenario: Gateway 查询失败时重入队不更新状态
- **WHEN** `gateway.QueryCardStatus` 返回 error
- **THEN** 记录 Warn 日志,更新失败统计,重入队后返回,不修改 DB
#### Scenario: 并发已满时重新入队
- **WHEN** `acquireConcurrency` 返回 false
- **THEN** 调用 `requeueCard` 后返回,不执行 Gateway 查询
---
### Requirement: 卡状态轮询类型纳入队列管理
`allTaskTypes``queue_manager.go`SHALL 包含 `TaskTypePollingCardStatus`,使得 `RemoveFromAllQueues`、调度器出队、初始化器均覆盖卡状态队列。
#### Scenario: 卡删除时清理卡状态队列
- **WHEN** 调用 `RemoveFromAllQueues(ctx, cardID)`
- **THEN** 卡 ID 从 `polling:card_status` 的所有分片队列中移除
---
### Requirement: 初始化器写入卡状态队列
`PollingInitializer` SHALL 在批量初始化时,为每张卡的 `polling:card_status` 分片 Sorted Set 写入初始条目(与 realname/carddata/package/protect 保持一致)。
#### Scenario: Worker 启动时卡状态队列被初始化
- **WHEN** `PollingInitializer.StartBackground` 完成批量加载
- **THEN** 每张启用轮询且匹配配置的卡在 `polling:card_status` 对应分片队列中有条目

View File

@@ -265,3 +265,85 @@ func NewPollingProtectHandler(
- **GIVEN** 卡 `is_standalone=true`(未绑定设备)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到独立卡,直接 requeue设备保护期与独立卡无关
---
### Requirement: PollingBase 支持 verboseLog 标志
`PollingBase` 结构体 SHALL 新增 `verboseLog bool` 字段,`NewPollingBase` 函数 SHALL 接受 `verboseLog bool` 参数并赋值。`cmd/worker/main.go` SHALL 从 `cfg.Polling.VerboseLog` 读取并传入。
#### Scenario: verboseLog 为 true 时详细日志启用
- **WHEN** `cfg.Polling.VerboseLog = true` 且 Worker 启动
- **THEN** `PollingBase.verboseLog = true`,所有 handler 在成功查询上游时输出 Info 日志
#### Scenario: verboseLog 为 false 时详细日志不输出
- **WHEN** `cfg.Polling.VerboseLog = false`(默认)
- **THEN** 成功查询上游时不输出额外日志,行为与修改前相同
---
### Requirement: 配置文件包含 polling.verbose_log 字段
`pkg/config/config.go` SHALL 新增 `PollingConfig struct``VerboseLog bool`,并加入 `Config` 结构体。`pkg/config/defaults/config.yaml` SHALL 包含 `polling.verbose_log: false`
#### Scenario: 默认配置 verbose_log 为 false
- **WHEN** 未设置 `JUNHONG_POLLING_VERBOSE_LOG` 环境变量
- **THEN** `cfg.Polling.VerboseLog = false`
#### Scenario: 环境变量覆盖开启 verbose_log
- **WHEN** 设置 `JUNHONG_POLLING_VERBOSE_LOG=true`
- **THEN** `cfg.Polling.VerboseLog = true`
---
### Requirement: realname handler 成功查询后输出 verbose 日志
`PollingRealnameHandler.Handle` SHALL 在 `gateway.QueryRealnameStatus` 成功返回后,当 `h.base.verboseLog` 为 true 时,以 `Info` 级别记录日志,包含:`card_id``iccid``real_status`bool`new_status`int`changed`bool
#### Scenario: verboseLog 开启时实名查询成功输出日志
- **WHEN** `verboseLog = true``QueryRealnameStatus` 成功返回
- **THEN** 输出包含 iccid + real_status + new_status + changed 的 Info 日志
#### Scenario: verboseLog 关闭时实名查询成功不输出额外日志
- **WHEN** `verboseLog = false``QueryRealnameStatus` 成功返回(状态未变)
- **THEN** 不输出 verbose 日志
---
### Requirement: carddata handler 成功查询后输出 verbose 日志
`PollingCarddataHandler.Handle` SHALL 在 `gateway.QueryFlow` 成功返回后,当 `h.base.verboseLog` 为 true 时,以 `Info` 级别记录日志,包含:`card_id``iccid``gateway_flow_mb`float64`increment_mb`float64`is_cross_month`bool
#### Scenario: verboseLog 开启时流量查询成功输出日志
- **WHEN** `verboseLog = true``QueryFlow` 成功返回
- **THEN** 输出包含 iccid + gateway_flow_mb + increment_mb + is_cross_month 的 Info 日志
#### Scenario: verboseLog 关闭时流量查询成功不输出额外日志
- **WHEN** `verboseLog = false``QueryFlow` 成功返回(流量无增量)
- **THEN** 不输出 verbose 日志
---
### Requirement: package handler 成功加载卡信息后输出 verbose 日志
`PollingPackageHandler.Handle` SHALL 在 `GetByID` 成功返回 `freshCard` 后、调用 `EvaluateAndAct` 之前,当 `h.base.verboseLog` 为 true 时,以 `Info` 级别记录日志,包含:`card_id``iccid``network_status`int`stop_reason`string
> package handler 不查询 Gatewayverbose log 记录的是本次评估时读取到的本地卡状态,便于排查"停复机评估为何结果不符预期"类问题。
#### Scenario: verboseLog 开启时套餐检查加载卡成功输出日志
- **WHEN** `verboseLog = true``iotCardStore.GetByID` 成功返回 `freshCard`
- **THEN** 输出包含 iccid + network_status + stop_reason 的 Info 日志
#### Scenario: verboseLog 关闭时套餐检查不输出额外日志
- **WHEN** `verboseLog = false`
- **THEN** 不输出 verbose 日志(现有 "套餐检查:执行停复机评估" Info 日志保持不变)
---
### Requirement: protect handler 成功执行保护期检查后输出 verbose 日志
`PollingProtectHandler.Handle` SHALL 在 switch 块执行完毕、调用 `UpdateFields(last_protect_check_at)` 之前,当 `h.base.verboseLog` 为 true 时,以 `Info` 级别记录日志,包含:`card_id``iccid``stop_protect`bool`start_protect`bool`network_status_at_check`int`action_taken`string
> 注:该日志在 switch 的所有分支强制停机、强制复机、EvaluateAndAct、无操作均成功执行后输出若某分支因 Gateway 失败提前 return则不输出已有 Error 日志覆盖)。`network_status_at_check` 反映检查时的卡状态(触发动作的原因),`action_taken` 反映实际执行了什么forced_stop / forced_resume / evaluate / no_action
#### Scenario: verboseLog 开启时保护期检查成功输出日志
- **WHEN** `verboseLog = true` 且 switch 块各分支均执行成功(未提前 return
- **THEN** 输出包含 iccid + stop_protect + start_protect + network_status_at_check + action_taken 的 Info 日志
#### Scenario: verboseLog 关闭时保护期检查不输出额外日志
- **WHEN** `verboseLog = false`
- **THEN** 不输出 verbose 日志

View File

@@ -25,6 +25,7 @@ type Config struct {
Storage StorageConfig `mapstructure:"storage"`
Gateway GatewayConfig `mapstructure:"gateway"`
PollingAutoTrigger PollingAutoTriggerConfig `mapstructure:"polling_auto_trigger"`
Polling PollingConfig `mapstructure:"polling"`
}
// ServerConfig HTTP 服务器配置
@@ -140,6 +141,13 @@ type GatewayConfig struct {
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
}
// PollingConfig 轮询通用配置
type PollingConfig struct {
// VerboseLog 开启后,所有轮询 handler 在成功获取上游数据时以 Info 级别输出详细日志
// 环境变量JUNHONG_POLLING_VERBOSE_LOG
VerboseLog bool `mapstructure:"verbose_log"`
}
// PollingAutoTriggerConfig 轮询自动触发配置
type PollingAutoTriggerConfig struct {
// EnableAutoTrigger 是否启用C端实名自动触发

View File

@@ -116,6 +116,10 @@ gateway:
app_secret: "BZeQttaZQt0i73moF"
timeout: 30
# 轮询配置
polling:
verbose_log: false # 是否开启详细日志环境变量JUNHONG_POLLING_VERBOSE_LOG
# 轮询自动触发配置
polling_auto_trigger:
enable_auto_trigger: true

View File

@@ -51,10 +51,11 @@ const (
TaskTypeCommissionStatsArchive = "commission:stats:archive" // 佣金统计归档
// 轮询任务类型
TaskTypePollingRealname = "polling:realname" // 实名状态检查
TaskTypePollingCarddata = "polling:carddata" // 卡流量检查
TaskTypePollingPackage = "polling:package" // 套餐流量检查
TaskTypePollingProtect = "polling:protect" // 保护期一致性检查
TaskTypePollingRealname = "polling:realname" // 实名状态检查
TaskTypePollingCarddata = "polling:carddata" // 卡流量检查
TaskTypePollingPackage = "polling:package" // 套餐流量检查
TaskTypePollingProtect = "polling:protect" // 保护期一致性检查
TaskTypePollingCardStatus = "polling:card_status" // 卡开停机状态检查
// 套餐激活任务类型
TaskTypePackageFirstActivation = "package:first:activation" // 首次实名激活

View File

@@ -167,12 +167,15 @@ func (h *Handler) registerPollingHandlers() {
protectHandler := task.NewPollingProtectHandler(
h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard,
h.workerResult.Stores.DeviceSimBinding, h.pollingStopResumeSvc)
cardStatusHandler := task.NewPollingCardStatusHandler(
h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard, h.pollingStopResumeSvc)
h.mux.HandleFunc(constants.TaskTypePollingRealname, realnameHandler.Handle)
h.mux.HandleFunc(constants.TaskTypePollingCarddata, carddataHandler.Handle)
h.mux.HandleFunc(constants.TaskTypePollingPackage, packageHandler.Handle)
h.mux.HandleFunc(constants.TaskTypePollingProtect, protectHandler.Handle)
h.logger.Info("已注册轮询任务处理器realname/carddata/package/protect")
h.mux.HandleFunc(constants.TaskTypePollingCardStatus, cardStatusHandler.Handle)
h.logger.Info("已注册轮询任务处理器realname/carddata/package/protect/card_status")
}
func (h *Handler) registerPackageActivationHandlers() {