feat: 下线设备保护期
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s

- 后台设备停复机不再设置 1 小时保护期,也不再因保护期拒绝相反操作;单卡手动停复机去掉保护期检查
- 删除保护期一致性轮询(polling:protect)及其强制停复机方法;调度、初始化、监控、匹配预览不再包含保护期;已入队的旧任务由空处理函数直接确认,下一次发布移除
- 轮询配置接口不再接受与返回 protect_check_interval;并发配置不再返回 protect,查询、更新、重置该类型时拒绝
- 资产详情 device_protect_status 固定返回 none 并标记废弃,兼容老版本客户端
- 删除保护期 Redis key 与时长常量;停机原因 protect_period 保留供历史数据与开放接口映射
- 迁移 000249 只把 protect_check_interval、last_protect_check_at 列注释标记为已废弃,不删列不改数据
This commit is contained in:
邹进
2026-10-25 17:19:10 +09:00
parent 4de67f6dc3
commit 79b7580554
32 changed files with 88 additions and 411 deletions

View File

@@ -48,9 +48,9 @@ type pollingPriorityTriggerKey struct{}
// WithPollingPriorityTrigger 标记本次业务评估来自普通套餐轮询。
//
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、保护期轮询、
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、
// 既有兼容入口等多条调用链复用;只有普通套餐轮询来源才允许追加优先轮询请求,
// 否则会在观测序列、手动实名、保护期与退款等上下文凭空产生加急触发。
// 否则会在观测序列、手动实名与退款等上下文凭空产生加急触发。
func WithPollingPriorityTrigger(ctx context.Context) context.Context {
return context.WithValue(ctx, pollingPriorityTriggerKey{}, true)
}

View File

@@ -271,7 +271,7 @@ func initServices(s *stores, deps *Dependencies) *services {
if deps.GatewayLimit != nil {
stopResumeService.SetStopResumeRecorder(deps.GatewayLimit.Recorder)
}
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)

View File

@@ -195,7 +195,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
if deps.GatewayLimit != nil {
stopResumeService.SetStopResumeRecorder(deps.GatewayLimit.Recorder)
}
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。
carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService))

View File

