feat: 停复机指令队列与设备维度判断
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

自动停复机统一经停复机指令队列执行(全局开关 polling.stop_resume.queue_enabled,默认关闭):
- 统一判断函数 decideCard,新旧路径共用;暂停轮询的卡在套餐到期、退款场景照常停机
- 同卡一条指令、版本号覆盖;领取校验单卡最小重试间隔;先于卡任务领取,不受 scheduler_mode 限制
- 执行器只调用一次网关,按错误分类退避与告警,结果未知在队列内确认
- 设备维度判断合并请求与场景,版本变化再判断,旧路径持设备锁时推后
- 套餐激活、重置、订单、流量扣减、套餐到期、退款、实名管控修改改为请求评估
- 监控增加停复机队列与近 1 小时执行计数,单卡轮询状态返回当前指令
- 迁移 000255 插入开关与告警、退避参数

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-26 00:36:26 +09:00
parent 1369fed642
commit 934a3a27a1
31 changed files with 2275 additions and 117 deletions

View File

@@ -12,10 +12,13 @@ import (
"go.uber.org/zap"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/nsqbus"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -35,9 +38,13 @@ type carrierSchedulerRuntime struct {
producer *nsqbus.Producer
alerts *carrierqueue.AlertWriter
executor *task.CarrierCardExecutor
singleton bool
instanceID string
logger *zap.Logger
// 停复机指令与设备判断的专用消费者:只看停复机出口开关,不受 scheduler_mode 限制。
srConsumers *nsqbus.ConsumerManager
devConsumers *nsqbus.ConsumerManager
srActive bool
singleton bool
instanceID string
logger *zap.Logger
mu sync.Mutex
lastMode string
@@ -56,6 +63,9 @@ func newCarrierSchedulerParts(runtime *workerRuntime, appLogger *zap.Logger) {
runtime.carrierScheduling = scheduling
runtime.lifecycleSvc.SetCarrierScheduling(scheduling)
runtime.pollingBase.SetSchedulerModeReader(func(ctx context.Context) string { return settings.Current(ctx).Mode })
if stopResumeSvc, ok := runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetCommandQueue(carrierqueue.NewCommandQueue(store, settings))
}
if runtime.gatewayLimit != nil && runtime.gatewayLimit.Classifier != nil {
classifier := runtime.gatewayLimit.Classifier
runtime.pollingBase.SetErrorClassifier(func(ctx context.Context, op, message string) string {
@@ -94,6 +104,7 @@ func startCarrierScheduler(ctx context.Context, cfg *config.Config, runtime *wor
MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts,
}, constants.NSQChannelWorker, executor.Handle, appLogger)
}
c.setupStopResume(carrierCfg, alerts)
var budgets carrierqueue.BudgetProvider
if runtime.gatewayLimit != nil && runtime.gatewayLimit.Limiter != nil {
budgets = runtime.gatewayLimit.Limiter
@@ -139,6 +150,7 @@ func (c *carrierSchedulerRuntime) step(ctx context.Context) {
c.logger.Error("按运营商账户调度模式监听 panic,已恢复", zap.Any("panic", r))
}
}()
c.syncStopResume(ctx)
mode := c.runtime.carrierSettings.Current(ctx).Mode
c.mu.Lock()
previous := c.lastMode
@@ -216,11 +228,74 @@ func (c *carrierSchedulerRuntime) grace() time.Duration {
return 30 * time.Second
}
// setupStopResume 创建停复机指令执行器、设备判断执行器与两个专用消费者。
func (c *carrierSchedulerRuntime) setupStopResume(carrierCfg config.CarrierSchedulerConfig, alerts *carrierqueue.AlertWriter) {
service, ok := c.runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService)
if !ok || carrierCfg.NSQDAddress == "" {
return
}
var classifier *gatewaylimit.Classifier
if c.runtime.gatewayLimit != nil {
classifier = c.runtime.gatewayLimit.Classifier
}
params := func(carrierID uint) carrierqueue.CarrierParams {
if c.dispatcher != nil {
return c.dispatcher.ParamsFor(carrierID)
}
return carrierqueue.MergeParams(model.CarrierPollingParam{}, false, carrierCfg)
}
srExecutor := task.NewStopResumeExecutor(c.runtime.carrierStore, c.runtime.carrierSettings, params, alerts, service, classifier,
c.instanceID, c.logger)
devExecutor := task.NewDeviceEvalExecutor(c.runtime.carrierStore, service, c.instanceID, c.logger)
consumerCfg := nsqbus.ConsumerConfig{
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts,
}
c.srConsumers = nsqbus.NewConsumerManager(consumerCfg, constants.NSQChannelWorker, srExecutor.Handle, c.logger)
c.devConsumers = nsqbus.NewConsumerManager(consumerCfg, constants.NSQChannelWorker, devExecutor.Handle, c.logger)
}
// syncStopResume 停复机出口开关开启时订阅各账户停复机 topic 与设备判断 topic;关闭时停止订阅(队列中的指令保留)。
func (c *carrierSchedulerRuntime) syncStopResume(ctx context.Context) {
if c.srConsumers == nil || c.dispatcher == nil {
return
}
enabled := c.runtime.carrierSettings.Current(ctx).StopResumeQueueEnabled
if !enabled {
if c.srActive {
c.logger.Info("停复机出口开关已关闭:停止停复机指令消费,未执行的指令保留")
c.srConsumers.StopAll(c.grace())
c.srConsumers.Reset()
c.devConsumers.StopAll(c.grace())
c.devConsumers.Reset()
c.srActive = false
}
return
}
if !c.srActive {
c.logger.Info("停复机出口开关已开启:自动停复机经停复机指令队列执行")
c.srActive = true
}
desired := map[string]int{}
for _, carrierID := range c.dispatcher.CarrierIDs(ctx) {
desired[constants.NSQStopResumeTopic(carrierID)] = c.dispatcher.ParamsFor(carrierID).SRMaxInFlight
}
c.srConsumers.Sync(desired)
c.devConsumers.Sync(map[string]int{constants.NSQDeviceEvalTopic: deviceEvalMaxInFlight})
}
// deviceEvalMaxInFlight 是每进程设备判断消费者并发。
const deviceEvalMaxInFlight = 10
// shutdown 优雅退出:停止订阅,宽限期内等待执行中的卡任务完成;未完成的由租约到期后重新领取。
func (c *carrierSchedulerRuntime) shutdown() {
if c.consumers != nil {
c.consumers.StopAll(c.grace())
}
if c.srConsumers != nil {
c.srConsumers.StopAll(c.grace())
c.devConsumers.StopAll(c.grace())
}
if c.producer != nil {
c.producer.Stop()
}

View File

@@ -283,6 +283,8 @@ func initServices(s *stores, deps *Dependencies) *services {
}
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机指令队列:开关开启时自动入口只请求评估,由 Worker 指令执行器调用网关。
stopResumeService.SetCommandQueue(carrierqueue.NewCommandQueue(carrierStore, carrierSettings))
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
@@ -543,7 +545,7 @@ func initServices(s *stores, deps *Dependencies) *services {
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, auditWriter),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier, auditWriter),
Carrier: newCarrierService(s.Carrier, auditWriter, stopResumeService, deps.Logger),
PackageSeries: packageSeriesService,
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
@@ -597,3 +599,12 @@ func initServices(s *stores, deps *Dependencies) *services {
CardObservationSeries: cardObservationSeries,
}
}
// newCarrierService 创建运营商配置服务并注入实名管控修改后的批量停复机评估。
func newCarrierService(store *postgres.CarrierStore, auditWriter *auditInfra.Writer, stopResume *iotCardSvc.StopResumeService, logger *zap.Logger) *carrierSvc.Service {
service := carrierSvc.New(store, auditWriter)
if stopResume != nil {
service.SetRealnameControlEvaluator(stopResume, logger)
}
return service
}

View File

@@ -0,0 +1,41 @@
// Package stopresume 提供自动停复机请求评估场景在 ctx 中的传递,供各自动入口与停复机服务共用(无业务依赖)。
package stopresume
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
type sceneKey struct{}
// WithScene 声明本次自动停复机评估的触发场景(constants.StopResumeScene*);未声明时按套餐步骤(周期评估)处理。
func WithScene(ctx context.Context, scene string) context.Context {
if scene == "" {
return ctx
}
return context.WithValue(ctx, sceneKey{}, scene)
}
// Scene 返回 ctx 中声明的场景;未声明返回套餐步骤场景。
func Scene(ctx context.Context) string {
if scene, ok := ctx.Value(sceneKey{}).(string); ok && scene != "" {
return scene
}
return constants.StopResumeScenePolling
}
// QueueAware 由停复机服务实现:停复机出口开关开启时请求评估只写共享缓存。
type QueueAware interface {
CommandQueueEnabled(ctx context.Context) bool
}
// RunAfterCommit 在业务事务提交后执行自动停复机请求:开关开启时同步执行(只写共享缓存,不起 goroutine);
// 关闭时沿用旧路径在 goroutine 中当场执行(含网关调用与重试,不阻塞业务流程)。
func RunAfterCommit(ctx context.Context, callback any, fn func()) {
if aware, ok := callback.(QueueAware); ok && aware.CommandQueueEnabled(ctx) {
fn()
return
}
go fn()
}

View File

@@ -3,6 +3,7 @@ package cardobservation
import (
"context"
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"strconv"
"time"
@@ -168,7 +169,7 @@ func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.De
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询网络变化后的卡事实失败")
}
return c.evaluator.EvaluateAndAct(ctx, &card)
return c.evaluator.EvaluateAndAct(stopresumedomain.WithScene(ctx, constants.StopResumeSceneNetworkObserved), &card)
}
// NewRealnameChangedConsumer 创建卡实名变化事件消费者。
@@ -217,7 +218,7 @@ func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.D
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名变化后的卡事实失败")
}
return c.evaluator.EvaluateAndAct(ctx, &card)
return c.evaluator.EvaluateAndAct(stopresumedomain.WithScene(ctx, constants.StopResumeSceneRealnameObserved), &card)
}
// TrafficIncrementedConsumer 消费卡流量正增量并推进幂等副作用进度。
@@ -286,7 +287,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询流量变化后的卡事实失败")
}
if err := c.evaluator.EvaluateAndAct(ctx, &card); err != nil {
if err := c.evaluator.EvaluateAndAct(stopresumedomain.WithScene(ctx, constants.StopResumeSceneFlowObserved), &card); err != nil {
return err
}
}

View File

