feat: 下线设备保护期
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s
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:
@@ -48,9 +48,9 @@ type pollingPriorityTriggerKey struct{}
|
||||
|
||||
// WithPollingPriorityTrigger 标记本次业务评估来自普通套餐轮询。
|
||||
//
|
||||
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、保护期轮询、
|
||||
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、
|
||||
// 既有兼容入口等多条调用链复用;只有普通套餐轮询来源才允许追加优先轮询请求,
|
||||
// 否则会在观测序列、手动实名、保护期与退款等上下文凭空产生加急触发。
|
||||
// 否则会在观测序列、手动实名与退款等上下文凭空产生加急触发。
|
||||
func WithPollingPriorityTrigger(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, pollingPriorityTriggerKey{}, true)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 时有值"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,7 +17,6 @@ var allTaskTypes = []string{
|
||||
constants.TaskTypePollingRealname,
|
||||
constants.TaskTypePollingCarddata,
|
||||
constants.TaskTypePollingPackage,
|
||||
constants.TaskTypePollingProtect,
|
||||
constants.TaskTypePollingCardStatus,
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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, "设备复机失败,所有卡均复机失败")
|
||||
|
||||
@@ -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, "调用运营商复机失败,请稍后重试")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "保护期检查已下线,不再维护并发配置")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("套餐检查后停复机评估失败",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 '上次保护期一致性检查时间';
|
||||
@@ -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 '已废弃(设备保护期已下线,不再读写):原上次保护期一致性检查时间';
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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秒)
|
||||
)
|
||||
|
||||
|
||||
@@ -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 侧主动停机
|
||||
|
||||
@@ -146,7 +146,7 @@ const (
|
||||
)
|
||||
|
||||
// PollingPriorityTaskTypes 返回纳入优先轮询的既有轮询任务类型。
|
||||
// 一次优先需求对应这些任务类型各执行一次;保护期一致性检查不在集合内。
|
||||
// 一次优先需求对应这些任务类型各执行一次。
|
||||
// 返回新切片,调用方修改不会影响其它调用方。
|
||||
func PollingPriorityTaskTypes() []string {
|
||||
return []string{
|
||||
|
||||
@@ -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
|
||||
// ========================================
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user