@@ -157,7 +157,6 @@ func (h *PollingManualTriggerHandler) GetStatus(c *fiber.Ctx) error {
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
constants.TaskTypePollingProtect,
} {
size, _ := h.service.GetQueueSize(ctx, taskType)
queueSizes[taskType] = size
@@ -272,6 +271,7 @@ func (h *PollingManualTriggerHandler) getTaskTypeName(taskType string) string {
case constants.TaskTypePollingPackage:
return "套餐检查"
case constants.TaskTypePollingProtect:
// 保护期已下线,保留名称供历史手动触发记录展示。
return "保护期检查"
default:
return taskType

View File

@@ -35,7 +35,7 @@ type AssetResolveResponse struct {
VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"`
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"`
EnableVirtualData bool `json:"enable_virtual_data" description:"当前主套餐是否启用虚流量(按套餐使用记录快照返回)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态:none/stop/start(仅asset_type=device时有效)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留(仅asset_type=device时有效)"`
// 绑定关系字段
AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"`
ICCID string `json:"iccid,omitempty" description:"卡ICCID(asset_type=card时有效)"`
@@ -141,7 +141,7 @@ type AssetRealtimeStatusResponse struct {
LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后一次流量检查时间(asset_type=card时有效)"`
LastRealNameCheckAt *time.Time `json:"last_real_name_check_at" description:"最后一次实名检查时间(asset_type=card时有效)"`
LastCardStatusCheckAt *time.Time `json:"last_card_status_check_at" description:"最后一次卡状态检查时间(asset_type=card时有效)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"保护期状态(asset_type=device时有效):none/stop/start"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留(asset_type=device时有效)"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info,始终返回,失败时仅含 gateway_msg)"`
}

View File

@@ -72,7 +72,7 @@ type AssetInfoResponse struct {
MaxSimSlots int `json:"max_sim_slots,omitempty" description:"最大插槽数"`
BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定卡数量"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡列表(含每张卡的ICCID/MSISDN/网络状态/实名状态/插槽位置)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态(none:无, stop:停机保护, start:开机保护)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留"`
// === 卡专属字段(asset_type=card 时有效) ===
ICCID string `json:"iccid,omitempty" description:"卡ICCID"`

View File

@@ -10,7 +10,6 @@ type CreatePollingConfigRequest struct {
RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=0" minimum:"0" description:"实名检查间隔(秒):正数按间隔(最小30秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"`
CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=0" minimum:"0" description:"流量检查间隔(秒):正数按间隔(最小60秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"`
PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=0" minimum:"0" description:"套餐检查间隔(秒):正数按间隔(最小60秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"`
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=0" minimum:"0" description:"卡状态检查间隔(秒):正数按间隔(最小30秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"`
PackageState *string `json:"package_state" validate:"omitempty,oneof=active depleted pending none" enum:"active,depleted,pending,none" description:"套餐状态匹配 (active:有生效套餐, depleted:套餐已用完, pending:只有待生效套餐, none:无套餐),为空不限"`
AssetStatus *int `json:"asset_status" validate:"omitempty,oneof=1 2 3 4" enum:"1,2,3,4" description:"资产状态匹配 (1:在库, 2:已销售, 3:已换货, 4:已停用),为空不限"`
@@ -29,7 +28,6 @@ type UpdatePollingConfigRequest struct {
RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=0" minimum:"0" description:"实名检查间隔(秒):正数按间隔(最小30秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"`
CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=0" minimum:"0" description:"流量检查间隔(秒):正数按间隔(最小60秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"`
PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=0" minimum:"0" description:"套餐检查间隔(秒):正数按间隔(最小60秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"`
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=0" minimum:"0" description:"卡状态检查间隔(秒):正数按间隔(最小30秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"`
PackageState *string `json:"package_state" validate:"omitempty,oneof=active depleted pending none" enum:"active,depleted,pending,none" description:"套餐状态匹配 (active:有生效套餐, depleted:套餐已用完, pending:只有待生效套餐, none:无套餐),为空不限"`
AssetStatus *int `json:"asset_status" validate:"omitempty,oneof=1 2 3 4" enum:"1,2,3,4" description:"资产状态匹配 (1:在库, 2:已销售, 3:已换货, 4:已停用),为空不限"`
@@ -66,7 +64,6 @@ type PollingConfigResponse struct {
RealnameCheckInterval *int `json:"realname_check_interval" description:"实名检查间隔(秒):正数按间隔,0 不检查,空继承"`
CarddataCheckInterval *int `json:"carddata_check_interval" description:"流量检查间隔(秒):正数按间隔,0 不检查,空继承"`
PackageCheckInterval *int `json:"package_check_interval" description:"套餐检查间隔(秒):正数按间隔,0 不检查,空继承"`
ProtectCheckInterval *int `json:"protect_check_interval" description:"保护期一致性检查间隔(秒),NULL表示不检查"`
CardStatusCheckInterval *int `json:"card_status_check_interval" description:"卡状态检查间隔(秒):正数按间隔,0 不检查,空继承"`
PackageState *string `json:"package_state" description:"套餐状态匹配 (active:有生效套餐, depleted:套餐已用完, pending:只有待生效套餐, none:无套餐),为空不限"`
AssetStatus *int `json:"asset_status" description:"资产状态匹配 (1:在库, 2:已销售, 3:已换货, 4:已停用),为空不限"`
@@ -117,7 +114,6 @@ type PollingConfigOverride struct {
RealnameCheckInterval *int `json:"realname_check_interval" minimum:"0" description:"实名检查间隔(秒),正数按间隔、0 不检查、空继承"`
CarddataCheckInterval *int `json:"carddata_check_interval" minimum:"0" description:"流量检查间隔(秒),正数按间隔、0 不检查、空继承"`
PackageCheckInterval *int `json:"package_check_interval" minimum:"0" description:"套餐检查间隔(秒),正数按间隔、0 不检查、空继承"`
ProtectCheckInterval *int `json:"protect_check_interval" minimum:"0" description:"保护期检查间隔(秒),正数按间隔、0 不检查、空继承"`
CardStatusCheckInterval *int `json:"card_status_check_interval" minimum:"0" description:"卡状态检查间隔(秒),正数按间隔、0 不检查、空继承"`
PackageState *string `json:"package_state" enum:"active,depleted,pending,none" description:"套餐状态匹配 (active:有生效套餐, depleted:套餐已用完, pending:只有待生效套餐, none:无套餐),为空不限"`
AssetStatus *int `json:"asset_status" enum:"1,2,3,4" description:"资产状态匹配 (1:在库, 2:已销售, 3:已换货, 4:已停用),为空不限"`
@@ -159,7 +155,7 @@ type PollingConfigBrief struct {
// PollingIntervalPreview 某检查类型最终采用的间隔与来源
type PollingIntervalPreview struct {
TaskType string `json:"task_type" description:"检查类型 (polling:realname:实名, polling:carddata:流量, polling:package:套餐, polling:card_status:卡状态, polling:protect:保护期)"`
TaskType string `json:"task_type" description:"检查类型 (polling:realname:实名, polling:carddata:流量, polling:package:套餐, polling:card_status:卡状态)"`
TaskTypeName string `json:"task_type_name" description:"检查类型名称"`
Mode string `json:"mode" description:"结果 (interval:按间隔检查, disabled:不检查, unmatched:没有任何配置给出取值,不检查)"`
Interval int `json:"interval" description:"间隔(秒),仅 mode=interval 时有值"`

View File

@@ -38,7 +38,6 @@ type IotCard struct {
LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"`
RealnameReversalCount int `gorm:"column:realname_reversal_count;type:int;default:0;not null;comment:实名逆转连续观测次数" json:"realname_reversal_count"`
RealnameReversalStartedAt *time.Time `gorm:"column:realname_reversal_started_at;comment:实名逆转当前确认窗口开始时间" json:"realname_reversal_started_at,omitempty"`
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"`

View File

@@ -17,7 +17,6 @@ type PollingConfig struct {
RealnameCheckInterval *int `gorm:"column:realname_check_interval;comment:实名检查间隔(秒):正数按间隔,0不检查(停止继承),NULL继承" json:"realname_check_interval"`
CarddataCheckInterval *int `gorm:"column:carddata_check_interval;comment:流量检查间隔(秒):正数按间隔,0不检查(停止继承),NULL继承" json:"carddata_check_interval"`
PackageCheckInterval *int `gorm:"column:package_check_interval;comment:套餐检查间隔(秒):正数按间隔,0不检查(停止继承),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:卡状态检查间隔(秒):正数按间隔,0不检查(停止继承),NULL继承" json:"card_status_check_interval"`
PackageState *string `gorm:"column:package_state;type:varchar(20);comment:套餐状态匹配:active/depleted/pending/none,NULL不限" json:"package_state"`
AssetStatus *int `gorm:"column:asset_status;type:smallint;comment:资产状态匹配:1在库 2已销售 3已换货 4已停用,NULL不限" json:"asset_status"`

View File

@@ -200,7 +200,7 @@ func (m *PollingConfigManager) lazyTierFacts(card *model.IotCard) func() TierFac
// hasAnyConfiguredInterval 检查配置是否至少配置了一种检查间隔(正数或 0)。
func hasAnyConfiguredInterval(cfg *model.PollingConfig) bool {
return cfg.RealnameCheckInterval != nil || cfg.CarddataCheckInterval != nil || cfg.PackageCheckInterval != nil ||
(cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0) || cfg.CardStatusCheckInterval != nil
cfg.CardStatusCheckInterval != nil
}
// IntervalSource 是某检查类型最终采用的间隔及其来源,供合并结果与匹配预览共用。
@@ -213,15 +213,11 @@ type IntervalSource struct {
Config *model.PollingConfig
}
// taskIntervalFields 返回配置中各检查类型的间隔字段(保护期按既有语义只认正数)。
// taskIntervalFields 返回配置中各检查类型的间隔字段。
func taskIntervalFields(cfg *model.PollingConfig) []struct {
taskType string
value *int
} {
protect := cfg.ProtectCheckInterval
if protect != nil && *protect <= 0 {
protect = nil
}
return []struct {
taskType string
value *int
@@ -229,7 +225,6 @@ func taskIntervalFields(cfg *model.PollingConfig) []struct {
{constants.TaskTypePollingRealname, cfg.RealnameCheckInterval},
{constants.TaskTypePollingCarddata, cfg.CarddataCheckInterval},
{constants.TaskTypePollingPackage, cfg.PackageCheckInterval},
{constants.TaskTypePollingProtect, protect},
{constants.TaskTypePollingCardStatus, cfg.CardStatusCheckInterval},
}
}

View File

@@ -327,8 +327,6 @@ func lastCheckAtByTaskType(card *model.IotCard, taskType string) *time.Time {
return card.LastRealNameCheckAt
case constants.TaskTypePollingCarddata, constants.TaskTypePollingPackage:
return card.LastDataCheckAt
case constants.TaskTypePollingProtect:
return card.LastProtectCheckAt
case constants.TaskTypePollingCardStatus:
return card.LastCardStatusCheckAt
default:

View File

@@ -17,7 +17,6 @@ var allTaskTypes = []string{
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
constants.TaskTypePollingProtect,
constants.TaskTypePollingCardStatus,
}

View File

@@ -295,8 +295,8 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
// 查套餐系列名称
s.fillSeriesName(ctx, resp)
// 查 Redis 保护期
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, device.ID)
// 保护期已下线,字段固定返回无保护期,兼容老版本客户端。
resp.DeviceProtectStatus = constants.DeviceProtectStatusNone
// 关联手机号:详情为单资产单次查询,按数据范围返回完整手机号。
phones, err := s.resolveAssociatedPhones(ctx, constants.AssetTypeDevice, device.ID)
@@ -525,7 +525,7 @@ func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id ui
})
}
}
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id)
resp.DeviceProtectStatus = constants.DeviceProtectStatusNone
// 实时查询 Gateway 设备状态(不缓存,per D-05)
// Gateway 失败不阻断主流程,DeviceRealtime 始终非 nil,失败时仅填 GatewayMsg(per D-06)
@@ -1035,19 +1035,6 @@ func (s *Service) UpdatePackageUsage(ctx context.Context, assetType string, asse
return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil
}
// getDeviceProtectStatus 查询设备保护期状态
func (s *Service) getDeviceProtectStatus(ctx context.Context, deviceID uint) string {
stopKey := constants.RedisDeviceProtectKey(deviceID, "stop")
startKey := constants.RedisDeviceProtectKey(deviceID, "start")
if s.redis.Exists(ctx, stopKey).Val() > 0 {
return "stop"
}
if s.redis.Exists(ctx, startKey).Val() > 0 {
return "start"
}
return "none"
}
// nonZeroTimePtr 将非零时间转为指针,零值返回 nil(避免序列化出历史时区偏移 +08:05)
func nonZeroTimePtr(t time.Time) *time.Time {
if t.IsZero() {

View File

@@ -1378,17 +1378,6 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
// 复机保护期内禁止停机
if s.redis != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result()
if exists > 0 {
appErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络被拒绝",
constants.AuditResultDenied, device, nil, nil, nil, nil, appErr)
return nil, appErr
}
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
@@ -1505,12 +1494,6 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
successCount++
}
// 成功停机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
}
if successCount == 0 && len(failedItems) == 0 {
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "设备绑定卡网络无需停用",
constants.AuditResultSuccess, device, nil, nil, nil,
@@ -1541,17 +1524,6 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
return errors.New(errors.CodeNotFound, "设备不存在")
}
// 停机保护期内禁止复机
if s.redis != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result()
if exists > 0 {
appErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络被拒绝",
constants.AuditResultDenied, device, nil, nil, nil, nil, appErr)
return appErr
}
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
@@ -1664,12 +1636,6 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
successCount++
}
// 成功复机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
}
// 全部失败时返回 error
if successCount == 0 && lastErr != nil {
appErr := errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败")

View File

@@ -27,16 +27,10 @@ import (
)
// StopResumeServiceInterface 停复机服务接口
// 供 4 个 Task Handler 通过接口注入,避免循环依赖
// 供轮询 Task Handler 通过接口注入,避免循环依赖
type StopResumeServiceInterface interface {
// EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
// ForceStopCard 强制停机单张卡,不执行正常停机条件判断。
ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error
// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。
ForceStartCard(ctx context.Context, card *model.IotCard) error
// ProtectCheckSkipsForRealname 判定保护期一致性检查是否因实名跳过该卡。
ProtectCheckSkipsForRealname(ctx context.Context, card *model.IotCard) (bool, error)
}
// 编译时验证 StopResumeService 实现了 StopResumeServiceInterface
@@ -220,7 +214,7 @@ func containsStopReason(reasons []string, target string) bool {
//
// 三个前置条件缺一不可:
// 1. 已注入优先轮询 Writer;
// 2. 本次评估来自普通套餐轮询(观测消费者、手动实名、保护期轮询等调用链不打标,一律不写);
// 2. 本次评估来自普通套餐轮询(观测消费者、手动实名等调用链不打标,一律不写);
// 3. 该载体存在待生效的套餐使用记录,否则每个普通套餐周期都重新入队会形成自激循环。
//
// 事件资源为该载体对应的资产(独立卡为自身,绑定设备为设备),卡快照由 Writer 在同一事务内冻结。
@@ -547,53 +541,6 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType
}
}
// ForceStopCard 强制停机单张卡,不执行正常停机条件判断。
func (s *StopResumeService) ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error {
if card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidParam)
}
_, err := s.stopCardWithRetry(ctx, card, stopReason)
return err
}
// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。
// 持通道阈值锁的卡必须先拒绝,再走既有保护期一致性检查,避免强行复机锁定期内的卡。
func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotCard) error {
if card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidParam)
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
blocked, err := s.channelThresholdBlocked(ctx, card)
if err != nil {
return err
}
if blocked {
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
return channelThresholdDenyError()
}
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
return err
}
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(),
"stop_reason": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结保护期复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err)
return err
}
s.reschedulePolling(ctx, card.ID)
if err := s.completeCardCommandAttempt(ctx, attempt, nil, true); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "终结保护期复机 Integration Log 失败")
}
return nil
}
// resumeSingleCard 对单张卡执行复机逻辑
// 依次检查:持通道阈值锁则拒绝 → 已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error {
@@ -615,7 +562,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
// 持通道阈值锁:周期内拒绝一切复机,记录拒绝事实并保留锁。
// 该入口被轮询评估、套餐激活/重置与设备维度复机复用,返回 nil 表示本次不动作,
// 避免让自动链路按失败重试;用户可见入口由 ManualStartCard/ForceStartCard 返回拒绝错误。
// 避免让自动链路按失败重试;用户可见入口由 ManualStartCard 返回拒绝错误。
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
if blockErr != nil {
return blockErr
@@ -982,24 +929,6 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
return denyErr
}
// 检查绑定设备是否在复机保护期
if s.deviceSimBindingStore != nil && s.redis != nil {
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if bindErr == nil && binding != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result()
if exists > 0 {
denyErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
cardSnapshot(card), map[string]any{"device_id": binding.DeviceID}, denyErr)
return denyErr
}
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, wrapErr)
return wrapErr
}
}
if _, err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
return err
}
@@ -1049,24 +978,6 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
return denyErr
}
// 检查绑定设备是否在停机保护期
if s.deviceSimBindingStore != nil && s.redis != nil {
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if bindErr == nil && binding != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result()
if exists > 0 {
denyErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
cardSnapshot(card), map[string]any{"device_id": binding.DeviceID}, denyErr)
return denyErr
}
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, wrapErr)
return wrapErr
}
}
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")