@@ -145,6 +145,21 @@ type CardPollingTypeState struct {
PendingConfirm []int `json:"pending_confirm" description:"待执行的延迟确认(秒)"`
}
// CardStopResumeCommandState 卡当前停复机指令(停复机指令队列)。
type CardStopResumeCommandState struct {
Action string `json:"action" description:"动作 (stop:停机, start:复机)"`
Reason string `json:"reason" description:"停机原因"`
Scenes []string `json:"scenes" description:"触发场景"`
Source string `json:"source" description:"请求来源"`
Version int64 `json:"version" description:"指令版本"`
FailCount int `json:"fail_count" description:"连续失败次数"`
LastError string `json:"last_error" description:"最近错误(类别|信息)"`
Pending bool `json:"pending" description:"结果未知,待确认"`
Unknown int `json:"unknown" description:"连续结果未知次数"`
WrittenAt *time.Time `json:"written_at" description:"最近写入时间"`
NextAt *time.Time `json:"next_at" description:"下次可执行时间(空表示不在队列中)"`
}
// CardPollingStateResponse 单卡轮询状态(只读)。
type CardPollingStateResponse struct {
CardID uint `json:"card_id" description:"卡ID"`
@@ -158,6 +173,10 @@ type CardPollingStateResponse struct {
ClaimExpireAt *time.Time `json:"claim_expire_at" description:"处理中标记到期时间"`
PriorityMark bool `json:"priority_mark" description:"执行中收到请求检查,收尾后进入优先队列"`
Types []CardPollingTypeState `json:"types" description:"各检查类型状态"`
StopResumeQueueEnabled bool `json:"stop_resume_queue_enabled" description:"停复机出口开关是否开启"`
StopResumeCommand *CardStopResumeCommandState `json:"stop_resume_command" description:"当前停复机指令(无指令为空)"`
LastStopResumeCallAt *time.Time `json:"last_stop_resume_call_at" description:"最近一次停复机网关调用时间(单卡最小重试间隔依据)"`
}
// CarrierPollingStatsItem 按运营商账户的轮询监控。
@@ -175,6 +194,15 @@ type CarrierPollingStatsItem struct {
ExpiredClaims1h int64 `json:"expired_claims_1h" description:"近 1 小时处理中标记到期次数(执行崩溃或超时)"`
OpenAlerts int64 `json:"open_alerts" description:"未处理卡级失败告警数"`
OpenAlertsByType map[string]int64 `json:"open_alerts_by_category" description:"未处理卡级失败告警数(按错误类别)"`
StopResumeQueueLength int64 `json:"stop_resume_queue_length" description:"停复机指令队列长度"`
StopResumeDue int64 `json:"stop_resume_due" description:"已到期停复机指令数"`
StopResumeRunning int64 `json:"stop_resume_running" description:"执行中停复机指令数"`
StopResumePending int64 `json:"stop_resume_pending" description:"结果未知待确认的停复机指令数(按队列前 500 条统计)"`
StopResumeSuccess1h int64 `json:"stop_resume_success_1h" description:"近 1 小时停复机成功数"`
StopResumeFailed1h int64 `json:"stop_resume_failed_1h" description:"近 1 小时停复机失败数(计入连续失败或终态)"`
StopResumeRetry1h int64 `json:"stop_resume_retry_1h" description:"近 1 小时停复机推后重试数(限流、频率限制、最小间隔)"`
StopResumeUnknown1h int64 `json:"stop_resume_unknown_1h" description:"近 1 小时停复机结果未知数"`
}
// CarrierPollingStatsResponse 按运营商账户的轮询监控。

View File

@@ -41,6 +41,12 @@ type CarrierParams struct {
CardMaxInFlight int
PriorityRatio float64
Configured bool // 是否有该账户的配置行
// 停复机指令(add-stop-resume-command-queue)。
SRInflightLimit int // 账户停复机执行上限
SRMaxInFlight int // 每进程停复机消费者 max_in_flight
SRMinInterval time.Duration // 单卡最小重试间隔(停机与复机合计)
SRConfirmDelay time.Duration // 结果未知后的确认延迟
}
// carrierEntry 是分发器缓存的账户信息。
@@ -110,11 +116,16 @@ func (d *Dispatcher) tick(ctx context.Context, interval time.Duration) {
d.logger.Error("分发器 panic,已恢复", zap.Any("panic", r))
}
}()
if !d.Ready(ctx) {
// 停复机指令与设备判断的分发只看停复机出口开关,不受 scheduler_mode 限制;卡任务只在新调度就绪时领取。
stopResumeOn := d.settings.Current(ctx).StopResumeQueueEnabled
cardsReady := d.Ready(ctx)
if !stopResumeOn && !cardsReady {
return
}
d.store.redis.HSet(ctx, constants.RedisCarrierSchedulerHeartbeatKey, d.instanceID, time.Now().UnixMilli())
d.drainLegacyHints(ctx)
if cardsReady {
d.drainLegacyHints(ctx)
}
d.refreshCarriers(ctx)
d.mu.Lock()
carriers := append([]carrierEntry(nil), d.carriers...)
@@ -123,6 +134,9 @@ func (d *Dispatcher) tick(ctx context.Context, interval time.Duration) {
if lockTTL <= 0 {
lockTTL = 900 * time.Millisecond
}
if stopResumeOn {
d.dispatchDeviceEvals(ctx, lockTTL)
}
for _, carrier := range carriers {
if ctx.Err() != nil {
return
@@ -131,10 +145,21 @@ func (d *Dispatcher) tick(ctx context.Context, interval time.Duration) {
if err != nil || !ok {
continue
}
d.dispatchCarrier(ctx, carrier)
// 停复机优先:先领取停复机指令占用本秒名额,再领取卡任务。
if stopResumeOn {
d.dispatchStopResume(ctx, carrier.id)
}
if cardsReady {
d.dispatchCarrier(ctx, carrier)
}
}
}
// StopResumeQueueEnabled 返回停复机出口开关当前值。
func (d *Dispatcher) StopResumeQueueEnabled(ctx context.Context) bool {
return d.settings.Current(ctx).StopResumeQueueEnabled
}
func (d *Dispatcher) dispatchCarrier(ctx context.Context, carrier carrierEntry) {
params := d.ParamsFor(carrier.id)
limit := params.InflightLimit
@@ -265,7 +290,10 @@ func (d *Dispatcher) ParamsFor(carrierID uint) CarrierParams {
// MergeParams 合并账户配置行与全局默认。
func MergeParams(row model.CarrierPollingParam, configured bool, cfg config.CarrierSchedulerConfig) CarrierParams {
params := CarrierParams{InflightLimit: cfg.DefaultInflightLimit, CardMaxInFlight: cfg.DefaultCardMaxInFlight, Configured: configured}
params := CarrierParams{InflightLimit: cfg.DefaultInflightLimit, CardMaxInFlight: cfg.DefaultCardMaxInFlight, Configured: configured,
SRInflightLimit: constants.DefaultStopResumeInflightLimit, SRMaxInFlight: constants.DefaultStopResumeMaxInFlight,
SRMinInterval: constants.DefaultStopResumeMinIntervalSeconds * time.Second,
SRConfirmDelay: constants.DefaultStopResumeConfirmDelaySeconds * time.Second}
if params.InflightLimit <= 0 {
params.InflightLimit = 20
}
@@ -284,6 +312,18 @@ func MergeParams(row model.CarrierPollingParam, configured bool, cfg config.Carr
if row.PriorityMaxRatio != nil && *row.PriorityMaxRatio > 0 {
params.PriorityRatio = *row.PriorityMaxRatio
}
if row.SRInflightLimit != nil && *row.SRInflightLimit > 0 {
params.SRInflightLimit = *row.SRInflightLimit
}
if row.StopResumeMaxInFlight != nil && *row.StopResumeMaxInFlight > 0 {
params.SRMaxInFlight = *row.StopResumeMaxInFlight
}
if row.StopResumeMinInterval != nil && *row.StopResumeMinInterval >= 0 {
params.SRMinInterval = time.Duration(*row.StopResumeMinInterval) * time.Second
}
if row.StopResumeConfirmDelay != nil && *row.StopResumeConfirmDelay > 0 {
params.SRConfirmDelay = time.Duration(*row.StopResumeConfirmDelay) * time.Second
}
return params
}

View File

@@ -51,6 +51,14 @@ func RegisterConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logg
DefaultValue: "600", Description: "账户级告警统计窗口(秒)", Min: &minOne, Max: &maxSeconds},
{Key: constants.SystemConfigPollingAccountAlertThreshold, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt,
DefaultValue: "50", Description: "账户级告警阈值:窗口内同账户新增失败告警数", Min: &minOne, Max: &maxCount},
{Key: constants.SystemConfigStopResumeQueueEnabled, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeBool,
DefaultValue: "false", Description: "自动停复机经停复机指令队列执行(全局开关,关闭即各入口当场执行)"},
{Key: constants.SystemConfigStopResumeUnknownAlert, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt,
DefaultValue: "3", Description: "停复机连续结果未知告警阈值", Min: &minOne, Max: &maxCount},
{Key: constants.SystemConfigStopResumeBackoff, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeJSON,
DefaultValue: `[600,900,1800,3600]`, Description: "停复机失败退避序列(秒,递增)", Validator: validateSequence},
{Key: constants.SystemConfigStopResumeAlertFailCount, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt,
DefaultValue: "5", Description: "停复机连续失败告警阈值", Min: &minOne, Max: &maxCount},
}
for _, definition := range definitions {
if _, exists := registry.Get(definition.Key); exists {
@@ -107,6 +115,23 @@ func validateStepTimeout(raw string) error {
return nil
}
// validateSequence 校验非空递增的正整数秒序列(停复机退避)。
func validateSequence(raw string) error {
var values []int
if err := json.Unmarshal([]byte(raw), &values); err != nil || len(values) == 0 {
return stderrors.New("退避序列必须是非空的秒数组")
}
for index, value := range values {
if value <= 0 {
return stderrors.New("退避间隔必须为正数")
}
if index > 0 && value <= values[index-1] {
return stderrors.New("退避序列必须递增")
}
}
return nil
}
func validateBackoff(raw string) error {
var backoff map[string][]int
if err := json.Unmarshal([]byte(raw), &backoff); err != nil {
@@ -171,6 +196,28 @@ type Settings struct {
ImmediateAlert map[string]bool
AccountAlertWindow time.Duration
AccountAlertLimit int
// 停复机指令队列(add-stop-resume-command-queue)。
StopResumeQueueEnabled bool
SRUnknownAlert int
SRBackoff []time.Duration
SRAlertFailCount int
}
// SRBackoffFor 返回停复机第 n 次连续失败(n 从 1 起)的退避间隔;超过序列长度取最后一项。
func (s Settings) SRBackoffFor(failCount int) time.Duration {
sequence := s.SRBackoff
if len(sequence) == 0 {
sequence = []time.Duration{10 * time.Minute}
}
index := failCount - 1
if index < 0 {
index = 0
}
if index >= len(sequence) {
index = len(sequence) - 1
}
return sequence[index]
}
// CarrierMode 判断当前是否为按运营商账户调度。
@@ -243,6 +290,9 @@ func DefaultSettings() Settings {
AlertFailCount: map[string]int{"default": 5}, AlertFailDuration: 6 * time.Hour,
ImmediateAlert: map[string]bool{constants.GatewayClassAccountConfig: true},
AccountAlertWindow: 10 * time.Minute, AccountAlertLimit: 50,
SRUnknownAlert: 3,
SRBackoff: []time.Duration{10 * time.Minute, 15 * time.Minute, 30 * time.Minute, time.Hour},
SRAlertFailCount: 5,
}
}
@@ -364,6 +414,8 @@ func (p *SettingsProvider) load(ctx context.Context) (Settings, error) {
{constants.SystemConfigPollingAlertFailDuration, func(v int) { s.AlertFailDuration = time.Duration(v) * time.Second }},
{constants.SystemConfigPollingAccountAlertWindow, func(v int) { s.AccountAlertWindow = time.Duration(v) * time.Second }},
{constants.SystemConfigPollingAccountAlertThreshold, func(v int) { s.AccountAlertLimit = v }},
{constants.SystemConfigStopResumeUnknownAlert, func(v int) { s.SRUnknownAlert = v }},
{constants.SystemConfigStopResumeAlertFailCount, func(v int) { s.SRAlertFailCount = v }},
}
for _, item := range ints {
raw, err := get(item.key)
@@ -376,6 +428,21 @@ func (p *SettingsProvider) load(ctx context.Context) (Settings, error) {
}
item.set(value)
}
if raw, err = get(constants.SystemConfigStopResumeQueueEnabled); err != nil {
return s, err
}
s.StopResumeQueueEnabled = raw == "true"
if raw, err = get(constants.SystemConfigStopResumeBackoff); err != nil {
return s, err
}
var srBackoff []int
if err := json.Unmarshal([]byte(raw), &srBackoff); err != nil || validateSequence(raw) != nil {
return s, stderrors.New("停复机退避序列配置非法")
}
s.SRBackoff = nil
for _, value := range srBackoff {
s.SRBackoff = append(s.SRBackoff, time.Duration(value)*time.Second)
}
return s, nil
}

View File

@@ -0,0 +1,120 @@
package carrierqueue
import (
"context"
"encoding/json"
"strconv"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// StopResumeMessage 是停复机指令 NSQ 消息体。
type StopResumeMessage struct {
CardID uint `json:"card_id"`
CarrierID uint `json:"carrier_id"`
Token string `json:"token"`
Version int64 `json:"version"`
Action string `json:"action"`
Pending bool `json:"pending"`
DispatchedAt int64 `json:"dispatched_at"`
}
// DeviceEvalMessage 是设备判断 NSQ 消息体。
type DeviceEvalMessage struct {
DeviceID uint `json:"device_id"`
Token string `json:"token"`
Version string `json:"version"`
Scenes []string `json:"scenes"`
DispatchedAt int64 `json:"dispatched_at"`
}
// deviceEvalLimit 是全局同时执行的设备判断上限:判断只读数据库与写指令,毫秒级。
const deviceEvalLimit = 50
// stopResumeLease 是停复机指令与设备判断的租约:单次执行只调一次网关,远小于该值。
func (d *Dispatcher) stopResumeLease() time.Duration {
return 2 * time.Minute
}
// dispatchStopResume 在卡任务之前领取账户停复机指令:领取数 = min(停复机所在限流组本秒剩余, 执行上限 − 执行中)。
func (d *Dispatcher) dispatchStopResume(ctx context.Context, carrierID uint) {
params := d.ParamsFor(carrierID)
capacity := params.SRInflightLimit
processes := d.cfg.SubscriberProcesses
if processes <= 0 {
processes = 1
}
if consume := params.SRMaxInFlight * processes; consume > 0 && capacity > consume {
capacity = consume
}
if d.budgets != nil {
ops := []string{constants.GatewayOpStop, constants.GatewayOpStart}
if groups, _, limited := d.budgets.Budget(ctx, carrierID, ops); limited {
for _, remaining := range groups {
if remaining < capacity {
capacity = remaining
}
}
}
}
if capacity <= 0 {
return
}
claimed, expired, err := d.store.ClaimStopResume(ctx, SRClaimInput{
CarrierID: carrierID, Lease: d.stopResumeLease(), Capacity: capacity, MinInterval: params.SRMinInterval,
TokenPrefix: d.instanceID + ":sr:" + strconv.FormatInt(time.Now().UnixNano(), 36),
})
if err != nil {
d.logger.Warn("运营商账户停复机指令领取失败", zap.Uint("carrier_id", carrierID), zap.Error(err))
return
}
if expired > 0 {
d.logger.Warn("停复机指令处理中标记到期(执行崩溃或超时)", zap.Uint("carrier_id", carrierID), zap.Int64("count", expired),
zap.String("metric", "stop_resume.claim_expired"))
}
topic := constants.NSQStopResumeTopic(carrierID)
for _, item := range claimed {
body, _ := json.Marshal(StopResumeMessage{CardID: item.CardID, CarrierID: carrierID, Token: item.Token, Version: item.Version,
Action: item.Action, Pending: item.Pending, DispatchedAt: time.Now().UnixMilli()})
if err := d.publisher.Publish(topic, body); err != nil {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
if releaseErr := d.store.ReleaseStopResume(releaseCtx, item.CardID, carrierID, item.Token); releaseErr != nil {
d.logger.Warn("发布失败后释放停复机指令失败", zap.Uint("card_id", item.CardID), zap.Error(releaseErr))
}
cancel()
d.logger.Warn("发布停复机指令失败,已释放", zap.Uint("card_id", item.CardID), zap.String("topic", topic), zap.Error(err))
}
}
}
// dispatchDeviceEvals 领取到期设备判断并发布;全局分发锁保证每轮只有一个实例领取。
func (d *Dispatcher) dispatchDeviceEvals(ctx context.Context, lockTTL time.Duration) {
ok, err := d.store.redis.SetNX(ctx, constants.RedisDeviceEvalDispatchLockKey, d.instanceID, lockTTL).Result()
if err != nil || !ok {
return
}
claimed, expired, err := d.store.ClaimDeviceEvals(ctx, d.stopResumeLease(), deviceEvalLimit,
d.instanceID+":dev:"+strconv.FormatInt(time.Now().UnixNano(), 36))
if err != nil {
d.logger.Warn("设备判断领取失败", zap.Error(err))
return
}
if expired > 0 {
d.logger.Warn("设备判断处理中标记到期(执行崩溃或超时)", zap.Int64("count", expired), zap.String("metric", "stop_resume.device_eval_expired"))
}
for _, item := range claimed {
body, _ := json.Marshal(DeviceEvalMessage{DeviceID: item.DeviceID, Token: item.Token, Version: item.Version, Scenes: item.Scenes,
DispatchedAt: time.Now().UnixMilli()})
if err := d.publisher.Publish(constants.NSQDeviceEvalTopic, body); err != nil {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
if releaseErr := d.store.ReleaseDeviceEval(releaseCtx, item.DeviceID, item.Token); releaseErr != nil {
d.logger.Warn("发布失败后释放设备判断失败", zap.Uint("device_id", item.DeviceID), zap.Error(releaseErr))
}
cancel()
d.logger.Warn("发布设备判断失败,已释放", zap.Uint("device_id", item.DeviceID), zap.Error(err))
}
}
}

View File

@@ -0,0 +1,29 @@
package carrierqueue
import "context"
// CommandQueue 是停复机指令队列写入端:开关读取受控系统配置,写入走共享缓存 Lua。
type CommandQueue struct {
store *Store
settings *SettingsProvider
}
// NewCommandQueue 创建停复机指令队列写入端。
func NewCommandQueue(store *Store, settings *SettingsProvider) *CommandQueue {
return &CommandQueue{store: store, settings: settings}
}
// Enabled 返回停复机出口开关 polling.stop_resume.queue_enabled。
func (q *CommandQueue) Enabled(ctx context.Context) bool {
return q != nil && q.store != nil && q.settings.Current(ctx).StopResumeQueueEnabled
}
// WriteCommand 写入或覆盖卡的停复机指令。
func (q *CommandQueue) WriteCommand(ctx context.Context, cmd StopResumeCommand) (int64, error) {
return q.store.WriteCommand(ctx, cmd)
}
// RequestDeviceEval 请求一次设备维度判断。
func (q *CommandQueue) RequestDeviceEval(ctx context.Context, deviceID uint, scenes []string) error {
return q.store.RequestDeviceEval(ctx, deviceID, scenes)
}

View File

@@ -0,0 +1,225 @@
package carrierqueue
import "github.com/redis/go-redis/v9"
// 停复机指令 Hash 字段(见 constants.RedisCardStopResumeCmdKey)。
const (
srFieldAction = "action" // stop / start
srFieldReason = "reason" // 停机原因(复机为空)
srFieldScenes = "scenes" // 触发场景(逗号分隔)
srFieldSource = "source" // 请求来源
srFieldCarrier = "carrier" // 所属运营商账户
srFieldVersion = "ver" // 指令版本,每次写入 HINCRBY
srFieldFail = "fail" // 连续失败次数
srFieldFirst = "ffail" // 首次失败(毫秒)
srFieldError = "err" // 最近错误(类别|信息)
srFieldPending = "pending" // 1 表示结果未知、待确认
srFieldUnknown = "unknown" // 连续结果未知次数
srFieldAt = "at" // 最近写入时间(毫秒)
)
// srWriteLua 写入或覆盖停复机指令:方向相反时清零失败与待确认,递增版本,放入账户停复机队列。
// 指令执行中(存在处理中标记)时只更新指令内容与版本,由收尾按版本保留;待确认中且方向不变时保持确认时间。
//
// KEYS[1] 指令 Hash;KEYS[2] 账户停复机队列;KEYS[3] 处理中标记。
// ARGV:action、reason、scenes、source、carrier、now_ms、card_id。返回新版本号。
const srWriteLua = `
local prev = redis.call('HGET', KEYS[1], 'action')
if prev and prev ~= ARGV[1] then
redis.call('HDEL', KEYS[1], 'fail', 'ffail', 'err', 'pending', 'unknown')
end
redis.call('HSET', KEYS[1], 'action', ARGV[1], 'reason', ARGV[2], 'scenes', ARGV[3], 'source', ARGV[4], 'carrier', ARGV[5], 'at', ARGV[6])
local ver = redis.call('HINCRBY', KEYS[1], 'ver', 1)
if redis.call('EXISTS', KEYS[3]) == 1 then return ver end
if redis.call('HGET', KEYS[1], 'pending') == '1' then
redis.call('ZADD', KEYS[2], 'NX', tonumber(ARGV[6]), ARGV[7])
else
redis.call('ZADD', KEYS[2], 'LT', tonumber(ARGV[6]), ARGV[7])
end
return ver
`
// srClaimLua 领取账户到期停复机指令:先清理过期执行中,按容量领取;未到单卡最小重试间隔的推后到可调用时间。
// 待确认的指令只查状态,不受最小间隔约束。指令已不存在的成员直接移出队列。
//
// KEYS[1] 账户停复机队列;KEYS[2] 账户停复机执行中计数。
// ARGV:now_ms、lease_ms、容量(执行上限与剩余 QPS 的较小值)、token 前缀、单卡最小间隔 ms。
// 返回 {过期数, card_id, token, ver, action, pending, ...}。
const srClaimLua = `
local now = tonumber(ARGV[1])
local lease = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local prefix = ARGV[4]
local minInterval = tonumber(ARGV[5])
local expired = redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
local result = {expired}
local capacity = limit - redis.call('ZCARD', KEYS[2])
if capacity <= 0 then return result end
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, capacity * 2 + 10)
local taken = 0
for _, id in ipairs(ids) do
if taken >= capacity then break end
local cmdKey = 'polling:card:' .. id .. ':stop_resume_cmd'
local fields = redis.call('HMGET', cmdKey, 'action', 'ver', 'pending')
if not fields[1] then
redis.call('ZREM', KEYS[1], id)
else
local pending = fields[3] == '1'
local blockedUntil = 0
if not pending then
local last = tonumber(redis.call('GET', 'polling:card:' .. id .. ':sr_last_call') or '0')
if last > 0 and last + minInterval > now then blockedUntil = last + minInterval end
end
if blockedUntil > 0 then
redis.call('ZADD', KEYS[1], 'XX', blockedUntil, id)
else
local claimKey = 'polling:card:' .. id .. ':sr_claim'
local token = prefix .. ':' .. id
if redis.call('SET', claimKey, token, 'NX', 'PX', lease) then
redis.call('ZADD', KEYS[1], 'XX', now + lease, id)
redis.call('ZADD', KEYS[2], now + lease, token)
taken = taken + 1
table.insert(result, id)
table.insert(result, token)
table.insert(result, fields[2] or '0')
table.insert(result, fields[1])
table.insert(result, pending and '1' or '0')
else
local ttl = redis.call('PTTL', claimKey)
if ttl < 0 then ttl = lease end
redis.call('ZADD', KEYS[1], 'XX', now + ttl, id)
end
end
end
end
return result
`
// srFinishLua 停复机指令收尾:token 不一致返回 0 不做任何修改;版本已变大(执行期间写入了新指令)时保留新指令、立即可领取(返回 2);
// 否则 done=1 删除指令并移出队列,done=0 写入字段并按 next_ms 重新排队(返回 1)。
//
// KEYS[1] 指令 Hash;KEYS[2] 处理中标记;KEYS[3] 执行权;KEYS[4] 执行中计数;KEYS[5] 账户停复机队列。
// ARGV:token、card_id、领取时版本、done(1/0)、next_ms、now_ms、set 字段对数 n、字段1、值1、…、删除字段…。
const srFinishLua = `
if redis.call('GET', KEYS[2]) ~= ARGV[1] then return 0 end
redis.call('DEL', KEYS[2])
redis.call('DEL', KEYS[3])
redis.call('ZREM', KEYS[4], ARGV[1])
local cardId = ARGV[2]
if (redis.call('HGET', KEYS[1], 'ver') or '0') ~= ARGV[3] then
if redis.call('EXISTS', KEYS[1]) == 1 then
redis.call('ZADD', KEYS[5], tonumber(ARGV[6]), cardId)
else
redis.call('ZREM', KEYS[5], cardId)
end
return 2
end
if ARGV[4] == '1' then
redis.call('DEL', KEYS[1])
redis.call('ZREM', KEYS[5], cardId)
return 1
end
local n = tonumber(ARGV[7])
local idx = 8
for i = 1, n do
redis.call('HSET', KEYS[1], ARGV[idx], ARGV[idx + 1])
idx = idx + 2
end
while idx <= #ARGV do
redis.call('HDEL', KEYS[1], ARGV[idx])
idx = idx + 1
end
redis.call('ZADD', KEYS[5], tonumber(ARGV[5]), cardId)
return 1
`
// srReleaseLua 发布失败:按 token 删除处理中标记、移出执行中计数,score 恢复为当前时间。
// KEYS[1] 处理中标记;KEYS[2] 执行中计数;KEYS[3] 队列。ARGV:token、member、now_ms。
const srReleaseLua = `
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
redis.call('DEL', KEYS[1])
redis.call('ZREM', KEYS[2], ARGV[1])
redis.call('ZADD', KEYS[3], 'XX', tonumber(ARGV[3]), ARGV[2])
return 1
`
// deviceEvalRequestLua 请求设备判断:递增版本、合并场景、设备进入判断队列(同一设备一个成员,ZADD LT 不推后已有更早时间)。
// KEYS[1] 版本;KEYS[2] 场景集合;KEYS[3] 设备判断队列;KEYS[4] 处理中标记。ARGV:device_id、now_ms、场景…。
const deviceEvalRequestLua = `
redis.call('INCR', KEYS[1])
for i = 3, #ARGV do redis.call('SADD', KEYS[2], ARGV[i]) end
if redis.call('EXISTS', KEYS[4]) == 1 then return 1 end
redis.call('ZADD', KEYS[3], 'LT', tonumber(ARGV[2]), ARGV[1])
return 1
`
// deviceEvalClaimLua 领取到期设备判断:写处理中标记并记下版本与场景快照。
// KEYS[1] 设备判断队列;KEYS[2] 执行中计数。ARGV:now_ms、lease_ms、上限、token 前缀。
// 返回 {过期数, device_id, token, ver, scenes, ...}。
const deviceEvalClaimLua = `
local now = tonumber(ARGV[1])
local lease = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local expired = redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
local result = {expired}
local capacity = limit - redis.call('ZCARD', KEYS[2])
if capacity <= 0 then return result end
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, capacity * 2 + 10)
local taken = 0
for _, id in ipairs(ids) do
if taken >= capacity then break end
local claimKey = 'polling:device:' .. id .. ':eval_claim'
local token = ARGV[4] .. ':' .. id
if redis.call('SET', claimKey, token, 'NX', 'PX', lease) then
redis.call('ZADD', KEYS[1], 'XX', now + lease, id)
redis.call('ZADD', KEYS[2], now + lease, token)
taken = taken + 1
local ver = redis.call('GET', 'polling:device:' .. id .. ':eval_ver') or '0'
local scenes = redis.call('SMEMBERS', 'polling:device:' .. id .. ':eval_scenes')
table.insert(result, id)
table.insert(result, token)
table.insert(result, ver)
table.insert(result, table.concat(scenes, ','))
else
local ttl = redis.call('PTTL', claimKey)
if ttl < 0 then ttl = lease end
redis.call('ZADD', KEYS[1], 'XX', now + ttl, id)
end
end
return result
`
// deviceEvalFinishLua 设备判断收尾:只移除本次已处理的场景;版本变大时立即再判断(返回 2);
// defer_ms > 0 表示本次未判断(旧路径持设备锁),推后再判断,场景不移除。
// KEYS[1] 处理中标记;KEYS[2] 执行权;KEYS[3] 执行中计数;KEYS[4] 版本;KEYS[5] 场景集合;KEYS[6] 队列。
// ARGV:token、device_id、领取时版本、now_ms、defer_ms、已处理场景…。
const deviceEvalFinishLua = `
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
redis.call('ZREM', KEYS[3], ARGV[1])
local now = tonumber(ARGV[4])
local deferMs = tonumber(ARGV[5])
if deferMs > 0 then
redis.call('ZADD', KEYS[6], now + deferMs, ARGV[2])
return 3
end
for i = 6, #ARGV do redis.call('SREM', KEYS[5], ARGV[i]) end
if (redis.call('GET', KEYS[4]) or '0') ~= ARGV[3] then
redis.call('ZADD', KEYS[6], now, ARGV[2])
return 2
end
redis.call('ZREM', KEYS[6], ARGV[2])
redis.call('DEL', KEYS[5])
return 1
`
var (
srWriteScript = redis.NewScript(srWriteLua)
srClaimScript = redis.NewScript(srClaimLua)
srFinishScript = redis.NewScript(srFinishLua)
srReleaseScript = redis.NewScript(srReleaseLua)
deviceRequestScript = redis.NewScript(deviceEvalRequestLua)
deviceClaimScript = redis.NewScript(deviceEvalClaimLua)
deviceFinishScript = redis.NewScript(deviceEvalFinishLua)
)

View File

@@ -0,0 +1,348 @@
package carrierqueue
import (
"context"
stderrors "errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// StopResumeCommand 是写入停复机队列的一条指令。
type StopResumeCommand struct {
CardID uint
CarrierID uint
Action string // constants.StopResumeActionStop / StopResumeActionStart
Reason string // 停机原因
Scenes []string // 触发场景
Source string // 请求来源(日志与排查)
}
// CommandState 是卡当前停复机指令的解析结果。
type CommandState struct {
Exists bool
Action string
Reason string
Scenes []string
Source string
CarrierID uint
Version int64
FailCount int
FirstFailed time.Time
LastError string
Pending bool
Unknown int
WrittenAt time.Time
NextAt time.Time // 队列中的可执行时间(零值表示不在队列中)
LastCallAt time.Time
}
// WriteCommand 写入或覆盖卡的停复机指令,返回新版本号。
func (s *Store) WriteCommand(ctx context.Context, cmd StopResumeCommand) (int64, error) {
if cmd.CardID == 0 || cmd.CarrierID == 0 {
return 0, stderrors.New("停复机指令缺少卡或运营商账户")
}
return srWriteScript.Run(ctx, s.redis,
[]string{constants.RedisCardStopResumeCmdKey(cmd.CardID), constants.RedisCarrierStopResumeQueueKey(cmd.CarrierID),
constants.RedisCardStopResumeClaimKey(cmd.CardID)},
cmd.Action, cmd.Reason, constants.JoinStopResumeScenes(cmd.Scenes), cmd.Source, cmd.CarrierID, time.Now().UnixMilli(), cmd.CardID,
).Int64()
}
// SRClaimInput 是一次账户停复机领取的参数。
type SRClaimInput struct {
CarrierID uint
Lease time.Duration
Capacity int // 执行上限与剩余 QPS 的较小值
TokenPrefix string
MinInterval time.Duration
}
// SRClaimed 是一条被领取的停复机指令。
type SRClaimed struct {
CardID uint
Token string
Version int64
Action string
Pending bool
}
// ClaimStopResume 领取账户到期停复机指令,返回领取结果与本轮清理的过期执行数。
func (s *Store) ClaimStopResume(ctx context.Context, input SRClaimInput) ([]SRClaimed, int64, error) {
raw, err := srClaimScript.Run(ctx, s.redis,
[]string{constants.RedisCarrierStopResumeQueueKey(input.CarrierID), constants.RedisCarrierStopResumeRunningKey(input.CarrierID)},
time.Now().UnixMilli(), input.Lease.Milliseconds(), input.Capacity, input.TokenPrefix, input.MinInterval.Milliseconds(),
).Slice()
if err != nil || len(raw) == 0 {
return nil, 0, err
}
expired, _ := raw[0].(int64)
claimed := make([]SRClaimed, 0, (len(raw)-1)/5)
for index := 1; index+4 < len(raw); index += 5 {
cardID, _ := strconv.ParseUint(fmt.Sprint(raw[index]), 10, 64)
version, _ := strconv.ParseInt(fmt.Sprint(raw[index+2]), 10, 64)
claimed = append(claimed, SRClaimed{CardID: uint(cardID), Token: fmt.Sprint(raw[index+1]), Version: version,
Action: fmt.Sprint(raw[index+3]), Pending: fmt.Sprint(raw[index+4]) == "1"})
}
return claimed, expired, nil
}
// AcquireStopResumeExec 抢停复机指令执行权:1 抢到,0 处理中标记已不是该 token,-1 已有执行者。
func (s *Store) AcquireStopResumeExec(ctx context.Context, cardID uint, token, execValue string, ttl time.Duration) (int64, error) {
return acquireExecScript.Run(ctx, s.redis, []string{constants.RedisCardStopResumeClaimKey(cardID), constants.RedisCardStopResumeExecKey(cardID)},
token, execValue, ttl.Milliseconds()).Int64()
}
// SRFinish 是一次停复机指令收尾。
type SRFinish struct {
CardID uint
CarrierID uint
Token string
Version int64
Done bool // true 结束并删除指令
NextAt time.Time
Set map[string]string
Del []string
}
// FinishStopResume 停复机指令收尾:返回 0 token 不一致、1 正常、2 版本已变大保留新指令。
func (s *Store) FinishStopResume(ctx context.Context, finish SRFinish) (int64, error) {
done := "0"
if finish.Done {
done = "1"
}
now := time.Now()
next := finish.NextAt
if next.IsZero() || next.Before(now) {
next = now
}
args := []any{finish.Token, finish.CardID, finish.Version, done, next.UnixMilli(), now.UnixMilli(), len(finish.Set)}
for field, value := range finish.Set {
args = append(args, field, value)
}
for _, field := range finish.Del {
args = append(args, field)
}
return srFinishScript.Run(ctx, s.redis,
[]string{constants.RedisCardStopResumeCmdKey(finish.CardID), constants.RedisCardStopResumeClaimKey(finish.CardID),
constants.RedisCardStopResumeExecKey(finish.CardID), constants.RedisCarrierStopResumeRunningKey(finish.CarrierID),
constants.RedisCarrierStopResumeQueueKey(finish.CarrierID)},
args...).Int64()
}
// ReleaseStopResume 发布失败时释放停复机指令处理中标记并恢复为立即可领取。
func (s *Store) ReleaseStopResume(ctx context.Context, cardID, carrierID uint, token string) error {
return srReleaseScript.Run(ctx, s.redis,
[]string{constants.RedisCardStopResumeClaimKey(cardID), constants.RedisCarrierStopResumeRunningKey(carrierID),
constants.RedisCarrierStopResumeQueueKey(carrierID)},
token, cardID, time.Now().UnixMilli()).Err()
}
// MarkStopResumeCall 记录本卡一次停复机网关调用(停机与复机合计),供单卡最小重试间隔判断。
func (s *Store) MarkStopResumeCall(ctx context.Context, cardID uint, at time.Time, keep time.Duration) error {
if keep < time.Hour {
keep = time.Hour
}
return s.redis.Set(ctx, constants.RedisCardStopResumeLastCallKey(cardID), at.UnixMilli(), keep).Err()
}
// LastStopResumeCall 返回本卡最近一次停复机网关调用时间;没有记录返回零值。
func (s *Store) LastStopResumeCall(ctx context.Context, cardID uint) (time.Time, error) {
ms, err := s.redis.Get(ctx, constants.RedisCardStopResumeLastCallKey(cardID)).Int64()
if stderrors.Is(err, redis.Nil) {
return time.Time{}, nil
}
if err != nil {
return time.Time{}, err
}
return time.UnixMilli(ms), nil
}
// ReadCommand 读取卡当前停复机指令与队列位置。
func (s *Store) ReadCommand(ctx context.Context, cardID uint) (CommandState, error) {
raw, err := s.redis.HGetAll(ctx, constants.RedisCardStopResumeCmdKey(cardID)).Result()
if err != nil {
return CommandState{}, err
}
state := ParseCommand(raw)
if last, lastErr := s.LastStopResumeCall(ctx, cardID); lastErr == nil {
state.LastCallAt = last
}
if state.Exists && state.CarrierID > 0 {
member := strconv.FormatUint(uint64(cardID), 10)
if score, scoreErr := s.redis.ZScore(ctx, constants.RedisCarrierStopResumeQueueKey(state.CarrierID), member).Result(); scoreErr == nil {
state.NextAt = time.UnixMilli(int64(score))
}
}
return state, nil
}
// ParseCommand 解析停复机指令 Hash。
func ParseCommand(raw map[string]string) CommandState {
state := CommandState{Exists: raw[srFieldAction] != ""}
state.Action, state.Reason, state.Source, state.LastError = raw[srFieldAction], raw[srFieldReason], raw[srFieldSource], raw[srFieldError]
state.Scenes = constants.SplitStopResumeScenes(raw[srFieldScenes])
if value, err := strconv.ParseUint(raw[srFieldCarrier], 10, 64); err == nil {
state.CarrierID = uint(value)
}
state.Version, _ = strconv.ParseInt(raw[srFieldVersion], 10, 64)
state.FailCount, _ = strconv.Atoi(raw[srFieldFail])
state.Unknown, _ = strconv.Atoi(raw[srFieldUnknown])
state.Pending = raw[srFieldPending] == "1"
if ms, err := strconv.ParseInt(raw[srFieldFirst], 10, 64); err == nil && ms > 0 {
state.FirstFailed = time.UnixMilli(ms)
}
if ms, err := strconv.ParseInt(raw[srFieldAt], 10, 64); err == nil && ms > 0 {
state.WrittenAt = time.UnixMilli(ms)
}
return state
}
// 停复机指令字段名导出给执行器组装收尾写入。
const (
SRFieldFail = srFieldFail
SRFieldFirst = srFieldFirst
SRFieldError = srFieldError
SRFieldPending = srFieldPending
SRFieldUnknown = srFieldUnknown
)
// RequestDeviceEval 请求一次设备维度判断:递增版本、合并场景、设备进入判断队列。
func (s *Store) RequestDeviceEval(ctx context.Context, deviceID uint, scenes []string) error {
if deviceID == 0 {
return stderrors.New("设备判断缺少设备")
}
args := []any{deviceID, time.Now().UnixMilli()}
for _, scene := range constants.SplitStopResumeScenes(constants.JoinStopResumeScenes(scenes)) {
args = append(args, scene)
}
return deviceRequestScript.Run(ctx, s.redis,
[]string{constants.RedisDeviceEvalVersionKey(deviceID), constants.RedisDeviceEvalScenesKey(deviceID), constants.RedisDeviceEvalQueueKey,
constants.RedisDeviceEvalClaimKey(deviceID)},
args...).Err()
}
// DeviceEvalClaimed 是一次被领取的设备判断。
type DeviceEvalClaimed struct {
DeviceID uint
Token string
Version string
Scenes []string
}
// ClaimDeviceEvals 领取到期设备判断。
func (s *Store) ClaimDeviceEvals(ctx context.Context, lease time.Duration, limit int, tokenPrefix string) ([]DeviceEvalClaimed, int64, error) {
raw, err := deviceClaimScript.Run(ctx, s.redis, []string{constants.RedisDeviceEvalQueueKey, constants.RedisDeviceEvalRunningKey},
time.Now().UnixMilli(), lease.Milliseconds(), limit, tokenPrefix).Slice()
if err != nil || len(raw) == 0 {
return nil, 0, err
}
expired, _ := raw[0].(int64)
claimed := make([]DeviceEvalClaimed, 0, (len(raw)-1)/4)
for index := 1; index+3 < len(raw); index += 4 {
deviceID, _ := strconv.ParseUint(fmt.Sprint(raw[index]), 10, 64)
claimed = append(claimed, DeviceEvalClaimed{DeviceID: uint(deviceID), Token: fmt.Sprint(raw[index+1]),
Version: fmt.Sprint(raw[index+2]), Scenes: constants.SplitStopResumeScenes(fmt.Sprint(raw[index+3]))})
}
return claimed, expired, nil
}
// AcquireDeviceEvalLock 抢设备判断锁(执行权):1 抢到,0 处理中标记已不是该 token,-1 已有判断在执行。
func (s *Store) AcquireDeviceEvalLock(ctx context.Context, deviceID uint, token, value string, ttl time.Duration) (int64, error) {
return acquireExecScript.Run(ctx, s.redis, []string{constants.RedisDeviceEvalClaimKey(deviceID), constants.RedisDeviceEvalLockKey(deviceID)},
token, value, ttl.Milliseconds()).Int64()
}
// FinishDeviceEval 设备判断收尾:返回 0 token 不一致、1 完成、2 版本变大已重新排队、3 已推后。
func (s *Store) FinishDeviceEval(ctx context.Context, claimed DeviceEvalClaimed, deferFor time.Duration) (int64, error) {
args := []any{claimed.Token, claimed.DeviceID, claimed.Version, time.Now().UnixMilli(), deferFor.Milliseconds()}
for _, scene := range claimed.Scenes {
args = append(args, scene)
}
return deviceFinishScript.Run(ctx, s.redis,
[]string{constants.RedisDeviceEvalClaimKey(claimed.DeviceID), constants.RedisDeviceEvalLockKey(claimed.DeviceID),
constants.RedisDeviceEvalRunningKey, constants.RedisDeviceEvalVersionKey(claimed.DeviceID),
constants.RedisDeviceEvalScenesKey(claimed.DeviceID), constants.RedisDeviceEvalQueueKey},
args...).Int64()
}
// ReleaseDeviceEval 发布失败时释放设备判断处理中标记。
func (s *Store) ReleaseDeviceEval(ctx context.Context, deviceID uint, token string) error {
return srReleaseScript.Run(ctx, s.redis,
[]string{constants.RedisDeviceEvalClaimKey(deviceID), constants.RedisDeviceEvalRunningKey, constants.RedisDeviceEvalQueueKey},
token, deviceID, time.Now().UnixMilli()).Err()
}
// StopResumeQueueStats 是账户停复机队列的即时统计。
type StopResumeQueueStats struct {
Queued int64
Due int64
Running int64
Pending int64
}
// StopResumeStats 读取账户停复机队列长度、到期数与执行中数;待确认数按队列成员抽样统计(最多 500 个)。
func (s *Store) StopResumeStats(ctx context.Context, carrierID uint) (StopResumeQueueStats, error) {
now := strconv.FormatInt(time.Now().UnixMilli(), 10)
queueKey := constants.RedisCarrierStopResumeQueueKey(carrierID)
pipe := s.redis.Pipeline()
queued := pipe.ZCard(ctx, queueKey)
due := pipe.ZCount(ctx, queueKey, "-inf", now)
running := pipe.ZCount(ctx, constants.RedisCarrierStopResumeRunningKey(carrierID), now, "+inf")
members := pipe.ZRange(ctx, queueKey, 0, 499)
if _, err := pipe.Exec(ctx); err != nil && !stderrors.Is(err, redis.Nil) {
return StopResumeQueueStats{}, err
}
stats := StopResumeQueueStats{Queued: queued.Val(), Due: due.Val(), Running: running.Val()}
if ids := members.Val(); len(ids) > 0 {
check := s.redis.Pipeline()
results := make([]*redis.StringCmd, 0, len(ids))
for _, id := range ids {
cardID, convErr := strconv.ParseUint(strings.TrimSpace(id), 10, 64)
if convErr != nil {
continue
}
results = append(results, check.HGet(ctx, constants.RedisCardStopResumeCmdKey(uint(cardID)), srFieldPending))
}
_, _ = check.Exec(ctx)
for _, result := range results {
if result.Val() == "1" {
stats.Pending++
}
}
}
return stats, nil
}
// IncrStopResumeStat 累加账户停复机执行计数(10 分钟分桶,保留 2 小时)。
func (s *Store) IncrStopResumeStat(ctx context.Context, carrierID uint, field string) {
key := constants.RedisCarrierStopResumeStatKey(carrierID, time.Now().Unix()/constants.StopResumeStatBucketSeconds)
pipe := s.redis.Pipeline()
pipe.HIncrBy(ctx, key, field, 1)
pipe.Expire(ctx, key, 2*time.Hour)
_, _ = pipe.Exec(ctx)
}
// StopResumeStatLastHour 汇总账户近 1 小时(最近 6 个分桶)的停复机执行计数。
func (s *Store) StopResumeStatLastHour(ctx context.Context, carrierID uint) map[string]int64 {
current := time.Now().Unix() / constants.StopResumeStatBucketSeconds
pipe := s.redis.Pipeline()
cmds := make([]*redis.MapStringStringCmd, 0, 6)
for bucket := current - 5; bucket <= current; bucket++ {
cmds = append(cmds, pipe.HGetAll(ctx, constants.RedisCarrierStopResumeStatKey(carrierID, bucket)))
}
_, _ = pipe.Exec(ctx)
totals := map[string]int64{}
for _, cmd := range cmds {
for field, value := range cmd.Val() {
count, _ := strconv.ParseInt(value, 10, 64)
totals[field] += count
}
}
return totals
}

View File

@@ -2,6 +2,7 @@ package polling
import (
"context"
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"time"
"github.com/bytedance/sonic"
@@ -435,16 +436,17 @@ func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, c
if h.stopResumeCallback == nil {
return
}
detachedCtx := context.WithoutCancel(ctx)
// 套餐到期即停:以"套餐到期"场景请求评估,暂停轮询的卡同样停机(只触发一次,不做补偿扫描)。
detachedCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeScenePackageExpired)
if carrierType == "iot_card" {
go func() {
stopresumedomain.RunAfterCommit(detachedCtx, h.stopResumeCallback, func() {
if err := h.stopResumeCallback.CheckAndStopCard(detachedCtx, carrierID); err != nil {
h.logger.Error("套餐过期后停机失败",
zap.Uint("card_id", carrierID),
zap.Error(err))
}
}()
})
return
}
@@ -457,14 +459,14 @@ func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, c
return
}
for _, b := range bindings {
cardID := b.IotCardID
go func(cID uint) {
cID := b.IotCardID
stopresumedomain.RunAfterCommit(detachedCtx, h.stopResumeCallback, func() {
if err := h.stopResumeCallback.CheckAndStopCard(detachedCtx, cID); err != nil {
h.logger.Error("套餐过期后停机失败",
zap.Uint("card_id", cID),
zap.Error(err))
}
}(cardID)
})
}
}
}
@@ -482,6 +484,7 @@ func (h *PackageActivationHandler) reconcileCarrierAfterActivation(ctx context.C
if carrierID == 0 {
return errors.New(errors.CodeInvalidParam, "套餐使用记录缺少有效载体")
}
ctx = stopresumedomain.WithScene(ctx, constants.StopResumeSceneActivation)
if carrierType == constants.AssetTypeIotCard {
if err := h.stopResumeCallback.CheckAndStopCard(ctx, carrierID); err != nil {

View File

@@ -3,6 +3,7 @@ package carrier
import (
"context"
"fmt"
"go.uber.org/zap"
"strconv"
"time"
@@ -26,6 +27,22 @@ type Service struct {
audit systemconfigapp.AuditWriter
// thresholdHits 是通道阈值命中记录只读查询;与运营商配置共用同一数据库连接。
thresholdHits *carrierthresholdquery.Query
// realnameEvaluator 实名管控修改后对受影响的卡批量请求停复机评估;未注入时不触发。
realnameEvaluator RealnameControlEvaluator
logger *zap.Logger
}
// RealnameControlEvaluator 是实名管控修改后批量请求停复机评估的能力(停复机出口开关关闭时不执行)。
type RealnameControlEvaluator interface {
RequestRealnameControlEvaluation(ctx context.Context, carrierID uint, enabled bool) (int, error)
}
// SetRealnameControlEvaluator 注入实名管控修改后的批量评估能力。
func (s *Service) SetRealnameControlEvaluator(evaluator RealnameControlEvaluator, logger *zap.Logger) {
if logger == nil {
logger = zap.NewNop()
}
s.realnameEvaluator, s.logger = evaluator, logger
}
func New(carrierStore *postgres.CarrierStore, audit systemconfigapp.AuditWriter) *Service {
@@ -210,10 +227,24 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
s.recordFailure(ctx, constants.AuditOperationCarrierUpdate, "更新运营商配置失败", carrier)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新运营商失败")
}
if before.RealnameControlEnabled != carrier.RealnameControlEnabled && s.realnameEvaluator != nil {
s.requestRealnameControlEvaluation(ctx, carrier.ID, carrier.RealnameControlEnabled == constants.StatusEnabled)
}
return s.toResponse(ctx, carrier), nil
}
// requestRealnameControlEvaluation 在事务提交后异步批量请求评估(只读库与写共享缓存,不阻塞保存接口)。
func (s *Service) requestRealnameControlEvaluation(ctx context.Context, carrierID uint, enabled bool) {
evalCtx := context.WithoutCancel(ctx)
go func() {
defer func() { _ = recover() }()
if _, err := s.realnameEvaluator.RequestRealnameControlEvaluation(evalCtx, carrierID, enabled); err != nil {
s.logger.Error("实名管控修改后批量请求停复机评估失败", zap.Uint("carrier_id", carrierID), zap.Error(err))
}
}()
}
func (s *Service) Delete(ctx context.Context, id uint) error {
carrier, err := s.carrierStore.GetByID(ctx, id)
if err != nil {

View File

@@ -2006,7 +2006,7 @@ func (s *Service) handleManualRealnameStatusChanged(ctx context.Context, oldStat
}
if s.stopResumeService != nil {
if err := s.stopResumeService.EvaluateAndAct(ctx, card); err != nil {
if err := s.stopResumeService.EvaluateAndAct(WithStopResumeScene(ctx, constants.StopResumeSceneRealnameObserved), card); err != nil {
s.logger.Warn("手动实名后触发停复机评估失败",
zap.Uint("card_id", card.ID),
zap.Error(err))

View File

@@ -0,0 +1,190 @@
package iot_card
import (
"context"
stderrors "errors"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 停复机指令执行器使用的服务能力(add-stop-resume-command-queue)。执行器只调用一次网关,不 sleep 重试。
// CommandDecision 是执行前按指令场景重新判断的结果。
type CommandDecision struct {
Card *model.IotCard
Action string // 空表示条件不再满足或已是目标状态,结束指令
Reason string
Gone bool // 卡已不存在
}
// DecideCommand 执行前按指令记录的场景重新判断该卡(只看本卡,不扩散到设备)。
// 复机持通道阈值锁时视为不再满足。
func (s *StopResumeService) DecideCommand(ctx context.Context, cardID uint, scenes []string) (CommandDecision, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return CommandDecision{Gone: true}, nil
}
return CommandDecision{}, errors.Wrap(errors.CodeDatabaseError, err, "读取卡失败")
}
decision, err := s.decideCard(ctx, card, scenes)
if err != nil {
return CommandDecision{Card: card}, err
}
if decision.Action == constants.StopResumeActionStart {
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
if blockErr != nil {
return CommandDecision{Card: card}, blockErr
}
if blocked {
return CommandDecision{Card: card}, nil
}
}
return CommandDecision{Card: card, Action: decision.Action, Reason: decision.Reason}, nil
}
// CommandCallResult 是一次停复机网关调用的结果。
type CommandCallResult struct {
// Unknown 为 true 表示结果未知:超时、连接失败,或上游已成功但本地回写失败。
Unknown bool
}
// ExecuteCommand 对卡调用一次停机或复机(限流等待模式由调用方 ctx 声明),成功后按现有回写更新卡状态、
// 停机原因与网络状态序列,并写停复机记录。返回的 error 为网关错误或回写错误,由执行器按错误分类处理。
func (s *StopResumeService) ExecuteCommand(ctx context.Context, card *model.IotCard, action, reason string) (CommandCallResult, error) {
if card == nil {
return CommandCallResult{}, errors.New(errors.CodeInvalidParam)
}
if action == constants.StopResumeActionStop {
outcome, err := s.stopCardAttempts(ctx, card, reason, 1)
return CommandCallResult{Unknown: err != nil && outcome.Result == constants.AuditResultUnknown && !gateway.IsRateLimited(err)}, err
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
attempt, integrationID, err := s.resumeCardAttempts(ctx, card, actionCode, summary, 1)
if err != nil {
return CommandCallResult{Unknown: isUnknownGatewayError(err)}, err
}
if err := s.completeAutoResume(ctx, card, attempt, integrationID, actionCode, summary); err != nil {
return CommandCallResult{Unknown: true}, err
}
return CommandCallResult{}, nil
}
// isUnknownGatewayError 判断网关错误是否为结果未知(超时、连接失败):请求可能已到达上游。
func isUnknownGatewayError(err error) bool {
gwErr, ok := gateway.AsGatewayError(err)
if !ok {
return false
}
return gwErr.Category == constants.GatewayErrorTimeout || gwErr.Category == constants.GatewayErrorNetwork
}
// QueryCommandCardStatus 结果未知后的确认查询:只查一次卡状态(受限流、不受单卡最小重试间隔约束)。
// 查询失败时 known=false 并返回错误,调用方保持待确认、不发起停复机。
func (s *StopResumeService) QueryCommandCardStatus(ctx context.Context, card *model.IotCard) (int, bool, error) {
if s.gatewayClient == nil {
return 0, false, errors.New(errors.CodeInternalError, "Gateway 未配置,无法查询卡状态")
}
queryCtx := gateway.WithCarrier(ctx, card.CarrierID)
response, err := s.gatewayClient.QueryCardStatus(queryCtx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
return 0, false, err
}
status, known := carddomain.MapGatewayNetworkStatus(response.CardStatus, response.Extend)
return status, known, nil
}
// ConfirmCommandState 按已确认的上游结果(确认查询已是目标状态、上游返回已是目标状态、停机遇上游终态)回写卡状态。
func (s *StopResumeService) ConfirmCommandState(ctx context.Context, card *model.IotCard, action, reason string) error {
if card == nil {
return errors.New(errors.CodeInvalidParam)
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络(确认)"
scene, expected := constants.CardObservationSceneBusinessResume, "online"
fields := map[string]any{"network_status": constants.NetworkStatusOnline, "resumed_at": time.Now(), "stop_reason": ""}
if action == constants.StopResumeActionStop {
actionCode, summary = stopAuditAction(ctx, reason)
summary += "(确认)"
scene, expected = constants.CardObservationSceneBusinessStop, "offline"
fields = map[string]any{"network_status": constants.NetworkStatusOffline, "stopped_at": time.Now(), "stop_reason": reason}
}
if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields, scene, expected, uuid.NewString(), actionCode, summary, ""); err != nil {
return err
}
s.reschedulePolling(ctx, card.ID)
return nil
}
// EvaluateDeviceCommands 设备维度判断:读取设备下各卡最新数据,按统一判断函数(各卡按所属运营商账户判断实名,
// 暂停轮询按场景集合判断)为需要动作的卡生成指令,进入各卡所属账户的停复机队列。只读数据库与写共享缓存,不调网关。
func (s *StopResumeService) EvaluateDeviceCommands(ctx context.Context, deviceID uint, scenes []string) error {
if s.commandQueue == nil {
return errors.New(errors.CodeInternalError, "停复机指令队列未配置")
}
cards, err := s.deviceCards(ctx, deviceID)
if err != nil {
return err
}
var failed []error
for _, card := range cards {
decision, decideErr := s.decideCard(ctx, card, scenes)
if decideErr != nil {
failed = append(failed, decideErr)
continue
}
if decision.Action == "" {
s.markNotRealnameIfBlocked(ctx, card, scenes)
continue
}
decision.DeviceID = 0
if writeErr := s.writeCardCommand(ctx, card, decision, scenes, "device_eval"); writeErr != nil {
failed = append(failed, writeErr)
}
}
if len(failed) > 0 {
return errors.Wrap(errors.CodeInternalError, stderrors.Join(failed...), "设备判断存在未写入指令的卡")
}
return nil
}
// markNotRealnameIfBlocked 设备下停机卡套餐满足但实名不满足时,把停机原因更新为未实名(与旧设备复机路径一致)。
func (s *StopResumeService) markNotRealnameIfBlocked(ctx context.Context, card *model.IotCard, scenes []string) {
if card.NetworkStatus != constants.NetworkStatusOffline || card.StopReason == constants.StopReasonNotRealname {
return
}
if suspended, err := s.autoControlSuspended(ctx, card); err != nil || suspended {
return
}
hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil || !hasPackage {
return
}
if exhausted, err := s.isTrafficExhausted(ctx, card); err != nil || exhausted {
return
}
realnameOK, err := s.isRealnameOK(ctx, card)
if err != nil || realnameOK {
return
}
if err := s.updateCardStopReasonWithAudit(ctx, card, constants.StopReasonNotRealname); err != nil {
s.logger.Warn("更新未实名卡停机原因失败", zap.Uint("card_id", card.ID), zap.Strings("scenes", scenes), zap.Error(err))
}
}
// LegacyDeviceOpLocked 判断旧路径是否正在对该设备执行停复机(持设备锁);设备判断须推后,不并行执行。
func (s *StopResumeService) LegacyDeviceOpLocked(ctx context.Context, deviceID uint) bool {
if s.redis == nil {
return false
}
exists, err := s.redis.Exists(ctx, constants.RedisPollingDeviceOpLockKey(deviceID)).Result()
return err == nil && exists > 0
}

View File

@@ -0,0 +1,92 @@
package iot_card
import (
"context"
"go.uber.org/zap"
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// WithStopResumeScene 声明本次自动停复机评估的触发场景;未声明时按套餐步骤(周期评估)处理。
func WithStopResumeScene(ctx context.Context, scene string) context.Context {
return stopresumedomain.WithScene(ctx, scene)
}
// stopResumeScenes 返回 ctx 中声明的场景集合(单个场景)。
func stopResumeScenes(ctx context.Context) []string {
return []string{stopresumedomain.Scene(ctx)}
}
// stopResumeDecision 是统一判断函数的结果。
type stopResumeDecision struct {
Action string // constants.StopResumeActionStop / StopResumeActionStart;空表示不动作
Reason string // 停机原因
Reasons []string // 全部命中的停机原因(按优先级)
DeviceID uint // 设备维度动作的设备;0 表示单卡
}
// decideCard 统一判断函数:暂停轮询(按场景) → 停机条件 → 复机条件。
// 暂停轮询的卡只在套餐失效类场景(套餐到期、退款)下放行无套餐停机,其余自动停复机一律不做。
// 设备维度原因(无套餐、流量耗尽)与绑定设备的复机返回 DeviceID,由调用方按设备处理。通道阈值锁由复机执行方判断。
func (s *StopResumeService) decideCard(ctx context.Context, card *model.IotCard, scenes []string) (stopResumeDecision, error) {
suspended, err := s.autoControlSuspended(ctx, card)
if err != nil {
return stopResumeDecision{}, err
}
allowSuspendedStop := constants.StopResumeSceneAllowsSuspendedStop(scenes)
if suspended && !allowSuspendedStop {
return stopResumeDecision{}, nil
}
switch card.NetworkStatus {
case constants.NetworkStatusOnline:
reasons, err := s.checkStopReasons(ctx, card)
if err != nil || len(reasons) == 0 {
return stopResumeDecision{}, err
}
primary := reasons[0]
if suspended {
// 暂停轮询:只有套餐失效(无可用主套餐)才停机。
if !containsStopReason(reasons, constants.StopReasonNoPackage) {
return stopResumeDecision{}, nil
}
primary = constants.StopReasonNoPackage
}
decision := stopResumeDecision{Action: constants.StopResumeActionStop, Reason: primary, Reasons: reasons}
if !card.IsStandalone && isDeviceScopeReason(primary) {
decision.DeviceID = s.boundDeviceID(ctx, card)
}
return decision, nil
case constants.NetworkStatusOffline:
if suspended {
return stopResumeDecision{}, nil
}
// 不看停机原因,是否复机只由本系统规则决定。
resume, err := s.shouldResume(ctx, card)
if err != nil || !resume {
return stopResumeDecision{}, err
}
decision := stopResumeDecision{Action: constants.StopResumeActionStart}
if !card.IsStandalone {
decision.DeviceID = s.boundDeviceID(ctx, card)
}
return decision, nil
default:
return stopResumeDecision{}, nil
}
}
// boundDeviceID 返回卡的有效绑定设备;查询失败时降级为单卡处理并记日志。
func (s *StopResumeService) boundDeviceID(ctx context.Context, card *model.IotCard) uint {
deviceID, bound, err := s.getCardDeviceID(ctx, card)
if err != nil {
s.logger.Error("查询设备绑定关系失败,降级为单卡处理", zap.Uint("card_id", card.ID), zap.Error(err))
return 0
}
if !bound {
return 0
}
return deviceID
}

View File

@@ -0,0 +1,70 @@
package iot_card
import (
"context"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// realnameControlBatchSize 是实名管控修改后分批读取受影响卡的批大小。
const realnameControlBatchSize = 500
// RequestRealnameControlEvaluation 运营商账户实名管控修改后对受影响的卡批量请求评估(只在停复机出口开关开启时执行):
// 0 → 1 时为该账户在线且未实名的卡,1 → 0 时为停机且未实名的卡;绑定设备的卡按设备请求设备判断。
// 停复机按账户 QPS 由指令队列平滑执行。返回请求评估的卡与设备数。
func (s *StopResumeService) RequestRealnameControlEvaluation(ctx context.Context, carrierID uint, enabled bool) (int, error) {
if !s.commandQueueEnabled(ctx) || s.db == nil {
return 0, nil
}
ctx = WithStopResumeScene(ctx, constants.StopResumeSceneRealnameControl)
scenes := []string{constants.StopResumeSceneRealnameControl}
networkStatus := constants.NetworkStatusOffline
if enabled {
networkStatus = constants.NetworkStatusOnline
}
requested := 0
devices := map[uint]bool{}
lastID := uint(0)
for {
var cards []*model.IotCard
if err := s.db.WithContext(ctx).Where("carrier_id = ? AND network_status = ? AND real_name_status <> ? AND id > ?",
carrierID, networkStatus, constants.RealNameStatusVerified, lastID).
Order("id").Limit(realnameControlBatchSize).Find(&cards).Error; err != nil {
return requested, errors.Wrap(errors.CodeDatabaseError, err, "查询实名管控受影响的卡失败")
}
if len(cards) == 0 {
break
}
for _, card := range cards {
lastID = card.ID
if deviceID := s.boundDeviceID(ctx, card); deviceID > 0 {
if !devices[deviceID] {
devices[deviceID] = true
if err := s.requestDeviceEval(ctx, deviceID, scenes, "realname_control"); err == nil {
requested++
}
}
continue
}
decision, err := s.decideCard(ctx, card, scenes)
if err != nil {
s.logger.Warn("实名管控修改后判断卡失败", zap.Uint("card_id", card.ID), zap.Error(err))
continue
}
if decision.Action == "" {
continue
}
decision.DeviceID = 0
if err := s.writeCardCommand(ctx, card, decision, scenes, "realname_control"); err == nil {
requested++
}
}
}
s.logger.Info("运营商账户实名管控修改后已批量请求评估", zap.Uint("carrier_id", carrierID), zap.Bool("enabled", enabled),
zap.Int("requested", requested), zap.String("metric", "stop_resume.realname_control_requested"))
return requested, nil
}

View File

@@ -0,0 +1,144 @@
package iot_card
import (
"context"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// StopResumeCommandQueue 是停复机指令队列的写入接缝(共享缓存)。
type StopResumeCommandQueue interface {
// Enabled 返回停复机出口开关 polling.stop_resume.queue_enabled。
Enabled(ctx context.Context) bool
WriteCommand(ctx context.Context, cmd carrierqueue.StopResumeCommand) (int64, error)
RequestDeviceEval(ctx context.Context, deviceID uint, scenes []string) error
}
// SetCommandQueue 注入停复机指令队列;未注入时始终当场执行。
func (s *StopResumeService) SetCommandQueue(queue StopResumeCommandQueue) {
s.commandQueue = queue
}
// CommandQueueEnabled 返回停复机出口开关是否开启(供调用方决定是否还需要异步执行)。
func (s *StopResumeService) CommandQueueEnabled(ctx context.Context) bool {
return s.commandQueueEnabled(ctx)
}
func (s *StopResumeService) commandQueueEnabled(ctx context.Context) bool {
return s != nil && s.commandQueue != nil && s.commandQueue.Enabled(ctx)
}
// RequestAssetEvaluation 请求评估门面:开关开启时单卡生成指令、设备请求设备判断;关闭时走旧路径当场执行。
// assetType 为 constants.AssetTypeIotCard / AssetTypeDevice。
func (s *StopResumeService) RequestAssetEvaluation(ctx context.Context, assetType string, assetID uint, scene, source string) error {
ctx = WithStopResumeScene(ctx, scene)
if !s.commandQueueEnabled(ctx) {
return s.legacyAssetEvaluation(ctx, assetType, assetID)
}
if assetType == constants.AssetTypeDevice {
return s.requestDeviceEval(ctx, assetID, []string{scene}, source)
}
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取卡失败")
}
scenes := []string{scene}
decision, err := s.decideCard(ctx, card, scenes)
if err != nil {
return err
}
return s.submitDecisionWithSource(ctx, card, decision, scenes, source)
}
// legacyAssetEvaluation 开关关闭时的当场执行:单卡按统一判断执行,设备按设备复机(套餐激活、重置、订单)或逐卡评估(其他场景)。
func (s *StopResumeService) legacyAssetEvaluation(ctx context.Context, assetType string, assetID uint) error {
if assetType == constants.AssetTypeDevice {
cards, err := s.deviceCards(ctx, assetID)
if err != nil {
return err
}
for _, card := range cards {
if err := s.EvaluateAndAct(ctx, card); err != nil {
return err
}
}
return nil
}
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取卡失败")
}
return s.EvaluateAndAct(ctx, card)
}
// submitDecision 把统一判断结果写入指令队列(来源取场景)。
func (s *StopResumeService) submitDecision(ctx context.Context, card *model.IotCard, decision stopResumeDecision, scenes []string) error {
return s.submitDecisionWithSource(ctx, card, decision, scenes, "")
}
func (s *StopResumeService) submitDecisionWithSource(ctx context.Context, card *model.IotCard, decision stopResumeDecision, scenes []string, source string) error {
if decision.Action == "" {
return nil
}
if decision.DeviceID > 0 {
return s.requestDeviceEval(ctx, decision.DeviceID, scenes, source)
}
return s.writeCardCommand(ctx, card, decision, scenes, source)
}
// writeCardCommand 生成单卡停复机指令;复机前检查通道阈值锁,持锁时记录拒绝事实不生成指令。
func (s *StopResumeService) writeCardCommand(ctx context.Context, card *model.IotCard, decision stopResumeDecision, scenes []string, source string) error {
if decision.Action == constants.StopResumeActionStart {
blocked, err := s.channelThresholdBlocked(ctx, card)
if err != nil {
return err
}
if blocked {
s.recordChannelThresholdDenial(ctx, card, constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络")
return nil
}
}
if source == "" {
source = constants.JoinStopResumeScenes(scenes)
}
version, err := s.commandQueue.WriteCommand(ctx, carrierqueue.StopResumeCommand{
CardID: card.ID, CarrierID: card.CarrierID, Action: decision.Action, Reason: decision.Reason, Scenes: scenes, Source: source,
})
if err != nil {
// 写入失败只记日志:由该卡下一次套餐步骤兜底。
s.logger.Error("写入停复机指令失败", zap.Uint("card_id", card.ID), zap.String("action", decision.Action),
zap.Strings("scenes", scenes), zap.String("metric", "stop_resume.request_failed"), zap.Error(err))
return errors.Wrap(errors.CodeRedisError, err, "写入停复机指令失败")
}
s.logger.Info("已生成停复机指令", zap.Uint("card_id", card.ID), zap.Uint("carrier_id", card.CarrierID),
zap.String("action", decision.Action), zap.String("reason", decision.Reason), zap.Strings("scenes", scenes), zap.Int64("version", version))
return nil
}
// requestDeviceEval 请求一次设备维度判断。
func (s *StopResumeService) requestDeviceEval(ctx context.Context, deviceID uint, scenes []string, source string) error {
if err := s.commandQueue.RequestDeviceEval(ctx, deviceID, scenes); err != nil {
s.logger.Error("请求设备判断失败", zap.Uint("device_id", deviceID), zap.Strings("scenes", scenes), zap.String("source", source),
zap.String("metric", "stop_resume.request_failed"), zap.Error(err))
return errors.Wrap(errors.CodeRedisError, err, "请求设备判断失败")
}
return nil
}
// deviceCards 返回设备下有效绑定的全部卡(在线与停机)。
func (s *StopResumeService) deviceCards(ctx context.Context, deviceID uint) ([]*model.IotCard, error) {
online, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOnline)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备在线卡失败")
}
offline, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOffline)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备停机卡失败")
}
return append(online, offline...), nil
}

View File

@@ -60,6 +60,8 @@ type StopResumeService struct {
stopResumeRecorder *gatewaylimit.StopResumeRecorder
// terminalPolling 停复机命中"自动关闭轮询"终态规则时关闭该卡轮询;未注入时按普通失败处理。
terminalPolling *gatewaylimit.TerminalPollingDisabler
// commandQueue 停复机指令队列;开关开启时自动入口只请求评估。
commandQueue StopResumeCommandQueue
}
// SetStopResumeRecorder 注入停复机记录写入器。
@@ -128,69 +130,36 @@ func (s *StopResumeService) SetPollingCallback(callback PollingCallback) {
s.pollingCallback = callback
}
// EvaluateAndAct 停复机统一入口
// 根据卡的当前网络状态,自动判断并执行停机或复机操作
// 设备/单卡维度通过 card.IsStandalone 推导(false 则为设备维度,停/复机覆盖设备下所有卡)
// EvaluateAndAct 停复机统一入口:按统一判断函数决定停机或复机。
// 停复机出口开关开启时只请求评估(生成指令或请求设备判断),不当场调用网关;关闭时当场执行。
// 设备/单卡维度通过 card.IsStandalone 推导(false 则为设备维度,停/复机覆盖设备下所有卡)。触发场景见 WithStopResumeScene。
func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotCard) error {
// 总开关开启时,卡或所在设备暂停轮询的不自动停复机。
suspended, err := s.autoControlSuspended(ctx, card)
scenes := stopResumeScenes(ctx)
decision, err := s.decideCard(ctx, card, scenes)
if err != nil {
return err
}
if suspended {
return nil
// 无有效套餐且存在待生效套餐使用记录:仅由普通套餐轮询来源追加优先轮询请求。
if containsStopReason(decision.Reasons, constants.StopReasonNoPackage) {
if err := s.appendNoValidPackagePriority(ctx, card); err != nil {
return err
}
}
switch card.NetworkStatus {
case constants.NetworkStatusOnline:
// 卡在线,检查是否需要停机
reasons, err := s.checkStopReasons(ctx, card)
if err != nil {
return err
if s.commandQueueEnabled(ctx) {
return s.submitDecision(ctx, card, decision, scenes)
}
switch decision.Action {
case constants.StopResumeActionStop:
if decision.DeviceID > 0 {
return s.stopDeviceCards(ctx, decision.DeviceID, decision.Reason)
}
if len(reasons) == 0 {
return nil
}
// 无有效套餐且存在待生效套餐使用记录:仅由普通套餐轮询来源追加优先轮询请求。
if containsStopReason(reasons, constants.StopReasonNoPackage) {
if err := s.appendNoValidPackagePriority(ctx, card); err != nil {
return err
}
}
// 取最高优先级原因(第一个)
primaryReason := reasons[0]
if !card.IsStandalone && isDeviceScopeReason(primaryReason) {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil {
s.logger.Error("查询设备绑定关系失败,降级为单卡停机",
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
// 降级:查不到绑定时按单卡处理
} else if bound {
return s.stopDeviceCards(ctx, deviceID, primaryReason)
}
}
_, stopErr := s.stopCardWithRetry(ctx, card, primaryReason)
_, stopErr := s.stopCardWithRetry(ctx, card, decision.Reason)
return stopErr
case constants.NetworkStatusOffline:
// 卡停机,检查是否可以复机:不看停机原因,是否复机只由本系统规则决定
shouldResume, err := s.shouldResume(ctx, card)
if err != nil {
return err
}
if !shouldResume {
return nil
}
if !card.IsStandalone {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil {
s.logger.Error("查询设备绑定关系失败,降级为单卡复机",
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
} else if bound {
return s.resumeDeviceCards(ctx, deviceID)
}
case constants.StopResumeActionStart:
if decision.DeviceID > 0 {
return s.resumeDeviceCards(ctx, decision.DeviceID)
}
return s.resumeSingleCard(ctx, card.ID)
default:
return nil
}
@@ -414,11 +383,11 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
var cardErrors []error
for _, card := range cards {
// 总开关开启时,暂停轮询的卡不随设备自动停机。
// 暂停轮询的卡不随设备自动停机;套餐失效类场景(套餐到期、退款)照常停机。
if suspended, suspendErr := s.autoControlSuspended(ctx, card); suspendErr != nil {
cardErrors = append(cardErrors, suspendErr)
continue
} else if suspended {
} else if suspended && !(stopReason == constants.StopReasonNoPackage && constants.StopResumeSceneAllowsSuspendedStop(stopResumeScenes(ctx))) {
continue
}
if _, stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
@@ -503,6 +472,9 @@ func (s *StopResumeService) CheckAndStopCard(ctx context.Context, cardID uint) e
// ResumeCardIfStopped 套餐激活或流量重置后自动复机入口
// 支持 iot_card 和 device 两种载体类型
func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error {
if s.commandQueueEnabled(ctx) {
return s.RequestAssetEvaluation(ctx, carrierType, carrierID, stopResumeScenes(ctx)[0], "resume_if_stopped")
}
switch carrierType {
case "iot_card":
return s.resumeSingleCard(ctx, carrierID)
@@ -582,7 +554,12 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
zap.Error(err))
return err
}
return s.completeAutoResume(ctx, card, attempt, integrationID, actionCode, summary)
}
// completeAutoResume 自动复机网关成功后回写在线状态、观测序列与统一审计,并重排轮询。
// 回写失败时返回错误,调用方按结果未知处理。
func (s *StopResumeService) completeAutoResume(ctx context.Context, card *model.IotCard, attempt *gatewayAttempt, integrationID, actionCode, summary string) error {
now := time.Now()
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
"network_status": constants.NetworkStatusOnline,
@@ -593,7 +570,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
s.logger.Error("复机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", cardID),
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Error(err))
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
@@ -608,7 +585,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
}
s.logger.Info("卡已自动复机",
zap.Uint("card_id", cardID),
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID))
return nil
@@ -619,6 +596,11 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
// 返回的 outcome 始终携带可回填可靠任务的结果分类(成功/失败/未知)与最后一次尝试的
// Integration Log 标识;error 与既有语义一致,表示本次停机未成功。
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) (carrierthresholddomain.CommandOutcome, error) {
return s.stopCardAttempts(ctx, card, stopReason, s.maxRetries)
}
// stopCardAttempts 调用运营商停机接口最多 attempts 次(停复机指令执行器只调用一次),成功后回写停机状态。
func (s *StopResumeService) stopCardAttempts(ctx context.Context, card *model.IotCard, stopReason string, attempts int) (carrierthresholddomain.CommandOutcome, error) {
actionCode, summary := stopAuditAction(ctx, stopReason)
if s.gatewayClient == nil {
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
@@ -641,7 +623,7 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
gatewayCtx := gateway.WithCarrier(gateway.WithAttemptObserver(ctx, attemptObserver), card.CarrierID)
var lastErr error
lastIntegrationID := ""
for i := 0; i < s.maxRetries; i++ {
for i := 0; i < attempts; i++ {
if i > 0 {
s.logger.Debug("重试调用停机接口",
zap.Int("attempt", i+1),
@@ -732,6 +714,11 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
// resumeCardWithRetry 调用运营商复机接口(带重试机制)。
// 第二个返回值是最后一次尝试的 Integration Log 标识(无论成功或失败),供调用方回填可靠任务状态。
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard, actionCode, summary string) (*gatewayAttempt, string, error) {
return s.resumeCardAttempts(ctx, card, actionCode, summary, s.maxRetries)
}
// resumeCardAttempts 调用运营商复机接口最多 attempts 次,不回写卡状态。
func (s *StopResumeService) resumeCardAttempts(ctx context.Context, card *model.IotCard, actionCode, summary string, attempts int) (*gatewayAttempt, string, error) {
if s.gatewayClient == nil {
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
@@ -751,7 +738,7 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
gatewayCtx := gateway.WithCarrier(gateway.WithAttemptObserver(ctx, attemptObserver), card.CarrierID)
var lastErr error
lastIntegrationID := ""
for i := 0; i < s.maxRetries; i++ {
for i := 0; i < attempts; i++ {
if i > 0 {
s.logger.Debug("重试调用复机接口",
zap.Int("attempt", i+1),

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/binary"
"fmt"
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"sort"
"strconv"
"strings"
@@ -2202,8 +2203,9 @@ func (s *Service) tryResumeAfterPayment(ctx context.Context, order *model.Order)
return
}
resumeCtx := context.WithoutCancel(ctx)
go func(ct string, cid uint) {
resumeCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeSceneOrder)
ct, cid := resumeCarrierType, resumeCarrierID
stopresumedomain.RunAfterCommit(resumeCtx, s.resumeCallback, func() {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, ct, cid); err != nil {
s.logger.Error("支付后自动复机失败",
zap.String("carrier_type", ct),
@@ -2211,7 +2213,7 @@ func (s *Service) tryResumeAfterPayment(ctx context.Context, order *model.Order)
zap.Uint("order_id", order.ID),
zap.Error(err))
}
}(resumeCarrierType, resumeCarrierID)
})
}
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {

View File

@@ -2,6 +2,7 @@ package packagepkg
import (
"context"
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"strconv"
"time"
@@ -778,15 +779,15 @@ func (s *ActivationService) resumeAfterActivation(ctx context.Context, carrierTy
if s.resumeCallback == nil {
return
}
resumeCtx := context.WithoutCancel(ctx)
go func() {
resumeCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeSceneActivation)
stopresumedomain.RunAfterCommit(resumeCtx, s.resumeCallback, func() {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, carrierType, carrierID); err != nil {
s.logger.Error(errorMessage,
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
}()
})
}
func (s *ActivationService) appendActivationObservation(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint, occurredAt time.Time) error {

View File

@@ -2,6 +2,7 @@ package packagepkg
import (
"context"
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"time"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
@@ -381,7 +382,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []
return
}
resumeCtx := context.WithoutCancel(ctx)
resumeCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeSceneReset)
for _, pkg := range packages {
var carrierType string
var carrierID uint
@@ -396,13 +397,14 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []
continue
}
go func(ct string, cid uint) {
ct, cid := carrierType, carrierID
stopresumedomain.RunAfterCommit(resumeCtx, s.resumeCallback, func() {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, ct, cid); err != nil {
s.logger.Warn("流量重置后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
})
}
}

View File

@@ -2,6 +2,7 @@ package packagepkg
import (
"context"
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"math"
"strconv"
"time"
@@ -449,15 +450,15 @@ func (s *UsageService) triggerSuspensionAfterCommit(ctx context.Context, carrier
return
}
stopCtx := context.WithoutCancel(ctx)
stopCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeSceneUsage)
if carrierType == constants.AssetTypeIotCard {
go func() {
stopresumedomain.RunAfterCommit(stopCtx, s.stopResumeCallback, func() {
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", carrierID),
zap.Error(err))
}
}()
})
return
}
@@ -479,14 +480,14 @@ func (s *UsageService) triggerSuspensionAfterCommit(ctx context.Context, carrier
}
for _, b := range bindings {
cardID := b.IotCardID
go func(cID uint) {
cID := b.IotCardID
stopresumedomain.RunAfterCommit(stopCtx, s.stopResumeCallback, func() {
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", cID),
zap.Error(err))
}
}(cardID)
})
}
}

View File

@@ -503,6 +503,28 @@ func (s *CarrierSchedulerService) CardState(ctx context.Context, cardID uint) (*
}
resp.Types = append(resp.Types, state)
}
resp.StopResumeQueueEnabled = s.settings.Current(ctx).StopResumeQueueEnabled
command, err := s.store.ReadCommand(ctx, card.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "读取卡停复机指令失败")
}
if !command.LastCallAt.IsZero() {
resp.LastStopResumeCallAt = timePtr(command.LastCallAt)
}
if command.Exists {
item := &dto.CardStopResumeCommandState{Action: command.Action, Reason: command.Reason, Scenes: command.Scenes, Source: command.Source,
Version: command.Version, FailCount: command.FailCount, LastError: command.LastError, Pending: command.Pending, Unknown: command.Unknown}
if item.Scenes == nil {
item.Scenes = []string{}
}
if !command.WrittenAt.IsZero() {
item.WrittenAt = timePtr(command.WrittenAt)
}
if !command.NextAt.IsZero() {
item.NextAt = timePtr(command.NextAt)
}
resp.StopResumeCommand = item
}
return resp, nil
}
@@ -571,6 +593,13 @@ func (s *CarrierSchedulerService) CarrierStats(ctx context.Context) (*dto.Carrie
item.RateLimited += atoi64(values[constants.GatewayErrorRateLimited])
}
item.QPSLastMinute = float64(attempts) / 60
if srStats, srErr := s.store.StopResumeStats(ctx, carrierID); srErr == nil {
item.StopResumeQueueLength, item.StopResumeDue = srStats.Queued, srStats.Due
item.StopResumeRunning, item.StopResumePending = srStats.Running, srStats.Pending
}
srCounts := s.store.StopResumeStatLastHour(ctx, carrierID)
item.StopResumeSuccess1h, item.StopResumeFailed1h = srCounts[constants.StopResumeStatSuccess], srCounts[constants.StopResumeStatFailed]
item.StopResumeRetry1h, item.StopResumeUnknown1h = srCounts[constants.StopResumeStatRetry], srCounts[constants.StopResumeStatUnknown]
for _, alert := range alertRows {
if alert.CarrierID == carrierID {
item.OpenAlerts += alert.Count

View File

@@ -1243,6 +1243,20 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) bool {
logger := s.logger
// 停复机出口开关开启:只请求评估("退款"场景,暂停轮询的资产同样停机),写入成功即视为资产后处理完成;
// 停机失败由指令队列退避与告警处理,不再触发 Outbox 重投与补偿扫描。
if s.stopResumeService != nil && s.stopResumeService.CommandQueueEnabled(ctx) {
evalType := constants.AssetTypeIotCard
if assetType == constants.AssetTypeDevice {
evalType = constants.AssetTypeDevice
}
if err := s.stopResumeService.RequestAssetEvaluation(ctx, evalType, assetID, constants.StopResumeSceneRefund, "refund"); err != nil {
logger.Error("退款资产处理:请求停机评估失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Error(err))
return false
}
return true
}
switch assetType {
case "iot_card":
// 单卡停机:需要先查卡获取 iccid

View File

@@ -0,0 +1,70 @@
package task
import (
"context"
"encoding/json"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
)
// 设备判断的推后时长:旧路径持设备锁时、判断失败时。
const (
deviceEvalLegacyLockDelay = 5 * time.Second
deviceEvalFailureDelay = 30 * time.Second
deviceEvalTimeout = 30 * time.Second
)
// DeviceEvalExecutor 设备维度判断执行器:抢设备判断锁,读取最新数据为设备下各卡生成指令;锁内不调网关。
type DeviceEvalExecutor struct {
store *carrierqueue.Store
service StopResumeCommandService
instanceID string
logger *zap.Logger
}
// NewDeviceEvalExecutor 创建设备判断执行器。
func NewDeviceEvalExecutor(store *carrierqueue.Store, service StopResumeCommandService, instanceID string, logger *zap.Logger) *DeviceEvalExecutor {
if logger == nil {
logger = zap.NewNop()
}
return &DeviceEvalExecutor{store: store, service: service, instanceID: instanceID, logger: logger}
}
// Handle 消费一条设备判断消息。收尾只移除本次已处理的场景;版本变大时用最新数据再判断一次。
func (e *DeviceEvalExecutor) Handle(ctx context.Context, body []byte, _ func()) {
var msg carrierqueue.DeviceEvalMessage
if err := json.Unmarshal(body, &msg); err != nil || msg.DeviceID == 0 || msg.Token == "" {
e.logger.Warn("设备判断消息无效,丢弃", zap.ByteString("body", body))
return
}
got, err := e.store.AcquireDeviceEvalLock(ctx, msg.DeviceID, msg.Token, msg.Token+"|"+e.instanceID, 2*deviceEvalTimeout)
if err != nil || got != 1 {
return
}
claimed := carrierqueue.DeviceEvalClaimed{DeviceID: msg.DeviceID, Token: msg.Token, Version: msg.Version, Scenes: msg.Scenes}
deferFor := time.Duration(0)
if e.service.LegacyDeviceOpLocked(ctx, msg.DeviceID) {
// 旧路径正在对该设备执行停复机:推后,不并行判断。
deferFor = deviceEvalLegacyLockDelay
} else {
evalCtx, cancel := context.WithTimeout(ctx, deviceEvalTimeout)
if evalErr := e.service.EvaluateDeviceCommands(evalCtx, msg.DeviceID, msg.Scenes); evalErr != nil {
e.logger.Warn("设备判断失败,稍后再判断", zap.Uint("device_id", msg.DeviceID), zap.Strings("scenes", msg.Scenes), zap.Error(evalErr))
deferFor = deviceEvalFailureDelay
}
cancel()
}
finishCtx, finishCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer finishCancel()
result, err := e.store.FinishDeviceEval(finishCtx, claimed, deferFor)
if err != nil {
e.logger.Warn("设备判断收尾失败,等待租约到期后重新领取", zap.Uint("device_id", msg.DeviceID), zap.Error(err))
return
}
if result == 2 {
e.logger.Info("设备判断期间有新请求,按最新数据再判断", zap.Uint("device_id", msg.DeviceID))
}
}

View File

@@ -0,0 +1,343 @@
package task
import (
"context"
"encoding/json"
"strconv"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// StopResumeCommandService 是停复机指令执行器依赖的停复机服务能力。
type StopResumeCommandService interface {
DecideCommand(ctx context.Context, cardID uint, scenes []string) (iot_card_svc.CommandDecision, error)
ExecuteCommand(ctx context.Context, card *model.IotCard, action, reason string) (iot_card_svc.CommandCallResult, error)
QueryCommandCardStatus(ctx context.Context, card *model.IotCard) (int, bool, error)
ConfirmCommandState(ctx context.Context, card *model.IotCard, action, reason string) error
EvaluateDeviceCommands(ctx context.Context, deviceID uint, scenes []string) error
LegacyDeviceOpLocked(ctx context.Context, deviceID uint) bool
}
// stopResumeTaskTimeout 是一条停复机指令的总执行时长上限(一次判断 + 一次网关调用)。
const stopResumeTaskTimeout = 60 * time.Second
// stopResumeRateLimitedDelay 本账户限流拒绝或等待超时(请求未发出)后的重排延迟,不计失败。
const stopResumeRateLimitedDelay = 5 * time.Second
// StopResumeExecutor 停复机指令执行器:执行前按场景重新判断,只调用一次网关,按错误分类退避与告警,结果未知在队列内确认。
type StopResumeExecutor struct {
store *carrierqueue.Store
settings *carrierqueue.SettingsProvider
params func(carrierID uint) carrierqueue.CarrierParams
alerts *carrierqueue.AlertWriter
service StopResumeCommandService
classifier *gatewaylimit.Classifier
instanceID string
logger *zap.Logger
}
// NewStopResumeExecutor 创建停复机指令执行器。
func NewStopResumeExecutor(store *carrierqueue.Store, settings *carrierqueue.SettingsProvider, params func(uint) carrierqueue.CarrierParams,
alerts *carrierqueue.AlertWriter, service StopResumeCommandService, classifier *gatewaylimit.Classifier, instanceID string, logger *zap.Logger) *StopResumeExecutor {
if logger == nil {
logger = zap.NewNop()
}
return &StopResumeExecutor{store: store, settings: settings, params: params, alerts: alerts, service: service,
classifier: classifier, instanceID: instanceID, logger: logger}
}
// srOutcome 是一次执行的收尾决定。
type srOutcome struct {
stat string // 计数字段覆盖;statSkip 表示不计数
done bool
nextAt time.Time
set map[string]string
del []string
}
// statSkip 表示本次执行不计入监控计数(指令不再需要、卡已不存在)。
const statSkip = "-"
func srDone() srOutcome { return srOutcome{done: true} }
func srEnded() srOutcome { return srOutcome{done: true, stat: statSkip} }
func srRetry(at time.Time) srOutcome {
return srOutcome{nextAt: at, set: map[string]string{}}
}
// Handle 消费一条停复机指令消息;返回后由消费者 FIN。重试与退避全部由 Redis score 表达。
func (e *StopResumeExecutor) Handle(ctx context.Context, body []byte, _ func()) {
var msg carrierqueue.StopResumeMessage
if err := json.Unmarshal(body, &msg); err != nil || msg.CardID == 0 || msg.Token == "" {
e.logger.Warn("停复机指令消息无效,丢弃", zap.ByteString("body", body))
return
}
execValue := msg.Token + "|" + e.instanceID
got, err := e.store.AcquireStopResumeExec(ctx, msg.CardID, msg.Token, execValue, 2*stopResumeTaskTimeout)
if err != nil || got != 1 {
return
}
taskCtx, cancel := context.WithTimeout(ctx, stopResumeTaskTimeout)
outcome := e.execute(taskCtx, msg)
cancel()
finishCtx, finishCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer finishCancel()
e.countOutcome(finishCtx, msg.CarrierID, outcome)
result, err := e.store.FinishStopResume(finishCtx, carrierqueue.SRFinish{
CardID: msg.CardID, CarrierID: msg.CarrierID, Token: msg.Token, Version: msg.Version,
Done: outcome.done, NextAt: outcome.nextAt, Set: outcome.set, Del: outcome.del,
})
switch {
case err != nil:
e.logger.Warn("停复机指令收尾失败,等待租约到期后重新领取", zap.Uint("card_id", msg.CardID), zap.Error(err))
case result == 0:
e.logger.Warn("停复机指令收尾时处理中标记已不一致", zap.Uint("card_id", msg.CardID), zap.String("metric", "stop_resume.finish_token_mismatch"))
case result == 2:
e.logger.Info("停复机指令执行期间已被新指令覆盖,保留新指令", zap.Uint("card_id", msg.CardID), zap.Int64("claimed_version", msg.Version))
}
}
func (e *StopResumeExecutor) execute(ctx context.Context, msg carrierqueue.StopResumeMessage) (outcome srOutcome) {
defer func() {
if r := recover(); r != nil {
e.logger.Error("停复机指令执行 panic,稍后重试", zap.Uint("card_id", msg.CardID), zap.Any("panic", r))
outcome = srRetry(time.Now().Add(time.Minute))
}
}()
cmd, err := e.store.ReadCommand(ctx, msg.CardID)
if err != nil {
return srRetry(time.Now().Add(time.Minute))
}
if !cmd.Exists {
return srEnded()
}
decision, err := e.service.DecideCommand(ctx, msg.CardID, cmd.Scenes)
if err != nil {
e.logger.Warn("停复机指令执行前判断失败,稍后重试", zap.Uint("card_id", msg.CardID), zap.Error(err))
return srRetry(time.Now().Add(time.Minute))
}
if decision.Gone {
return srEnded()
}
if decision.Action != cmd.Action {
// 条件不再满足或已是目标状态:结束指令,不调用上游。
e.logger.Info("停复机指令执行前重新判断不再需要,结束指令", zap.Uint("card_id", msg.CardID),
zap.String("action", cmd.Action), zap.String("decided", decision.Action), zap.Strings("scenes", cmd.Scenes))
e.recovered(cmd, decision.Card)
return srEnded()
}
card := decision.Card
reason := decision.Reason
params := e.params(card.CarrierID)
settings := e.settings.Current(ctx)
gatewayCtx := gateway.WithRateLimitWait(ctx, 20*time.Second)
if !cmd.Pending {
return e.call(gatewayCtx, card, cmd, reason, params, settings)
}
if confirmed, next := e.confirm(gatewayCtx, card, cmd, reason, params, settings); confirmed != nil {
return *confirmed
} else if next != nil {
return *next
}
// 确认不是目标状态且已满足单卡最小间隔:清除待确认后重新调用。
result := e.call(gatewayCtx, card, cmd, reason, params, settings)
if !result.done && result.set[carrierqueue.SRFieldPending] != "1" {
result.del = append(result.del, carrierqueue.SRFieldPending)
}
return result
}
// confirm 处理待确认指令:查一次卡状态。已是目标状态返回结束;查询失败保持待确认按退避重查;
// 不是目标状态时清除待确认,未到单卡最小间隔则推后,否则返回 (nil, nil) 由调用方重新发起停复机。
func (e *StopResumeExecutor) confirm(ctx context.Context, card *model.IotCard, cmd carrierqueue.CommandState, reason string,
params carrierqueue.CarrierParams, settings carrierqueue.Settings) (*srOutcome, *srOutcome) {
status, known, err := e.service.QueryCommandCardStatus(ctx, card)
if gateway.IsRateLimited(err) {
retry := srRetry(time.Now().Add(stopResumeRateLimitedDelay))
return nil, &retry
}
if err != nil || !known {
retry := srRetry(time.Now().Add(settings.SRBackoffFor(cmd.Unknown)))
if err != nil {
retry.set[carrierqueue.SRFieldError] = truncateRunes(constants.StopResumeFailureUnknown+"|确认查询失败: "+err.Error(), 300)
}
return nil, &retry
}
if status == targetNetworkStatus(cmd.Action) {
if confirmErr := e.service.ConfirmCommandState(ctx, card, cmd.Action, reason); confirmErr != nil {
e.logger.Warn("确认已是目标状态但回写失败,稍后再确认", zap.Uint("card_id", card.ID), zap.Error(confirmErr))
retry := srRetry(time.Now().Add(time.Minute))
return nil, &retry
}
e.logger.Info("停复机结果未知,确认查询已是目标状态,按成功结束", zap.Uint("card_id", card.ID), zap.String("action", cmd.Action))
e.recovered(cmd, card)
done := srDone()
return &done, nil
}
last, _ := e.store.LastStopResumeCall(ctx, card.ID)
if allowed := last.Add(params.SRMinInterval); !last.IsZero() && allowed.After(time.Now()) {
retry := srRetry(allowed)
retry.del = []string{carrierqueue.SRFieldPending}
return nil, &retry
}
return nil, nil
}
// call 调用一次停机或复机并按结果分类处理。
func (e *StopResumeExecutor) call(ctx context.Context, card *model.IotCard, cmd carrierqueue.CommandState, reason string,
params carrierqueue.CarrierParams, settings carrierqueue.Settings) srOutcome {
previousCall, _ := e.store.LastStopResumeCall(ctx, card.ID)
now := time.Now()
if err := e.store.MarkStopResumeCall(ctx, card.ID, now, 2*params.SRMinInterval); err != nil {
return srRetry(now.Add(time.Minute))
}
result, err := e.service.ExecuteCommand(ctx, card, cmd.Action, reason)
if err == nil {
e.logger.Info("停复机指令执行成功", zap.Uint("card_id", card.ID), zap.String("action", cmd.Action), zap.String("reason", reason),
zap.Strings("scenes", cmd.Scenes), zap.String("metric", "stop_resume.success"))
e.recovered(cmd, card)
return srDone()
}
if gateway.IsRateLimited(err) {
// 本账户限流:请求未发出,恢复最近调用时间,短延迟重排,不计失败。
e.restoreLastCall(ctx, card.ID, previousCall, params)
return srRetry(time.Now().Add(stopResumeRateLimitedDelay))
}
classification := e.classify(ctx, cmd.Action, err)
if result.Unknown || classification.MaybeAccepted {
return e.unknown(card, cmd, err, params, settings)
}
message := truncateRunes(classification.Category+"|"+err.Error(), 300)
switch classification.Category {
case constants.GatewayClassAlreadyTarget:
if confirmErr := e.service.ConfirmCommandState(ctx, card, cmd.Action, reason); confirmErr != nil {
return e.unknown(card, cmd, confirmErr, params, settings)
}
e.recovered(cmd, card)
return srDone()
case constants.GatewayClassTerminal:
// 终态不可操作:不重试、结束指令、写告警;停机方向上游已不可用时按目标状态视为已停。
if cmd.Action == constants.StopResumeActionStop {
if confirmErr := e.service.ConfirmCommandState(ctx, card, cmd.Action, reason); confirmErr != nil {
e.logger.Warn("停机遇上游终态,回写已停失败", zap.Uint("card_id", card.ID), zap.Error(confirmErr))
}
}
e.reportFailure(card, cmd, classification.Category, err.Error(), cmd.FailCount+1, now)
return srOutcome{done: true, stat: constants.StopResumeStatFailed}
case constants.GatewayClassRateLimitedUpstream:
retry := srRetry(now.Add(maxDuration(params.SRMinInterval, settings.SRBackoffFor(1))))
retry.set[carrierqueue.SRFieldError] = message
return retry
}
failCount := cmd.FailCount + 1
retry := srRetry(now.Add(settings.SRBackoffFor(failCount)))
retry.set[carrierqueue.SRFieldFail] = strconv.Itoa(failCount)
retry.set[carrierqueue.SRFieldError] = message
firstFailed := cmd.FirstFailed
if firstFailed.IsZero() {
firstFailed = now
retry.set[carrierqueue.SRFieldFirst] = strconv.FormatInt(now.UnixMilli(), 10)
}
if failCount >= settings.SRAlertFailCount || settings.ImmediateAlert[classification.Category] {
e.reportFailure(card, cmd, classification.Category, err.Error(), failCount, firstFailed)
}
e.logger.Warn("停复机指令执行失败,按退避重试", zap.Uint("card_id", card.ID), zap.String("action", cmd.Action),
zap.String("category", classification.Category), zap.Int("fail_count", failCount), zap.Time("next_at", retry.nextAt),
zap.String("metric", "stop_resume.failed"), zap.Error(err))
return retry
}
// unknown 结果未知:置待确认,确认延迟后先判断再查状态;连续未知达阈值告警,指令不丢弃。
func (e *StopResumeExecutor) unknown(card *model.IotCard, cmd carrierqueue.CommandState, err error, params carrierqueue.CarrierParams,
settings carrierqueue.Settings) srOutcome {
unknown := cmd.Unknown + 1
retry := srRetry(time.Now().Add(params.SRConfirmDelay))
retry.set[carrierqueue.SRFieldPending] = "1"
retry.set[carrierqueue.SRFieldUnknown] = strconv.Itoa(unknown)
retry.set[carrierqueue.SRFieldError] = truncateRunes(constants.StopResumeFailureUnknown+"|"+err.Error(), 300)
if unknown >= settings.SRUnknownAlert {
e.reportFailure(card, cmd, constants.StopResumeFailureUnknown, err.Error(), unknown, cmd.WrittenAt)
}
e.logger.Warn("停复机结果未知,确认延迟后查状态", zap.Uint("card_id", card.ID), zap.String("action", cmd.Action),
zap.Int("unknown", unknown), zap.String("metric", "stop_resume.unknown"), zap.Error(err))
return retry
}
func (e *StopResumeExecutor) classify(ctx context.Context, action string, err error) gatewaylimit.Classification {
gwErr, ok := gateway.AsGatewayError(err)
if !ok || gwErr.Category != constants.GatewayErrorUpstream {
return gatewaylimit.Classification{Category: constants.GatewayClassTransient}
}
op := constants.GatewayOpStop
if action == constants.StopResumeActionStart {
op = constants.GatewayOpStart
}
return e.classifier.Classify(ctx, op, gwErr.ProviderMessage)
}
func (e *StopResumeExecutor) restoreLastCall(ctx context.Context, cardID uint, previous time.Time, params carrierqueue.CarrierParams) {
if previous.IsZero() {
e.store.Client().Del(ctx, constants.RedisCardStopResumeLastCallKey(cardID))
return
}
_ = e.store.MarkStopResumeCall(ctx, cardID, previous, 2*params.SRMinInterval)
}
func (e *StopResumeExecutor) reportFailure(card *model.IotCard, cmd carrierqueue.CommandState, category, message string, failCount int, firstFailed time.Time) {
if firstFailed.IsZero() {
firstFailed = time.Now()
}
e.alerts.ReportFailure(carrierqueue.FailureEvent{CardID: card.ID, ICCID: card.ICCID, CarrierID: card.CarrierID,
CheckType: constants.CheckTypeStopResume, Category: category, Message: cmd.Action + ": " + message,
FailCount: failCount, FirstFailed: firstFailed, At: time.Now()})
}
func (e *StopResumeExecutor) recovered(cmd carrierqueue.CommandState, card *model.IotCard) {
if card != nil && (cmd.FailCount > 0 || cmd.Unknown > 0) {
e.alerts.ReportRecovered(card.ID, constants.CheckTypeStopResume)
}
}
func targetNetworkStatus(action string) int {
if action == constants.StopResumeActionStop {
return constants.NetworkStatusOffline
}
return constants.NetworkStatusOnline
}
func maxDuration(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}
// countOutcome 累加执行计数:结束为成功,置待确认为结果未知,带失败次数为失败,其余推后为重试。
func (e *StopResumeExecutor) countOutcome(ctx context.Context, carrierID uint, outcome srOutcome) {
if outcome.stat == statSkip {
return
}
field := constants.StopResumeStatRetry
switch {
case outcome.stat != "":
field = outcome.stat
case outcome.done:
field = constants.StopResumeStatSuccess
case outcome.set[carrierqueue.SRFieldPending] == "1":
field = constants.StopResumeStatUnknown
case outcome.set[carrierqueue.SRFieldFail] != "":
field = constants.StopResumeStatFailed
}
e.store.IncrStopResumeStat(ctx, carrierID, field)
}

View File

@@ -0,0 +1,4 @@
-- 回滚停复机指令队列的受控系统配置。
DELETE FROM tb_system_config WHERE config_key IN (
'polling.stop_resume.queue_enabled', 'polling.stop_resume.unknown_alert_threshold',
'polling.stop_resume.backoff_seconds', 'polling.stop_resume.alert_fail_count');

View File

@@ -0,0 +1,16 @@
-- 停复机指令队列的受控系统配置(add-stop-resume-command-queue),module = polling。
-- 配置的权威定义(类型、取值范围、默认值、JSON 结构校验)在代码注册表中;本迁移只落库初始值。
-- 已存在同名 Key 时不覆盖(ON CONFLICT DO NOTHING)。
--
-- 1. polling.stop_resume.queue_enabled:自动停复机经停复机指令队列执行的全局开关,默认关闭;关闭即各入口当场执行(回退)。
-- 2. polling.stop_resume.unknown_alert_threshold:停复机连续结果未知达到该次数时写失败告警,指令不丢弃。
-- 3. polling.stop_resume.backoff_seconds:停复机失败退避序列(秒,递增),超过序列长度按最后一项重试。
-- 4. polling.stop_resume.alert_fail_count:停复机连续失败告警阈值。
-- 账户级停复机参数(执行上限、max_in_flight、单卡最小重试间隔、确认延迟)已在 tb_carrier_polling_param 建列(000251)。
INSERT INTO tb_system_config (config_key, config_value, value_type, module, description, is_readonly, is_sensitive, creator, updater, created_at, updated_at) VALUES
('polling.stop_resume.queue_enabled', 'false', 'bool', 'polling', '自动停复机经停复机指令队列执行(全局开关,关闭即各入口当场执行)', FALSE, FALSE, 0, 0, NOW(), NOW()),
('polling.stop_resume.unknown_alert_threshold', '3', 'int', 'polling', '停复机连续结果未知告警阈值', FALSE, FALSE, 0, 0, NOW(), NOW()),
('polling.stop_resume.backoff_seconds', '[600,900,1800,3600]', 'json', 'polling', '停复机失败退避序列(秒,递增)', FALSE, FALSE, 0, 0, NOW(), NOW()),
('polling.stop_resume.alert_fail_count', '5', 'int', 'polling', '停复机连续失败告警阈值', FALSE, FALSE, 0, 0, NOW(), NOW())
ON CONFLICT (config_key) DO NOTHING;

View File

@@ -1,50 +1,50 @@
## 0. 前置确认
- [ ] 0.1 确认第 1、4、5 个 Change 已上线。
- [x] 0.1 确认第 1、4、5 个 Change 已上线。(第 1、4、5 个 Change 已部署测试环境;生产按第五部分第 3 节依赖顺序上线)
## 1. 迁移与配置
- [ ] 1.1 新增成对迁移:`tb_system_config` 插入 `polling.stop_resume.queue_enabled`(默认 false)、结果未知告警阈值、停复机退避序列与告警阈值;注册受控系统配置项。
- [x] 1.1 新增成对迁移:`tb_system_config` 插入 `polling.stop_resume.queue_enabled`(默认 false)、结果未知告警阈值、停复机退避序列与告警阈值;注册受控系统配置项。(000255;carrierqueue.RegisterConfigDefinitions 注册 queue_enabled、unknown_alert_threshold、backoff_seconds、alert_fail_count,退避序列校验非空递增)
## 2. 统一判断与请求评估
- [ ] 2.1 抽出统一判断函数(暂停轮询按场景 → 停机条件 → 复机条件 → 通道阈值锁),`EvaluateAndAct`、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 改为调用它。
- [ ] 2.2 新增"请求评估(卡或设备、场景、来源)"门面:开关开启时单卡生成指令、设备请求设备判断;关闭时走旧路径当场执行。
- [ ] 2.3 指令写入 Lua:覆盖并 `HINCRBY ver`、方向相反清零失败计数、放入卡所属账户 `stop_resume`。
- [x] 2.1 抽出统一判断函数(暂停轮询按场景 → 停机条件 → 复机条件 → 通道阈值锁),`EvaluateAndAct`、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 改为调用它。(decideCard:EvaluateAndAct 新旧两条路径共用;暂停轮询的卡只在套餐到期、退款场景放行无套餐停机;通道阈值锁在复机写指令与执行前判断)
- [x] 2.2 新增"请求评估(卡或设备、场景、来源)"门面:开关开启时单卡生成指令、设备请求设备判断;关闭时走旧路径当场执行。(RequestAssetEvaluation 门面;EvaluateAndAct、ResumeCardIfStopped、CheckAndStopCard 签名不变,开关开启时改为写指令或请求设备判断,场景经 ctx 传入)
- [x] 2.3 指令写入 Lua:覆盖并 `HINCRBY ver`、方向相反清零失败计数、放入卡所属账户 `stop_resume`。(srWriteLua:HINCRBY ver、方向相反清零 fail/pending/unknown、ZADD LT;执行中只更新内容与版本)
## 3. 指令领取与执行
- [ ] 3.1 分发器在卡任务之前领取 `stop_resume`:清理过期 `sr_running`,领取数 = min(剩余 QPS, `sr_inflight_limit` − 执行中),校验 `sr_last_call` 推后,写 `sr_claim` 并记版本;开关开启后对全部账户领取。
- [ ] 3.2 NSQ `polling.stop_resume.{cid}` 专用消费者:核对 token、抢 `sr_exec`、续期。
- [ ] 3.3 执行器:按场景重新判断 → 写 `sr_last_call` → 调用一次网关(限流等待模式)→ 成功沿用现有回写与停复机记录;收尾按版本移除或保留。
- [ ] 3.4 结果未知:置待确认,确认延迟后先判断再查状态,按规则结束、重发或推后;查询失败不重发;连续未知达阈值告警。
- [ ] 3.5 失败分类处理:频率限制、终态(含自动关闭轮询)、已是目标状态、账户配置错误、临时故障与业务错误;停复机方向写入 `tb_polling_failure_alert`;指令不丢弃。
- [x] 3.1 分发器在卡任务之前领取 `stop_resume`:清理过期 `sr_running`,领取数 = min(剩余 QPS, `sr_inflight_limit` − 执行中),校验 `sr_last_call` 推后,写 `sr_claim` 并记版本;开关开启后对全部账户领取。(dispatchStopResume 在 dispatchCarrier 前执行,容量 = min(停机/复机所在限流组剩余, sr_inflight_limit, 消费能力);领取 Lua 校验 sr_last_call 推后;只看开关,不受 scheduler_mode 限制)
- [x] 3.2 NSQ `polling.stop_resume.{cid}` 专用消费者:核对 token、抢 `sr_exec`、续期。(polling.stop_resume.{cid} 专用 ConsumerManager,核对 token 抢 sr_exec;单次执行 ≤60 秒、租约 2 分钟,不续期)
- [x] 3.3 执行器:按场景重新判断 → 写 `sr_last_call` → 调用一次网关(限流等待模式)→ 成功沿用现有回写与停复机记录;收尾按版本移除或保留。(StopResumeExecutor:DecideCommand → MarkStopResumeCall → ExecuteCommand 单次调用(限流等待模式)→ 现有回写与停复机记录;srFinishLua 按版本移除或保留)
- [x] 3.4 结果未知:置待确认,确认延迟后先判断再查状态,按规则结束、重发或推后;查询失败不重发;连续未知达阈值告警。(超时、连接失败、MaybeAccepted、成功后回写失败 → pending;确认延迟后先判断再查状态,查询失败不重发;unknown 达阈值告警)
- [x] 3.5 失败分类处理:频率限制、终态(含自动关闭轮询)、已是目标状态、账户配置错误、临时故障与业务错误;停复机方向写入 `tb_polling_failure_alert`;指令不丢弃。(频率限制不计失败按 max(最小间隔, 首个退避) 推后;终态结束并告警,停机方向回写已停,自动关闭轮询沿用 TerminalPollingDisabler;已是目标状态按成功回写;account_config 首次告警;其余计失败按停复机退避序列;check_type=stop_resume 写入 tb_polling_failure_alert)
## 4. 设备维度判断
- [ ] 4.1 请求设备判断 Lua:`INCR eval_ver`、`SADD eval_scenes`、`ZADD LT device_eval:queue`。
- [ ] 4.2 分发与消费:抢 `device_eval:dispatch_lock` 领取,NSQ `polling.device_eval`;消费者抢 `eval_lock`(检查旧路径设备锁),读最新数据判断,为各卡写指令(记录场景)。
- [ ] 4.3 收尾:`SREM` 已处理场景,比较版本,变大则 score 设为当前时间再判断。
- [ ] 4.4 轮询路径不再使用 `RedisPollingDeviceOpLockKey`;人工设备停复机保持不变。
- [x] 4.1 请求设备判断 Lua:`INCR eval_ver`、`SADD eval_scenes`、`ZADD LT device_eval:queue`。(deviceEvalRequestLua:INCR eval_ver、SADD eval_scenes、ZADD LT)
- [x] 4.2 分发与消费:抢 `device_eval:dispatch_lock` 领取,NSQ `polling.device_eval`;消费者抢 `eval_lock`(检查旧路径设备锁),读最新数据判断,为各卡写指令(记录场景)。(device_eval 全局分发锁领取,polling.device_eval 消费者抢 eval_lock;旧路径设备锁存在时推后 5 秒;EvaluateDeviceCommands 逐卡统一判断并写指令)
- [x] 4.3 收尾:`SREM` 已处理场景,比较版本,变大则 score 设为当前时间再判断。(deviceEvalFinishLua:SREM 已处理场景,版本变大立即再判断)
- [x] 4.4 轮询路径不再使用 `RedisPollingDeviceOpLockKey`;人工设备停复机保持不变。(开关开启时轮询路径经设备判断,不再抢 RedisPollingDeviceOpLockKey;人工设备停复机不变)
## 5. 自动入口改造
- [ ] 5.1 套餐步骤与 Outbox 三个卡观测消费者改为请求评估,不再同步调网关、不 sleep 重试。
- [ ] 5.2 `resumeAfterActivation`(4 处)、`reset_service.go:400`、`order/service.go:2196` 去掉 goroutine,事务提交后请求评估。
- [ ] 5.3 `refund/service.go` `stopAsset` 改为请求评估,写入成功即标记 `asset_reset`;`RecoverRefundPostProcessing` 只补偿"请求未写入"。
- [ ] 5.4 `package_activation_handler.go` 到期失效处:无其他有效主套餐时以"套餐到期"场景请求评估;接续激活路径不变。
- [ ] 5.5 `carrier/service.go` 实名管控修改后按 0 → 1、1 → 0 筛选受影响卡批量请求评估(设备卡按设备)。
- [x] 5.1 套餐步骤与 Outbox 三个卡观测消费者改为请求评估,不再同步调网关、不 sleep 重试。(套餐步骤经 EvaluateAndAct;Outbox 网络、实名、流量三个消费者标场景 network_observed / realname_observed / flow_observed)
- [x] 5.2 `resumeAfterActivation`(4 处)、`reset_service.go:400`、`order/service.go:2196` 去掉 goroutine,事务提交后请求评估。(stopresume.RunAfterCommit:开关开启时事务提交后同步请求评估、不起 goroutine;关闭时沿用 goroutine 当场执行;流量扣减后停机同样处理)
- [x] 5.3 `refund/service.go` `stopAsset` 改为请求评估,写入成功即标记 `asset_reset`;`RecoverRefundPostProcessing` 只补偿"请求未写入"。(开关开启时 stopAsset 以 refund 场景请求评估,写入成功即标记 asset_reset;关闭时沿用 ManualStopCard;补偿扫描只会补"请求未写入"的退款)
- [x] 5.4 `package_activation_handler.go` 到期失效处:无其他有效主套餐时以"套餐到期"场景请求评估;接续激活路径不变。(triggerStopAfterExpiry 以 package_expired 场景请求评估,暂停轮询的卡同样停机;接续激活路径不变)
- [x] 5.5 `carrier/service.go` 实名管控修改后按 0 → 1、1 → 0 筛选受影响卡批量请求评估(设备卡按设备)。(RequestRealnameControlEvaluation:0→1 在线未实名、1→0 停机未实名,设备卡按设备,分批 500;仅开关开启时执行,保存接口提交后异步触发)
## 6. 监控与接口
- [ ] 6.1 `/polling-stats` 按账户增加停复机队列长度、执行中、成功、失败、重试、结果未知数。
- [ ] 6.2 `/iot-cards/:id/polling-state` 返回当前指令(动作、原因、场景、版本、失败次数、待确认)。
- [ ] 6.3 重新生成接口文档。
- [x] 6.1 `/polling-stats` 按账户增加停复机队列长度、执行中、成功、失败、重试、结果未知数。(/polling-stats/carrier-queues 增加停复机队列长度、到期、执行中、待确认,近 1 小时成功、失败、重试、结果未知)
- [x] 6.2 `/iot-cards/:id/polling-state` 返回当前指令(动作、原因、场景、版本、失败次数、待确认)。(返回 stop_resume_queue_enabled、stop_resume_command、last_stop_resume_call_at)
- [x] 6.3 重新生成接口文档。
## 7. 编译与规范校验
- [ ] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。
- [ ] 7.2 `openspec validate add-stop-resume-command-queue` 与 `openspec doctor --json` 通过。
- [x] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。
- [x] 7.2 `openspec validate add-stop-resume-command-queue` 与 `openspec doctor --json` 通过。(validate 通过;当前 openspec 无 doctor 命令)
## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001)

View File

@@ -0,0 +1,174 @@
package constants
import (
"fmt"
"strings"
)
// 停复机指令队列(add-stop-resume-command-queue)。
// 停复机请求评估场景:随请求评估与指令保存,执行前重新判断沿用同一场景。
const (
StopResumeScenePolling = "polling" // 套餐步骤(周期评估)
StopResumeSceneFlowObserved = "flow_observed" // 流量增加观测
StopResumeSceneNetworkObserved = "network_observed" // 网络状态变化观测
StopResumeSceneRealnameObserved = "realname_observed" // 实名变化观测(含人工实名)
StopResumeSceneActivation = "activation" // 套餐激活
StopResumeSceneReset = "reset" // 流量重置
StopResumeSceneOrder = "order" // 订单支付后套餐生效
StopResumeSceneRefund = "refund" // 退款后资产无可用主套餐
StopResumeScenePackageExpired = "package_expired" // 套餐到期且无其他有效主套餐
StopResumeSceneRealnameControl = "realname_control" // 运营商账户实名管控修改
StopResumeSceneUsage = "usage" // 流量扣减后
)
// StopResumeSceneAllowsSuspendedStop 判断场景集合中是否含套餐失效类场景:暂停轮询的卡在这些场景下照常停机。
func StopResumeSceneAllowsSuspendedStop(scenes []string) bool {
for _, scene := range scenes {
if scene == StopResumeScenePackageExpired || scene == StopResumeSceneRefund {
return true
}
}
return false
}
// JoinStopResumeScenes 把场景集合编码为逗号分隔串(去重、去空)。
func JoinStopResumeScenes(scenes []string) string {
seen := map[string]bool{}
parts := make([]string, 0, len(scenes))
for _, scene := range scenes {
scene = strings.TrimSpace(scene)
if scene == "" || seen[scene] {
continue
}
seen[scene] = true
parts = append(parts, scene)
}
return strings.Join(parts, ",")
}
// SplitStopResumeScenes 解析逗号分隔的场景串。
func SplitStopResumeScenes(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
scenes := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
scenes = append(scenes, part)
}
}
return scenes
}
// CheckTypeStopResume 是失败告警表中停复机方向的检查类型。
const CheckTypeStopResume = "stop_resume"
// 停复机指令失败类别(不来自上游错误分类的部分)。
const (
StopResumeFailureUnknown = "unknown" // 结果未知(超时、连接失败、可能已受理)
)
// 停复机指令默认参数(账户未配置时使用)。
const (
DefaultStopResumeMinIntervalSeconds = 600 // 单卡最小重试间隔:上游卡状态变更每十分钟只允许调用一次
DefaultStopResumeConfirmDelaySeconds = 60 // 结果未知后的确认延迟
DefaultStopResumeInflightLimit = 10 // 账户停复机执行上限
DefaultStopResumeMaxInFlight = 10 // 每进程停复机消费者 max_in_flight
)
// 停复机指令队列受控系统配置键(module = polling)。
const (
SystemConfigStopResumeQueueEnabled = "polling.stop_resume.queue_enabled"
SystemConfigStopResumeUnknownAlert = "polling.stop_resume.unknown_alert_threshold"
SystemConfigStopResumeBackoff = "polling.stop_resume.backoff_seconds"
SystemConfigStopResumeAlertFailCount = "polling.stop_resume.alert_fail_count"
)
// ========================================
// 停复机指令队列 Redis Key
// ========================================
// RedisCarrierStopResumeQueueKey 账户停复机指令队列(ZSET,member=card_id,score=可执行时间毫秒)。
func RedisCarrierStopResumeQueueKey(carrierID uint) string {
return fmt.Sprintf("polling:carrier:%d:stop_resume", carrierID)
}
// RedisCarrierStopResumeRunningKey 账户停复机执行中计数(ZSET,member=token,score=租约到期毫秒)。
func RedisCarrierStopResumeRunningKey(carrierID uint) string {
return fmt.Sprintf("polling:carrier:%d:sr_running", carrierID)
}
// RedisCardStopResumeCmdKey 卡停复机指令(Hash:action、reason、scenes、source、carrier、ver、fail、ffail、err、pending、unknown、at)。
func RedisCardStopResumeCmdKey(cardID uint) string {
return fmt.Sprintf("polling:card:%d:stop_resume_cmd", cardID)
}
// RedisCardStopResumeClaimKey 停复机指令处理中标记,值=派发 token,TTL=租约。
func RedisCardStopResumeClaimKey(cardID uint) string {
return fmt.Sprintf("polling:card:%d:sr_claim", cardID)
}
// RedisCardStopResumeExecKey 停复机指令执行权,值=token + 实例。
func RedisCardStopResumeExecKey(cardID uint) string {
return fmt.Sprintf("polling:card:%d:sr_exec", cardID)
}
// RedisCardStopResumeLastCallKey 本卡最近一次停复机网关调用时间(毫秒,停机与复机合计)。
func RedisCardStopResumeLastCallKey(cardID uint) string {
return fmt.Sprintf("polling:card:%d:sr_last_call", cardID)
}
// RedisDeviceEvalQueueKey 设备判断队列(ZSET,member=device_id,score=可执行时间毫秒)。
const RedisDeviceEvalQueueKey = "polling:device_eval:queue"
// RedisDeviceEvalRunningKey 设备判断执行中计数(ZSET,member=token)。
const RedisDeviceEvalRunningKey = "polling:device_eval:running"
// RedisDeviceEvalDispatchLockKey 设备判断分发锁。
const RedisDeviceEvalDispatchLockKey = "polling:device_eval:dispatch_lock"
// RedisDeviceEvalVersionKey 设备判断版本号(每次请求 INCR)。
func RedisDeviceEvalVersionKey(deviceID uint) string {
return fmt.Sprintf("polling:device:%d:eval_ver", deviceID)
}
// RedisDeviceEvalScenesKey 设备待判断场景集合(Set)。
func RedisDeviceEvalScenesKey(deviceID uint) string {
return fmt.Sprintf("polling:device:%d:eval_scenes", deviceID)
}
// RedisDeviceEvalClaimKey 设备判断处理中标记,值=token。
func RedisDeviceEvalClaimKey(deviceID uint) string {
return fmt.Sprintf("polling:device:%d:eval_claim", deviceID)
}
// RedisDeviceEvalLockKey 设备判断锁(执行权),同一设备同一时刻只有一个判断在执行。
func RedisDeviceEvalLockKey(deviceID uint) string {
return fmt.Sprintf("polling:device:%d:eval_lock", deviceID)
}
// NSQStopResumeTopic 返回账户停复机指令 topic。
func NSQStopResumeTopic(carrierID uint) string {
return fmt.Sprintf("polling.stop_resume.%d", carrierID)
}
// NSQDeviceEvalTopic 设备判断 topic。
const NSQDeviceEvalTopic = "polling.device_eval"
// 停复机指令执行计数字段(按 10 分钟分桶,供监控汇总近 1 小时)。
const (
StopResumeStatSuccess = "success"
StopResumeStatFailed = "failed"
StopResumeStatRetry = "retry"
StopResumeStatUnknown = "unknown"
)
// StopResumeStatBucketSeconds 是停复机执行计数的分桶时长。
const StopResumeStatBucketSeconds = 600
// RedisCarrierStopResumeStatKey 账户停复机执行计数(Hash,field=计数字段),bucket = unix 秒 / 分桶时长。
func RedisCarrierStopResumeStatKey(carrierID uint, bucket int64) string {
return fmt.Sprintf("polling:carrier:%d:sr_stats:%d", carrierID, bucket)
}