View File

@@ -80,19 +80,3 @@ func (s *StopResumeService) unifiedRealnameOK(ctx context.Context, card *model.I
}
return card.RealNameStatus == constants.RealNameStatusVerified, nil
}
// ProtectCheckSkipsForRealname 判定保护期一致性检查是否因实名跳过该卡。
// 总开关关闭时与原有行为一致(未实名即跳过);开启时只在通道开启实名管控且卡未实名时跳过。
func (s *StopResumeService) ProtectCheckSkipsForRealname(ctx context.Context, card *model.IotCard) (bool, error) {
if card.RealNameStatus == constants.RealNameStatusVerified {
return false, nil
}
if !unifiedStopResumeEnabled() {
return true, nil
}
controlled, err := s.channelRealnameControlled(ctx, card)
if err != nil {
return false, err
}
return controlled, nil
}

View File

@@ -71,7 +71,7 @@ func pollingConfigState(config *model.PollingConfig) map[string]any {
"config_name": config.ConfigName, "card_condition": config.CardCondition,
"card_category": config.CardCategory, "carrier_id": config.CarrierID, "priority": config.Priority,
"realname_check_interval": config.RealnameCheckInterval, "carddata_check_interval": config.CarddataCheckInterval,
"package_check_interval": config.PackageCheckInterval, "protect_check_interval": config.ProtectCheckInterval,
"package_check_interval": config.PackageCheckInterval,
"card_status_check_interval": config.CardStatusCheckInterval, "status": config.Status,
"package_state": config.PackageState, "asset_status": config.AssetStatus,
"pre_reset_window": config.PreResetWindow, "pre_reset_carddata_interval": config.PreResetCarddataInterval,

View File

@@ -58,6 +58,9 @@ func (s *ConcurrencyService) List(ctx context.Context) ([]*ConcurrencyStatus, er
result := make([]*ConcurrencyStatus, 0, len(configs))
for _, cfg := range configs {
if isRetiredConcurrencyTaskType(cfg.TaskType) {
continue
}
status := &ConcurrencyStatus{
TaskType: cfg.TaskType,
TaskTypeName: s.getTaskTypeName(cfg.TaskType),
@@ -91,6 +94,9 @@ func (s *ConcurrencyService) List(ctx context.Context) ([]*ConcurrencyStatus, er
// GetByTaskType 根据任务类型获取并发配置及状态
func (s *ConcurrencyService) GetByTaskType(ctx context.Context, taskType string) (*ConcurrencyStatus, error) {
if isRetiredConcurrencyTaskType(taskType) {
return nil, retiredConcurrencyTaskTypeError()
}
cfg, err := s.store.GetByTaskType(ctx, taskType)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "并发配置不存在")
@@ -130,6 +136,9 @@ func (s *ConcurrencyService) UpdateMaxConcurrency(ctx context.Context, taskType
if maxConcurrency < 1 || maxConcurrency > constants.PollingMaxConcurrencyLimit {
return errors.New(errors.CodeInvalidParam, "并发数必须为 1-1000")
}
if isRetiredConcurrencyTaskType(taskType) {
return retiredConcurrencyTaskTypeError()
}
// 验证任务类型存在
config, err := s.store.GetByTaskType(ctx, taskType)
@@ -177,6 +186,9 @@ func (s *ConcurrencyService) ResetConcurrency(ctx context.Context, taskType stri
if operatorID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
if isRetiredConcurrencyTaskType(taskType) {
return retiredConcurrencyTaskTypeError()
}
// 验证任务类型存在
config, err := s.store.GetByTaskType(ctx, taskType)
if err != nil {
@@ -249,6 +261,9 @@ func (s *ConcurrencyService) InitFromDB(ctx context.Context) error {
}
for _, cfg := range configs {
if isRetiredConcurrencyTaskType(cfg.TaskType) {
continue
}
configKey := constants.RedisPollingConcurrencyConfigKey(cfg.TaskType)
if err := s.redis.Set(ctx, configKey, cfg.MaxConcurrency, 24*time.Hour).Err(); err != nil {
// 忽略单个配置同步失败
@@ -256,6 +271,7 @@ func (s *ConcurrencyService) InitFromDB(ctx context.Context) error {
}
}
_ = s.redis.Del(ctx, constants.RedisPollingConcurrencyConfigKey("stop_start")).Err()
_ = s.redis.Del(ctx, constants.RedisPollingConcurrencyConfigKey(retiredProtectConcurrencyTaskType)).Err()
return nil
}
@@ -283,11 +299,21 @@ func (s *ConcurrencyService) getTaskTypeName(taskType string) string {
return "流量检查"
case "package":
return "套餐检查"
case "protect":
return "保护期检查"
case "card_status":
return "卡状态检查"
default:
return taskType
}
}
// retiredProtectConcurrencyTaskType 是已下线的保护期检查并发配置;数据库行保留但不再返回或维护。
const retiredProtectConcurrencyTaskType = "protect"
// isRetiredConcurrencyTaskType 判断并发配置是否属于已下线的轮询任务(兼容带 polling: 前缀的写法)。
func isRetiredConcurrencyTaskType(taskType string) bool {
return strings.TrimPrefix(strings.TrimSpace(taskType), "polling:") == retiredProtectConcurrencyTaskType
}
func retiredConcurrencyTaskTypeError() error {
return errors.New(errors.CodeInvalidParam, "保护期检查已下线,不再维护并发配置")
}

View File

@@ -79,7 +79,6 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig
RealnameCheckInterval: req.RealnameCheckInterval,
CarddataCheckInterval: req.CarddataCheckInterval,
PackageCheckInterval: req.PackageCheckInterval,
ProtectCheckInterval: req.ProtectCheckInterval,
CardStatusCheckInterval: req.CardStatusCheckInterval,
PackageState: req.PackageState,
AssetStatus: req.AssetStatus,
@@ -188,9 +187,6 @@ 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
}
@@ -402,7 +398,6 @@ func (s *ConfigService) toResponse(c *model.PollingConfig) *dto.PollingConfigRes
RealnameCheckInterval: c.RealnameCheckInterval,
CarddataCheckInterval: c.CarddataCheckInterval,
PackageCheckInterval: c.PackageCheckInterval,
ProtectCheckInterval: c.ProtectCheckInterval,
CardStatusCheckInterval: c.CardStatusCheckInterval,
PackageState: c.PackageState,
AssetStatus: c.AssetStatus,

View File

@@ -149,7 +149,7 @@ func (s *ConfigService) MatchPreview(ctx context.Context, req *dto.PollingConfig
return resp, nil
}
var previewTaskTypes = []string{constants.TaskTypePollingCardStatus, constants.TaskTypePollingCarddata, constants.TaskTypePollingRealname, constants.TaskTypePollingPackage, constants.TaskTypePollingProtect}
var previewTaskTypes = []string{constants.TaskTypePollingCardStatus, constants.TaskTypePollingCarddata, constants.TaskTypePollingRealname, constants.TaskTypePollingPackage}
// applyPreviewOverrides 把未保存修改合并进启用配置快照并重新排序;返回草稿配置标记。
func applyPreviewOverrides(configs []*model.PollingConfig, overrides []dto.PollingConfigOverride) ([]*model.PollingConfig, map[*model.PollingConfig]bool, error) {
@@ -176,8 +176,8 @@ func applyPreviewOverrides(configs []*model.PollingConfig, overrides []dto.Polli
ID: item.ID, ConfigName: item.ConfigName, CardCondition: item.CardCondition, CardCategory: item.CardCategory,
CarrierID: item.CarrierID, Priority: item.Priority, RealnameCheckInterval: item.RealnameCheckInterval,
CarddataCheckInterval: item.CarddataCheckInterval, PackageCheckInterval: item.PackageCheckInterval,
ProtectCheckInterval: item.ProtectCheckInterval, CardStatusCheckInterval: item.CardStatusCheckInterval,
PackageState: item.PackageState, AssetStatus: item.AssetStatus, PreResetWindow: item.PreResetWindow,
CardStatusCheckInterval: item.CardStatusCheckInterval,
PackageState: item.PackageState, AssetStatus: item.AssetStatus, PreResetWindow: item.PreResetWindow,
PreResetCarddataInterval: item.PreResetCarddataInterval, Status: 1,
}
if draft.ConfigName == "" {
@@ -336,11 +336,8 @@ func (s *ConfigService) TierStats(ctx context.Context) (*dto.PollingTierStatsRes
return resp, nil
}
// previewTaskTypeName 返回检查类型中文名称(保护期检查在优先轮询名称表之外单独处理)。
// previewTaskTypeName 返回检查类型中文名称。
func previewTaskTypeName(taskType string) string {
if taskType == constants.TaskTypePollingProtect {
return "保护期检查"
}
return constants.PollingPriorityTaskTypeName(taskType)
}

View File

@@ -22,7 +22,6 @@ func validatePollingConfig(cfg *model.PollingConfig) error {
{"实名检查间隔", cfg.RealnameCheckInterval, 30},
{"流量检查间隔", cfg.CarddataCheckInterval, 60},
{"套餐检查间隔", cfg.PackageCheckInterval, 60},
{"保护期一致性检查间隔", cfg.ProtectCheckInterval, 30},
{"卡状态检查间隔", cfg.CardStatusCheckInterval, 30},
}
configured := false

View File

@@ -265,8 +265,6 @@ func (s *MonitoringService) getTaskTypeName(taskType string) string {
return "流量检查"
case constants.TaskTypePollingPackage:
return "套餐检查"
case constants.TaskTypePollingProtect:
return "保护期检查"
case constants.TaskTypePollingCardStatus:
return "卡状态检查"
default:
@@ -280,7 +278,6 @@ func pollingMonitorTaskTypes() []string {
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
constants.TaskTypePollingProtect,
constants.TaskTypePollingCardStatus,
}
}

View File

@@ -112,7 +112,7 @@ func (h *PollingPackageHandler) Handle(ctx context.Context, t *asynq.Task) error
zap.String("stop_reason", freshCard.StopReason))
}
// 标记本次评估来自普通套餐轮询:无有效套餐场景据此才允许追加优先轮询请求,
// 其余调用链(观测消费者、手动实名、保护期轮询等)不打标即不写。
// 其余调用链(观测消费者、手动实名等)不打标即不写。
evaluationCtx := priorityapp.WithPollingPriorityTrigger(ctx)
if evalErr := h.stopResumeSvc.EvaluateAndAct(evaluationCtx, freshCard); evalErr != nil {
h.base.logger.Warn("套餐检查后停复机评估失败",

View File

@@ -1,166 +0,0 @@
package task
import (
"context"
"time"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"gorm.io/gorm"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"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"
)
// PollingProtectHandler 保护期一致性检查任务处理器
// 保护期内:直接调 Gateway 强制停/复机(绕过 EvaluateAndAct 三条件判断)
// 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件
// 两种路径不可混淆:保护期内=强制修正;保护期结束=重新评估
type PollingProtectHandler struct {
base *PollingBase
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
stopResumeSvc iot_card_svc.StopResumeServiceInterface
}
// NewPollingProtectHandler 创建保护期一致性检查任务处理器
func NewPollingProtectHandler(
db *gorm.DB,
observationSeriesEvents cardObservationApp.SeriesEventWriter,
base *PollingBase,
gw *gateway.Client,
iotCardStore *postgres.IotCardStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
) *PollingProtectHandler {
return &PollingProtectHandler{
base: base,
iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
stopResumeSvc: stopResumeSvc,
}
}
// Handle 处理保护期一致性检查任务
func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error {
startTime := time.Now()
cardID, ok := parseTaskPayload(t.Payload(), h.base.logger)
if !ok {
return nil
}
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingProtect, "保护期一致性轮询任务", t.ResultWriter().TaskID())
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingProtect) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingProtect)
card, err := h.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if isNotFound(err) {
return nil
}
h.base.logger.Error("查询卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
// 独立卡(未绑设备)跳过保护期检查
if card.IsStandalone {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
// 实名不满足的卡跳过保护期检查:停复机新规则总开关关闭时未实名即跳过,开启时只在通道开启实名管控且未实名时跳过
if h.stopResumeSvc != nil {
skip, skipErr := h.stopResumeSvc.ProtectCheckSkipsForRealname(ctx, card)
if skipErr != nil {
h.base.logger.Warn("保护期检查判断实名管控失败,本次跳过", zap.Uint("card_id", cardID), zap.Error(skipErr))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
if skip {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
} else if card.RealNameStatus != constants.RealNameStatusVerified {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err != nil || binding == nil {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
deviceID := binding.DeviceID
stopProtectGeneration := h.base.redis.Get(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val()
startProtectGeneration := h.base.redis.Get(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val()
stopProtect := stopProtectGeneration != ""
startProtect := startProtectGeneration != ""
actionTaken := "no_action"
switch {
case stopProtect && card.NetworkStatus == constants.NetworkStatusOnline:
// 保护期内:停机保护期发现开机卡 → 强制停机(绕过 EvaluateAndAct)
h.base.logger.Info("保护期一致性:停机保护期内发现开机卡,强制停机",
zap.Uint("card_id", card.ID), zap.Uint("device_id", deviceID))
if h.stopResumeSvc == nil {
break
}
if err := h.stopResumeSvc.ForceStopCard(ctx, card, constants.StopReasonProtectPeriod); err != nil {
h.base.logger.Error("保护期强制停机失败",
zap.Uint("card_id", card.ID), zap.Error(err))
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOffline})
actionTaken = "forced_stop"
case startProtect && card.NetworkStatus == constants.NetworkStatusOffline:
// 保护期内:复机保护期发现停机卡 → 强制复机(绕过 EvaluateAndAct)
h.base.logger.Info("保护期一致性:复机保护期内发现停机卡,强制复机",
zap.Uint("card_id", card.ID), zap.Uint("device_id", deviceID))
if h.stopResumeSvc == nil {
break
}
if err := h.stopResumeSvc.ForceStartCard(ctx, card); err != nil {
h.base.logger.Error("保护期强制复机失败",
zap.Uint("card_id", card.ID), zap.Error(err))
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOnline})
actionTaken = "forced_resume"
case !stopProtect && !startProtect:
// 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件
if h.stopResumeSvc != nil {
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, card); evalErr != nil {
h.base.logger.Warn("保护期结束后停复机评估失败",
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{
"last_protect_check_at": time.Now(),
}); updateErr != nil {
h.base.logger.Warn("更新保护期检查时间失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
}
h.base.updateStats(ctx, constants.TaskTypePollingProtect, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}

View File

@@ -0,0 +1,3 @@
-- 回滚:恢复两列原注释。
COMMENT ON COLUMN tb_polling_config.protect_check_interval IS '保护期一致性检查间隔(秒),NULL=不参与';
COMMENT ON COLUMN tb_iot_card.last_protect_check_at IS '上次保护期一致性检查时间';

View File

@@ -0,0 +1,8 @@
-- 下线设备保护期(remove-device-protect-period)。
-- 背景:后台设备停复机设置 1 小时保护期,保护期轮询(polling:protect)方向不一致时强制停复机;
-- 自动停复机不看保护期,导致保护期内反复翻转。保护期整体下线,代码不再读写以下两列。
--
-- 设计选择:数据库只加不删,列保留,仅把注释标记为已废弃;不改数据。
COMMENT ON COLUMN tb_polling_config.protect_check_interval IS '已废弃(设备保护期已下线,不再读写):原保护期一致性检查间隔(秒)';
COMMENT ON COLUMN tb_iot_card.last_protect_check_at IS '已废弃(设备保护期已下线,不再读写):原上次保护期一致性检查时间';

View File

@@ -1,29 +1,29 @@
## 1. 迁移
- [ ] 1.1 新增成对迁移:更新 `tb_polling_config.protect_check_interval`、`tb_iot_card.last_protect_check_at` 列注释为"已废弃";down 恢复原注释。
- [x] 1.1 新增成对迁移:更新 `tb_polling_config.protect_check_interval`、`tb_iot_card.last_protect_check_at` 列注释为"已废弃";down 恢复原注释。(`000249_deprecate_device_protect_columns`)
## 2. 设备与单卡停复机
- [ ] 2.1 `internal/service/device/service.go`:设备停机、复机不再设置保护期,不再因保护期拒绝相反操作。
- [ ] 2.2 `internal/service/iot_card/stop_resume_service.go`:手动停复机单卡(第 969、1036 行附近)去掉保护期检查。
- [ ] 2.3 删除 `RedisDeviceProtectKey`、`DeviceProtectPeriodDuration`;保留 `StopReasonProtectPeriod` 并在注释说明不再产生新值。
- [x] 2.1 `internal/service/device/service.go`:设备停机、复机不再设置保护期,不再因保护期拒绝相反操作。
- [x] 2.2 `internal/service/iot_card/stop_resume_service.go`:手动停复机单卡(第 969、1036 行附近)去掉保护期检查。(同时删除只服务于保护期轮询的 `ForceStopCard`、`ForceStartCard`)
- [x] 2.3 删除 `RedisDeviceProtectKey`、`DeviceProtectPeriodDuration`;保留 `StopReasonProtectPeriod` 并在注释说明不再产生新值。
## 3. 下线保护期轮询
- [ ] 3.1 删除 `internal/task/polling_protect_handler.go`;`pkg/queue/handler.go` 对 `polling:protect` 改为空处理函数直接成功(下一次发布再移除)。
- [ ] 3.2 调度器、初始化器、生命周期、监控与统计去掉 `protect` 类型;删除 `ProtectCheckSkipsForRealname`。
- [ ] 3.3 轮询并发配置与任务类型名称去掉 `protect`,提交 `protect` 时拒绝。
- [ ] 3.4 `PollingConfig` 模型与 DTO 去掉 `ProtectCheckInterval`;`config_manager.go` 匹配去掉保护期;`IotCard` 模型去掉 `LastProtectCheckAt` 的读写。
- [x] 3.1 删除 `internal/task/polling_protect_handler.go`;`pkg/queue/handler.go` 对 `polling:protect` 改为空处理函数直接成功(下一次发布再移除)。(`TaskTypePollingProtect` 常量与队列权重暂留,用于消化存量任务)
- [x] 3.2 调度器、初始化器、生命周期、监控与统计去掉 `protect` 类型;删除 `ProtectCheckSkipsForRealname`。
- [x] 3.3 轮询并发配置与任务类型名称去掉 `protect`,提交 `protect` 时拒绝。(数据库 `protect` 行保留,服务层过滤:列表不返回,查询、更新、重置返回参数错误)
- [x] 3.4 `PollingConfig` 模型与 DTO 去掉 `ProtectCheckInterval`;`config_manager.go` 匹配去掉保护期;`IotCard` 模型去掉 `LastProtectCheckAt` 的读写。
## 4. 资产接口兼容
- [ ] 4.1 后台与 C 端资产详情的 `device_protect_status` 固定返回 `none`,DTO 说明标记已废弃;`internal/service/asset/service.go` 不再读取保护期 key。
- [ ] 4.2 重新生成接口文档。
- [x] 4.1 后台与 C 端资产详情的 `device_protect_status` 固定返回 `none`,DTO 说明标记已废弃;`internal/service/asset/service.go` 不再读取保护期 key。(三个字段加 `deprecated:"true"`,接口文档显示已废弃)
- [x] 4.2 重新生成接口文档。
## 5. 编译与规范校验
- [ ] 5.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;全仓检索无残留的保护期 key 读写。
- [ ] 5.2 `openspec validate remove-device-protect-period` 与 `openspec doctor --json` 通过。
- [x] 5.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;全仓检索无残留的保护期 key 读写。
- [x] 5.2 `openspec validate remove-device-protect-period` 与 `openspec doctor --json` 通过。(validate 通过;当前 openspec 无 doctor 命令)
## 6. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001)

View File

@@ -64,7 +64,7 @@ const (
TaskTypePollingRealname = "polling:realname" // 实名状态检查
TaskTypePollingCarddata = "polling:carddata" // 卡流量检查
TaskTypePollingPackage = "polling:package" // 套餐流量检查
TaskTypePollingProtect = "polling:protect" // 保护期一致性检查
TaskTypePollingProtect = "polling:protect" // 已下线:保护期一致性检查,仅保留空处理消化存量任务,下一次发布移除
TaskTypePollingCardStatus = "polling:card_status" // 卡开停机状态检查
// 套餐激活任务类型
@@ -235,7 +235,7 @@ const (
QueuePollingRealname = TaskTypePollingRealname // 实名轮询任务队列
QueuePollingCarddata = TaskTypePollingCarddata // 流量轮询任务队列
QueuePollingPackage = TaskTypePollingPackage // 套餐轮询任务队列
QueuePollingProtect = TaskTypePollingProtect // 保护期轮询任务队列
QueuePollingProtect = TaskTypePollingProtect // 已下线:保护期轮询任务队列,仅消化存量任务
QueuePollingCardStatus = TaskTypePollingCardStatus // 卡状态轮询任务队列
QueuePackageFirstActivation = TaskTypePackageFirstActivation // 首次实名激活任务队列
QueuePackageQueueActivation = TaskTypePackageQueueActivation // 主套餐排队激活任务队列
@@ -499,9 +499,11 @@ const (
AutoPurchaseStatusFailed = "failed" // 失败
)
// 设备保护期相关时长常量
// DeviceProtectStatusNone 是资产详情 device_protect_status 的固定返回值:设备保护期已下线,字段仅为兼容老版本客户端保留。
const DeviceProtectStatusNone = "none"
// 设备相关时长常量
const (
DeviceProtectPeriodDuration = 1 * time.Hour // 设备停/复机保护期时长(1小时)
DeviceRefreshCooldownDuration = 30 * time.Second // 设备网关刷新冷却时长(30秒)
)

View File

@@ -72,7 +72,7 @@ const (
StopReasonTrafficExhausted = "traffic_exhausted" // 流量耗尽
StopReasonManual = "manual" // 手动停机
StopReasonArrears = "arrears" // 欠费
StopReasonProtectPeriod = "protect_period" // 保护期一致性检查自动停机
StopReasonProtectPeriod = "protect_period" // 保护期一致性检查自动停机(保护期已下线,不再产生新值;保留供历史数据与开放接口映射)
StopReasonNoPackage = "no_package" // 停机原因:无有效套餐
StopReasonNotRealname = "not_realname" // 停机原因:非行业卡且未完成实名认证
StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机

View File

@@ -146,7 +146,7 @@ const (
)
// PollingPriorityTaskTypes 返回纳入优先轮询的既有轮询任务类型。
// 一次优先需求对应这些任务类型各执行一次;保护期一致性检查不在集合内。
// 一次优先需求对应这些任务类型各执行一次。
// 返回新切片,调用方修改不会影响其它调用方。
func PollingPriorityTaskTypes() []string {
return []string{

View File

@@ -417,16 +417,9 @@ func RedisClientPurchaseLockKey(assetType string, assetID uint) string {
}
// ========================================
// 设备保护期相关 Redis Key
// 设备刷新相关 Redis Key
// ========================================
// RedisDeviceProtectKey 生成设备保护期 Redis 键
// action: "stop"(停机保护期)或 "start"(复机保护期),两者互斥
// 过期时间:DeviceProtectPeriodDuration(1 小时)
func RedisDeviceProtectKey(deviceID uint, action string) string {
return fmt.Sprintf("protect:device:%d:%s", deviceID, action)
}
// RedisDeviceRefreshCooldownKey 生成设备刷新冷却期 Redis 键
// 用途:防止同一设备短时间内多次调网关刷新(限频)
// 过期时间:DeviceRefreshCooldownDuration(30 秒)
@@ -434,16 +427,6 @@ func RedisDeviceRefreshCooldownKey(deviceID uint) string {
return fmt.Sprintf("refresh:cooldown:device:%d", deviceID)
}
// ========================================
// 轮询保护期队列 Redis Key
// ========================================
// RedisPollingQueueProtectKey 保护期一致性检查轮询队列键
// 用途:存储需要进行保护期一致性检查的卡 ID(ZSet,按时间排序)
func RedisPollingQueueProtectKey() string {
return "polling:queue:protect"
}
// ========================================
// 微信配置相关 Redis Key
// ========================================

View File

@@ -1,6 +1,8 @@
package queue
import (
"context"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
@@ -287,19 +289,16 @@ func (h *Handler) registerPollingHandlers() {
packageHandler := task.NewPollingPackageHandler(
h.pollingBase, h.workerResult.Stores.IotCard,
h.pollingStopResumeSvc)
protectHandler := task.NewPollingProtectHandler(
h.db, h.workerResult.Services.ObservationSeriesEvents,
h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard,
h.workerResult.Stores.DeviceSimBinding, h.pollingStopResumeSvc)
cardStatusHandler := task.NewPollingCardStatusHandler(
h.pollingBase, h.gatewayClient, h.workerResult.Services.CardObservation, integrationRepository)
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)
// 设备保护期已下线:保留空处理函数消化发布前已入队的 polling:protect 任务,下一次发布再移除。
h.mux.HandleFunc(constants.TaskTypePollingProtect, func(context.Context, *asynq.Task) error { return nil })
h.mux.HandleFunc(constants.TaskTypePollingCardStatus, cardStatusHandler.Handle)
h.logger.Info("已注册轮询任务处理器(realname/carddata/package/protect/card_status)")
h.logger.Info("已注册轮询任务处理器(realname/carddata/package/card_status)")
}
func (h *Handler) registerPackageActivationHandlers() {