feat: 按运营商账户调度的后台接口与重算到期

- /polling-carrier-params 列表(含默认标记与消费能力)、详情、保存(执行上限不得大于订阅进程 max_in_flight 之和)、删除;/polling-check-scenes 列表与修改(检查类型、递增的确认延迟、冷却);/polling-failure-alerts 列表、按账户与错误类别汇总、详情、标记已处理;GET /iot-cards/:id/polling-state 单卡轮询状态;GET /polling-stats/carrier-queues 按账户队列与执行监控
- 读取仅超级管理员与平台账号,修改仅超级管理员(告警处理平台账号可用),均写统一审计;新增审计动作与资源
- carrier 模式下重算到期时间写入卡到期信息与账户队列,只提前不推后
This commit is contained in:
邹进
2026-10-25 18:56:32 +09:00
parent 0d966249b8
commit c741f1751d
14 changed files with 1190 additions and 7 deletions

View File

@@ -33,6 +33,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。

View File

@@ -42,6 +42,7 @@ func generateAdminDocs(outputPath string) error {
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。

View File

@@ -196,9 +196,9 @@ func runWorker(cfg *config.Config) {
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
partitionMaintainer := gatewaylimit.NewPartitionMaintainer(postgres.NewCardStopResumeRecordStore(runtime.db), runtime.gatewayLimit.Settings, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypeStopResumeRecordPartition, partitionMaintainer.Handle)
recalcHandler := polling.NewRecalculateTaskHandler(
polling.NewDueRecalculator(runtime.db, runtime.redisClient, runtime.pollingConfigMgr, runtime.pollingQueueMgr, appLogger),
runtime.redisClient, appLogger)
dueRecalculator := polling.NewDueRecalculator(runtime.db, runtime.redisClient, runtime.pollingConfigMgr, runtime.pollingQueueMgr, appLogger)
dueRecalculator.SetCarrierScheduling(runtime.carrierScheduling)
recalcHandler := polling.NewRecalculateTaskHandler(dueRecalculator, runtime.redisClient, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypePollingRecalculateDue, recalcHandler.Handle)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
rescuePendingImportTasks(ctx, runtime, appLogger)

View File

@@ -337,10 +337,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
GatewayRateLimit: admin.NewGatewayRateLimitHandler(pollingSvcPkg.NewGatewayRateLimitService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil), deps.GatewayLimit), deps.Redis, validate),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
CarrierScheduler: admin.NewCarrierSchedulerHandler(pollingSvcPkg.NewCarrierSchedulerService(deps.DB, deps.Redis,
auditInfra.NewWriter(auditInfra.NewRegistry(), nil), carrierSchedulerConfig()), validate),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
PollingPriority: admin.NewPriorityPollingHandler(
svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate,
),
@@ -408,3 +410,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
}
}
// carrierSchedulerConfig 返回按运营商账户调度的 yaml 参数(供后台校验执行上限与展示默认值)。
func carrierSchedulerConfig() config.CarrierSchedulerConfig {
if cfg := config.Get(); cfg != nil {
return cfg.Polling.Carrier
}
return config.CarrierSchedulerConfig{}
}

View File

@@ -65,6 +65,7 @@ type Handlers struct {
WeComApprovalCallback *callback.WeComApprovalHandler
PollingConfig *admin.PollingConfigHandler
GatewayRateLimit *admin.GatewayRateLimitHandler
CarrierScheduler *admin.CarrierSchedulerHandler
PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler
PollingAlert *admin.PollingAlertHandler

View File

@@ -0,0 +1,195 @@
package admin
import (
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// CarrierSchedulerHandler 按运营商账户调度的后台接口:调度参数、请求检查场景、失败告警、单卡轮询状态、按账户监控。
type CarrierSchedulerHandler struct {
service *pollingSvc.CarrierSchedulerService
validator *validator.Validate
}
// NewCarrierSchedulerHandler 创建按运营商账户调度 Handler。
func NewCarrierSchedulerHandler(service *pollingSvc.CarrierSchedulerService, validator *validator.Validate) *CarrierSchedulerHandler {
return &CarrierSchedulerHandler{service: service, validator: validator}
}
func (h *CarrierSchedulerHandler) validate(prefix string, req any) error {
if err := h.validator.Struct(req); err != nil {
return errors.New(errors.CodeInvalidParam, validationMessage(prefix, req, err))
}
return nil
}
// ListParams 查询运营商账户调度参数列表。GET /api/admin/polling-carrier-params
func (h *CarrierSchedulerHandler) ListParams(c *fiber.Ctx) error {
result, err := h.service.ListParams(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// GetParam 查询运营商账户调度参数。GET /api/admin/polling-carrier-params/:carrier_id
func (h *CarrierSchedulerHandler) GetParam(c *fiber.Ctx) error {
carrierID, err := rateLimitPathID(c, "carrier_id", "运营商账户 ID")
if err != nil {
return err
}
result, err := h.service.GetParam(c.UserContext(), carrierID)
if err != nil {
return err
}
return response.Success(c, result)
}
// SaveParam 保存运营商账户调度参数。PUT /api/admin/polling-carrier-params/:carrier_id
func (h *CarrierSchedulerHandler) SaveParam(c *fiber.Ctx) error {
var req dto.SaveCarrierPollingParamRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
carrierID, err := rateLimitPathID(c, "carrier_id", "运营商账户 ID")
if err != nil {
return err
}
req.CarrierID = carrierID
if err := h.validate("运营商账户调度参数不合法", &req); err != nil {
return err
}
result, err := h.service.SaveParam(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// DeleteParam 删除运营商账户调度参数(恢复全局默认)。DELETE /api/admin/polling-carrier-params/:carrier_id
func (h *CarrierSchedulerHandler) DeleteParam(c *fiber.Ctx) error {
carrierID, err := rateLimitPathID(c, "carrier_id", "运营商账户 ID")
if err != nil {
return err
}
if err := h.service.DeleteParam(c.UserContext(), carrierID); err != nil {
return err
}
return response.Success(c, nil)
}
// ListScenes 查询请求检查场景。GET /api/admin/polling-check-scenes
func (h *CarrierSchedulerHandler) ListScenes(c *fiber.Ctx) error {
result, err := h.service.ListScenes(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateScene 修改请求检查场景。PUT /api/admin/polling-check-scenes/:scene
func (h *CarrierSchedulerHandler) UpdateScene(c *fiber.Ctx) error {
var req dto.UpdatePollingCheckSceneRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
req.Scene = strings.TrimSpace(c.Params("scene"))
if req.Scene == "" {
return errors.New(errors.CodeInvalidParam, "无效的场景编码")
}
if err := h.validate("请求检查场景参数不合法", &req); err != nil {
return err
}
result, err := h.service.UpdateScene(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListAlerts 查询卡轮询失败告警列表。GET /api/admin/polling-failure-alerts
func (h *CarrierSchedulerHandler) ListAlerts(c *fiber.Ctx) error {
var req dto.PollingFailureAlertListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("卡轮询失败告警查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListAlerts(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// AlertSummary 未处理告警按运营商账户 × 错误类别汇总。GET /api/admin/polling-failure-alerts/summary
func (h *CarrierSchedulerHandler) AlertSummary(c *fiber.Ctx) error {
result, err := h.service.AlertSummary(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// GetAlert 查询卡轮询失败告警详情。GET /api/admin/polling-failure-alerts/:id
func (h *CarrierSchedulerHandler) GetAlert(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "告警 ID")
if err != nil {
return err
}
result, err := h.service.GetAlert(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// HandleAlert 标记卡轮询失败告警已处理。PUT /api/admin/polling-failure-alerts/:id/status
func (h *CarrierSchedulerHandler) HandleAlert(c *fiber.Ctx) error {
var req dto.HandlePollingFailureAlertRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "告警 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("卡轮询失败告警处理参数不合法", &req); err != nil {
return err
}
if err := h.service.HandleAlert(c.UserContext(), &req); err != nil {
return err
}
return response.Success(c, nil)
}
// CardState 查询单卡轮询状态。GET /api/admin/iot-cards/:id/polling-state
func (h *CarrierSchedulerHandler) CardState(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "卡 ID")
if err != nil {
return err
}
result, err := h.service.CardState(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CarrierStats 按运营商账户的轮询队列与执行监控。GET /api/admin/polling-stats/carrier-queues
func (h *CarrierSchedulerHandler) CarrierStats(c *fiber.Ctx) error {
result, err := h.service.CarrierStats(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -277,6 +277,10 @@ func NewRegistry() *Registry {
gatewayErrorRuleCreated := pollingAction(constants.AuditActionGatewayErrorRuleCreated, "创建上游错误分类规则", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal)
gatewayErrorRuleUpdated := pollingAction(constants.AuditActionGatewayErrorRuleUpdated, "更新上游错误分类规则", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal)
gatewayErrorRuleStatusUpdated := pollingAction(constants.AuditActionGatewayErrorRuleStatusUpdated, "更新上游错误分类规则状态", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal)
carrierPollingParamSaved := pollingAction(constants.AuditActionCarrierPollingParamSaved, "保存运营商账户调度参数", constants.AuditResourceCarrierPollingParam, constants.AuditRiskHigh)
carrierPollingParamDeleted := pollingAction(constants.AuditActionCarrierPollingParamDeleted, "删除运营商账户调度参数", constants.AuditResourceCarrierPollingParam, constants.AuditRiskHigh)
pollingCheckSceneUpdated := pollingAction(constants.AuditActionPollingCheckSceneUpdated, "修改请求检查场景", constants.AuditResourcePollingCheckScene, constants.AuditRiskNormal)
pollingFailureAlertHandled := pollingAction(constants.AuditActionPollingFailureAlertHandled, "处理卡轮询失败告警", constants.AuditResourcePollingFailureAlert, constants.AuditRiskNormal)
gatewayErrorRuleDeleted := pollingAction(constants.AuditActionGatewayErrorRuleDeleted, "删除上游错误分类规则", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal)
pollingConcurrencyUpdated := pollingAction(constants.AuditActionPollingConcurrencyUpdated, "更新轮询并发配置", constants.AuditResourcePollingConcurrencyConfig, constants.AuditRiskNormal)
pollingConcurrencyReset := pollingAction(constants.AuditActionPollingConcurrencyReset, "重置轮询并发计数", constants.AuditResourcePollingConcurrencyConfig, constants.AuditRiskNormal)
@@ -678,6 +682,10 @@ func NewRegistry() *Registry {
constants.AuditActionGatewayErrorRuleUpdated: gatewayErrorRuleUpdated,
constants.AuditActionGatewayErrorRuleStatusUpdated: gatewayErrorRuleStatusUpdated,
constants.AuditActionGatewayErrorRuleDeleted: gatewayErrorRuleDeleted,
constants.AuditActionCarrierPollingParamSaved: carrierPollingParamSaved,
constants.AuditActionCarrierPollingParamDeleted: carrierPollingParamDeleted,
constants.AuditActionPollingCheckSceneUpdated: pollingCheckSceneUpdated,
constants.AuditActionPollingFailureAlertHandled: pollingFailureAlertHandled,
constants.AuditActionPollingConcurrencyUpdated: pollingConcurrencyUpdated,
constants.AuditActionPollingConcurrencyReset: pollingConcurrencyReset,
constants.AuditActionPollingAlertRuleCreated: pollingAlertRuleCreated,
@@ -966,6 +974,18 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceGatewayErrorRule, Name: "上游错误分类规则",
IdentityFields: []string{"id", "keyword", "match_type", "category", "priority", "status"},
},
constants.AuditResourceCarrierPollingParam: {
Type: constants.AuditResourceCarrierPollingParam, Name: "运营商账户调度参数",
IdentityFields: []string{"carrier_id", "inflight_limit", "card_max_in_flight", "priority_max_ratio"},
},
constants.AuditResourcePollingCheckScene: {
Type: constants.AuditResourcePollingCheckScene, Name: "请求检查场景",
IdentityFields: []string{"scene", "scene_name"},
},
constants.AuditResourcePollingFailureAlert: {
Type: constants.AuditResourcePollingFailureAlert, Name: "卡轮询失败告警",
IdentityFields: []string{"id", "alert_level", "card_id", "carrier_id", "check_type", "status"},
},
constants.AuditResourcePollingAlertRule: {
Type: constants.AuditResourcePollingAlertRule, Name: "轮询告警规则",
IdentityFields: []string{"id", "rule_name", "task_type", "metric_type", "operator", "threshold", "alert_level", "status"},

View File

@@ -0,0 +1,185 @@
package dto
import "time"
// CarrierPollingParamItem 运营商账户调度参数(含全局默认合并后的生效值)。
type CarrierPollingParamItem struct {
CarrierID uint `json:"carrier_id" description:"运营商账户ID"`
CarrierName string `json:"carrier_name" description:"运营商账户名称"`
IsDefault bool `json:"is_default" description:"是否未单独配置、全部使用全局默认"`
InflightLimit int `json:"inflight_limit" description:"生效的执行上限(跨 Worker 实例同时执行卡任务数)"`
CardMaxInFlight int `json:"card_max_in_flight" description:"生效的每进程卡任务 max_in_flight"`
PriorityMaxRatio *float64 `json:"priority_max_ratio" description:"优先队列每轮最大占比(0~1),空表示不限"`
StopResumeMaxInFlight *int `json:"stop_resume_max_in_flight" description:"每进程停复机 max_in_flight(停复机指令队列使用),空表示默认"`
SRInflightLimit *int `json:"sr_inflight_limit" description:"停复机执行上限(停复机指令队列使用),空表示默认"`
StopResumeMinInterval *int `json:"stop_resume_min_interval" description:"单卡两次停复机调用最小间隔(秒),空表示默认"`
StopResumeConfirmDelay *int `json:"stop_resume_confirm_delay" description:"停复机结果未知后确认延迟(秒),空表示默认"`
ConfiguredInflight *int `json:"configured_inflight_limit" description:"单独配置的执行上限,空表示使用默认"`
ConfiguredMaxInFlight *int `json:"configured_card_max_in_flight" description:"单独配置的每进程 max_in_flight,空表示使用默认"`
ConsumeCapacity int `json:"consume_capacity" description:"当前订阅进程 max_in_flight 之和(执行上限不应超过该值)"`
}
// CarrierPollingParamListResponse 运营商账户调度参数列表。
type CarrierPollingParamListResponse struct {
Items []CarrierPollingParamItem `json:"items" description:"各运营商账户调度参数"`
SubscriberProcesses int `json:"subscriber_processes" description:"订阅卡任务的 Worker 进程数"`
DefaultInflight int `json:"default_inflight_limit" description:"全局默认执行上限"`
DefaultMaxInFlight int `json:"default_card_max_in_flight" description:"全局默认每进程 max_in_flight"`
}
// CarrierPollingParamPath 运营商账户路径参数。
type CarrierPollingParamPath struct {
CarrierID uint `json:"-" path:"carrier_id" required:"true" description:"运营商账户ID"`
}
// SaveCarrierPollingParamRequest 保存运营商账户调度参数;字段为空表示该项使用全局默认。
type SaveCarrierPollingParamRequest struct {
CarrierID uint `json:"-" path:"carrier_id" required:"true" description:"运营商账户ID"`
InflightLimit *int `json:"inflight_limit" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"执行上限,空表示默认"`
CardMaxInFlight *int `json:"card_max_in_flight" validate:"omitempty,min=1,max=2000" minimum:"1" maximum:"2000" description:"每进程卡任务 max_in_flight,空表示默认"`
StopResumeMaxInFlight *int `json:"stop_resume_max_in_flight" validate:"omitempty,min=1,max=1000" minimum:"1" maximum:"1000" description:"每进程停复机 max_in_flight,空表示默认"`
SRInflightLimit *int `json:"sr_inflight_limit" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"停复机执行上限,空表示默认"`
PriorityMaxRatio *float64 `json:"priority_max_ratio" validate:"omitempty,gt=0,lte=1" description:"优先队列每轮最大占比(0~1],空表示不限"`
StopResumeMinInterval *int `json:"stop_resume_min_interval" validate:"omitempty,min=1,max=86400" minimum:"1" maximum:"86400" description:"单卡停复机最小间隔(秒),空表示默认"`
StopResumeConfirmDelay *int `json:"stop_resume_confirm_delay" validate:"omitempty,min=1,max=86400" minimum:"1" maximum:"86400" description:"停复机结果未知确认延迟(秒),空表示默认"`
}
// PollingCheckSceneItem 请求检查场景。
type PollingCheckSceneItem struct {
Scene string `json:"scene" description:"场景编码 (order_paid:订单支付, package_activated:套餐激活, stop_resume_succeeded:停复机成功, device_operation:设备操作, auto_renewal_purchase:自动续费购包, client_asset_detail:C 端资产详情, admin_asset_realtime:后台资产实时状态, realname_link:实名链接)"`
SceneName string `json:"scene_name" description:"场景名称"`
CheckTypes []string `json:"check_types" description:"检查类型 (card_status:卡状态, carddata:流量, realname:实名)"`
ConfirmDelays []int `json:"confirm_delays" description:"延迟确认序列(秒),空表示只立即检查一次"`
Cooldown *int `json:"cooldown" description:"冷却时长(秒),最近一次成功检查未超过该时长则不请求;空表示不冷却"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// UpdatePollingCheckSceneRequest 修改请求检查场景(场景由代码预置,只可改检查类型、确认延迟与冷却)。
type UpdatePollingCheckSceneRequest struct {
Scene string `json:"-" path:"scene" required:"true" description:"场景编码"`
CheckTypes []string `json:"check_types" validate:"required,min=1,max=3,dive,oneof=card_status carddata realname" required:"true" description:"检查类型 (card_status:卡状态, carddata:流量, realname:实名)"`
ConfirmDelays []int `json:"confirm_delays" validate:"omitempty,max=5,dive,min=1,max=86400" description:"延迟确认序列(秒,递增),空表示只立即检查一次"`
Cooldown *int `json:"cooldown" validate:"omitempty,min=1,max=86400" minimum:"1" maximum:"86400" description:"冷却时长(秒),空表示不冷却"`
}
// PollingFailureAlertListRequest 卡轮询失败告警列表请求。
type PollingFailureAlertListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
CarrierID *uint `json:"carrier_id" query:"carrier_id" validate:"omitempty,min=1" description:"运营商账户ID"`
CheckType string `json:"check_type" query:"check_type" validate:"omitempty,oneof=card_status carddata realname package all" description:"检查类型 (card_status:卡状态, carddata:流量, realname:实名, package:套餐, all:账户级)"`
ErrorCategory string `json:"error_category" query:"error_category" validate:"omitempty,max=50" description:"错误类别"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2 3 4" description:"状态 (1:未处理, 2:已恢复, 3:已关闭轮询, 4:已处理)"`
AlertLevel string `json:"alert_level" query:"alert_level" validate:"omitempty,oneof=card carrier" description:"告警级别 (card:卡级, carrier:账户级)"`
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=30" description:"ICCID(精确)"`
StartTime string `json:"start_time" query:"start_time" description:"最近失败时间起始(带时区的 RFC3339 秒级时间)"`
EndTime string `json:"end_time" query:"end_time" description:"最近失败时间结束(带时区的 RFC3339 秒级时间)"`
}
// PollingFailureAlertItem 卡轮询失败告警。
type PollingFailureAlertItem struct {
ID uint `json:"id" description:"告警ID"`
AlertLevel string `json:"alert_level" description:"告警级别 (card:卡级, carrier:账户级)"`
CardID uint `json:"card_id" description:"卡ID,账户级为 0"`
ICCID string `json:"iccid" description:"ICCID"`
CarrierID uint `json:"carrier_id" description:"运营商账户ID"`
CarrierName string `json:"carrier_name" description:"运营商账户名称"`
CheckType string `json:"check_type" description:"检查类型"`
CheckTypeName string `json:"check_type_name" description:"检查类型名称"`
ErrorCategory string `json:"error_category" description:"最近错误类别"`
FailCount int `json:"fail_count" description:"连续失败次数;账户级为窗口内告警数"`
FirstFailedAt *time.Time `json:"first_failed_at" description:"首次失败时间"`
LastFailedAt *time.Time `json:"last_failed_at" description:"最近失败时间"`
LastError string `json:"last_error" description:"最近错误"`
Status int `json:"status" description:"状态 (1:未处理, 2:已恢复, 3:已关闭轮询, 4:已处理)"`
StatusName string `json:"status_name" description:"状态名称"`
HandledBy uint `json:"handled_by" description:"处理人"`
HandledAt *time.Time `json:"handled_at" description:"处理时间"`
HandleRemark string `json:"handle_remark" description:"处理备注"`
RecoveredAt *time.Time `json:"recovered_at" description:"自动恢复时间"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// PollingFailureAlertPageResult 卡轮询失败告警分页结果。
type PollingFailureAlertPageResult struct {
List []PollingFailureAlertItem `json:"items" description:"告警列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"size" description:"每页数量"`
}
// PollingFailureAlertSummaryItem 未处理告警按运营商账户 × 错误类别汇总。
type PollingFailureAlertSummaryItem struct {
CarrierID uint `json:"carrier_id" description:"运营商账户ID"`
CarrierName string `json:"carrier_name" description:"运营商账户名称"`
ErrorCategory string `json:"error_category" description:"错误类别"`
OpenCount int64 `json:"open_count" description:"未处理卡级告警数"`
}
// PollingFailureAlertIDPath 告警路径参数。
type PollingFailureAlertIDPath struct {
ID uint `json:"-" path:"id" required:"true" description:"告警ID"`
}
// HandlePollingFailureAlertRequest 标记告警已处理。
type HandlePollingFailureAlertRequest struct {
ID uint `json:"-" path:"id" required:"true" description:"告警ID"`
Status int `json:"status" validate:"required,oneof=4" required:"true" description:"目标状态 (4:已处理);关闭轮询请使用资产轮询开关接口"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"处理备注"`
}
// CardPollingStatePath 卡ID路径参数。
type CardPollingStatePath struct {
ID uint `json:"-" path:"id" required:"true" description:"卡ID"`
}
// CardPollingTypeState 卡某检查类型的轮询状态。
type CardPollingTypeState struct {
CheckType string `json:"check_type" description:"检查类型"`
CheckTypeName string `json:"check_type_name" description:"检查类型名称"`
NextDueAt *time.Time `json:"next_due_at" description:"下次到期时间"`
LastSuccessAt *time.Time `json:"last_success_at" description:"最近一次成功时间"`
FailCount int `json:"fail_count" description:"连续失败次数"`
FirstFailedAt *time.Time `json:"first_failed_at" description:"首次失败时间"`
LastError string `json:"last_error" description:"最近错误(类别|信息)"`
PendingConfirm []int `json:"pending_confirm" description:"待执行的延迟确认(秒)"`
}
// CardPollingStateResponse 单卡轮询状态(只读)。
type CardPollingStateResponse struct {
CardID uint `json:"card_id" description:"卡ID"`
ICCID string `json:"iccid" description:"ICCID"`
CarrierID uint `json:"carrier_id" description:"运营商账户ID"`
SchedulerMode string `json:"scheduler_mode" description:"当前调度模式 (legacy:旧分片调度, carrier:按运营商账户调度)"`
EnablePolling bool `json:"enable_polling" description:"卡轮询开关"`
Queue string `json:"queue" description:"所在队列 (priority:优先, queue:普通, none:不在轮询中)"`
QueueScoreAt *time.Time `json:"queue_score_at" description:"队列中下次可领取时间"`
Running bool `json:"running" description:"是否执行中"`
ClaimExpireAt *time.Time `json:"claim_expire_at" description:"处理中标记到期时间"`
PriorityMark bool `json:"priority_mark" description:"执行中收到请求检查,收尾后进入优先队列"`
Types []CardPollingTypeState `json:"types" description:"各检查类型状态"`
}
// CarrierPollingStatsItem 按运营商账户的轮询监控。
type CarrierPollingStatsItem struct {
CarrierID uint `json:"carrier_id" description:"运营商账户ID"`
CarrierName string `json:"carrier_name" description:"运营商账户名称"`
PriorityLength int64 `json:"priority_length" description:"优先队列长度"`
QueueLength int64 `json:"queue_length" description:"普通队列长度"`
DueCount int64 `json:"due_count" description:"已到期卡数(优先 + 普通)"`
Running int64 `json:"running" description:"执行中卡任务数"`
InflightLimit int `json:"inflight_limit" description:"执行上限"`
QPSLastMinute float64 `json:"qps_last_minute" description:"上一分钟实际网关调用 QPS(查询类)"`
RateLimitWaits int64 `json:"rate_limit_waits" description:"上一分钟限流等待次数"`
RateLimited int64 `json:"rate_limited" description:"上一分钟限流拒绝或等待超时次数"`
ExpiredClaims1h int64 `json:"expired_claims_1h" description:"近 1 小时处理中标记到期次数(执行崩溃或超时)"`
OpenAlerts int64 `json:"open_alerts" description:"未处理卡级失败告警数"`
OpenAlertsByType map[string]int64 `json:"open_alerts_by_category" description:"未处理卡级失败告警数(按错误类别)"`
}
// CarrierPollingStatsResponse 按运营商账户的轮询监控。
type CarrierPollingStatsResponse struct {
SchedulerMode string `json:"scheduler_mode" description:"当前调度模式"`
InitState string `json:"init_state" description:"新结构初始化状态 (running / done,空表示未初始化)"`
Items []CarrierPollingStatsItem `json:"items" description:"各运营商账户监控"`
}

View File

@@ -2,10 +2,12 @@ package polling
import (
"context"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -197,3 +199,28 @@ func (i *CarrierInitializer) setProgress(update func(*InitProgress)) {
update(&i.progress)
i.mu.Unlock()
}
// AdvanceDue 把卡某检查类型的到期时间提前到 due(只提前不推后),并同步把账户队列中的 score 提前;返回是否提前。
// 卡不在轮询中(无到期信息)时不处理。
func (c *CarrierScheduling) AdvanceDue(ctx context.Context, card *model.IotCard, checkType string, due time.Time) (bool, error) {
if c == nil || c.store == nil || card == nil {
return false, nil
}
current, err := c.store.ReadDue(ctx, card.ID)
if err != nil || current.CarrierID == 0 {
return false, err
}
item, ok := current.Types[checkType]
if !ok || !item.HasDue || !due.Before(item.Due) {
return false, nil
}
client := c.store.Client()
member := strconv.FormatUint(uint64(card.ID), 10)
pipe := client.TxPipeline()
pipe.HSet(ctx, constants.RedisCardDueKey(card.ID), carrierqueue.FieldDue(checkType), due.UnixMilli())
score := redis.ZAddArgs{XX: true, LT: true, Members: []redis.Z{{Score: float64(due.UnixMilli()), Member: member}}}
pipe.ZAddArgs(ctx, constants.RedisCarrierQueueKey(current.CarrierID), score)
pipe.ZAddArgs(ctx, constants.RedisCarrierPriorityQueueKey(current.CarrierID), score)
_, err = pipe.Exec(ctx)
return err == nil, err
}

View File

@@ -32,6 +32,39 @@ type DueRecalculator struct {
configMgr *PollingConfigManager
queueMgr *PollingQueueManager
logger *zap.Logger
// carrier 按运营商账户调度接缝:carrier 模式下重算写入卡到期信息与账户队列。
carrier *CarrierScheduling
}
// SetCarrierScheduling 注入按运营商账户调度接缝。
func (r *DueRecalculator) SetCarrierScheduling(carrier *CarrierScheduling) {
r.carrier = carrier
}
// recalculateCarrierBatch 按运营商账户调度时重算:各检查类型候选到期同旧结构口径,只提前不推后。
func (r *DueRecalculator) recalculateCarrierBatch(ctx context.Context, cards []*model.IotCard) (int, error) {
now := time.Now()
advanced := 0
for _, card := range cards {
for taskType, info := range r.configMgr.MergedTaskIntervals(card) {
checkType := constants.CheckTypeFromTaskType(taskType)
if checkType == "" || info.Interval <= 0 {
continue
}
due := recalculatedDue(lastCheckAtByTaskType(card, taskType), info.Interval, now)
if windowDue := r.configMgr.NextCheckAt(ctx, card, taskType, info, now); windowDue.Before(due) && windowDue.After(now) {
due = windowDue
}
ok, err := r.carrier.AdvanceDue(ctx, card, checkType, due)
if err != nil {
return advanced, err
}
if ok {
advanced++
}
}
}
return advanced, nil
}
// NewDueRecalculator 创建重算器。
@@ -97,6 +130,9 @@ func (r *DueRecalculator) recalculateBatch(ctx context.Context, cards []*model.I
if tier := r.configMgr.TierResolver(); tier != nil {
tier.Prefill(ctx, cards)
}
if r.carrier.Active(ctx) {
return r.recalculateCarrierBatch(ctx, cards)
}
now := time.Now()
pipe := r.redis.Pipeline()
var cmds []*redis.IntCmd

View File

@@ -136,6 +136,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.PollingConfig != nil {
registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath)
}
if handlers.CarrierScheduler != nil {
registerCarrierSchedulerRoutes(authGroup, handlers.CarrierScheduler, doc, basePath)
}
if handlers.GatewayRateLimit != nil {
registerGatewayRateLimitRoutes(authGroup, handlers.GatewayRateLimit, doc, basePath)
}

View File

@@ -0,0 +1,68 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerCarrierSchedulerRoutes 注册按运营商账户调度的后台接口。读取仅超级管理员与平台账号,修改仅超级管理员(服务层校验)。
func registerCarrierSchedulerRoutes(router fiber.Router, handler *admin.CarrierSchedulerHandler, doc *openapi.Generator, basePath string) {
tags := []string{"轮询调度"}
params := router.Group("/polling-carrier-params")
paramsPath := basePath + "/polling-carrier-params"
Register(params, doc, paramsPath, "GET", "", handler.ListParams, RouteSpec{
Summary: "查询运营商账户调度参数列表", Description: "未单独配置的账户返回全局默认并标记 is_default。", Tags: tags,
Input: nil, Output: new(dto.CarrierPollingParamListResponse), Auth: true,
})
Register(params, doc, paramsPath, "GET", "/:carrier_id", handler.GetParam, RouteSpec{
Summary: "查询运营商账户调度参数", Tags: tags, Input: new(dto.CarrierPollingParamPath), Output: new(dto.CarrierPollingParamItem), Auth: true,
})
Register(params, doc, paramsPath, "PUT", "/:carrier_id", handler.SaveParam, RouteSpec{
Summary: "保存运营商账户调度参数(仅超级管理员)",
Description: "字段为空表示该项使用全局默认;执行上限不得大于当前订阅进程 max_in_flight 之和;下一轮调度生效并写统一审计。",
Tags: tags, Input: new(dto.SaveCarrierPollingParamRequest), Output: new(dto.CarrierPollingParamItem), Auth: true,
})
Register(params, doc, paramsPath, "DELETE", "/:carrier_id", handler.DeleteParam, RouteSpec{
Summary: "删除运营商账户调度参数(恢复全局默认,仅超级管理员)", Tags: tags, Input: new(dto.CarrierPollingParamPath), Output: nil, Auth: true,
})
scenes := router.Group("/polling-check-scenes")
scenesPath := basePath + "/polling-check-scenes"
Register(scenes, doc, scenesPath, "GET", "", handler.ListScenes, RouteSpec{
Summary: "查询请求检查场景", Description: "业务事件与读入口在按运营商账户调度时按场景请求检查;场景由代码预置。", Tags: tags,
Input: nil, Output: new([]dto.PollingCheckSceneItem), Auth: true,
})
Register(scenes, doc, scenesPath, "PUT", "/:scene", handler.UpdateScene, RouteSpec{
Summary: "修改请求检查场景(仅超级管理员)", Description: "只可修改检查类型、延迟确认序列(递增)与冷却时长。", Tags: tags,
Input: new(dto.UpdatePollingCheckSceneRequest), Output: new(dto.PollingCheckSceneItem), Auth: true,
})
alerts := router.Group("/polling-failure-alerts")
alertsPath := basePath + "/polling-failure-alerts"
Register(alerts, doc, alertsPath, "GET", "", handler.ListAlerts, RouteSpec{
Summary: "查询卡轮询失败告警列表", Tags: tags, Input: new(dto.PollingFailureAlertListRequest), Output: new(dto.PollingFailureAlertPageResult), Auth: true,
})
Register(alerts, doc, alertsPath, "GET", "/summary", handler.AlertSummary, RouteSpec{
Summary: "未处理卡轮询失败告警按运营商账户与错误类别汇总", Tags: tags, Input: nil, Output: new([]dto.PollingFailureAlertSummaryItem), Auth: true,
})
Register(alerts, doc, alertsPath, "GET", "/:id", handler.GetAlert, RouteSpec{
Summary: "查询卡轮询失败告警详情", Tags: tags, Input: new(dto.PollingFailureAlertIDPath), Output: new(dto.PollingFailureAlertItem), Auth: true,
})
Register(alerts, doc, alertsPath, "PUT", "/:id/status", handler.HandleAlert, RouteSpec{
Summary: "标记卡轮询失败告警已处理", Description: "只有未处理告警可标记;关闭该卡轮询请使用资产轮询开关接口。", Tags: tags,
Input: new(dto.HandlePollingFailureAlertRequest), Output: nil, Auth: true,
})
Register(router, doc, basePath, "GET", "/iot-cards/:id/polling-state", handler.CardState, RouteSpec{
Summary: "查询单卡轮询状态", Description: "各检查类型下次到期、最近成功、连续失败与最近错误、是否执行中;只读。", Tags: tags,
Input: new(dto.CardPollingStatePath), Output: new(dto.CardPollingStateResponse), Auth: true,
})
Register(router, doc, basePath, "GET", "/polling-stats/carrier-queues", handler.CarrierStats, RouteSpec{
Summary: "按运营商账户的轮询队列与执行监控", Description: "按运营商账户调度的各账户队列长度、已到期数、执行中、上一分钟 QPS、限流等待与拒绝、处理中标记到期、未处理告警。", Tags: tags,
Input: nil, Output: new(dto.CarrierPollingStatsResponse), Auth: true,
})
}

View File

@@ -0,0 +1,622 @@
package polling
import (
"context"
"encoding/json"
stderrors "errors"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/datatypes"
"gorm.io/gorm"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// CarrierSchedulerService 按运营商账户调度的后台维护与监控:调度参数、请求检查场景、失败告警、单卡轮询状态与按账户监控。
// 读取仅超级管理员与平台账号;修改仅超级管理员并写统一审计,下一轮调度生效。
type CarrierSchedulerService struct {
db *gorm.DB
redis *redis.Client
auditWriter *auditinfra.Writer
store *carrierqueue.Store
settings *carrierqueue.SettingsProvider
cfg config.CarrierSchedulerConfig
}
// NewCarrierSchedulerService 创建按运营商账户调度后台服务。
func NewCarrierSchedulerService(db *gorm.DB, redisClient *redis.Client, auditWriter *auditinfra.Writer, cfg config.CarrierSchedulerConfig) *CarrierSchedulerService {
return &CarrierSchedulerService{db: db, redis: redisClient, auditWriter: auditWriter, store: carrierqueue.NewStore(redisClient),
settings: carrierqueue.NewDefaultSettingsProvider(db, redisClient, nil), cfg: cfg}
}
func (s *CarrierSchedulerService) processes() int {
if s.cfg.SubscriberProcesses > 0 {
return s.cfg.SubscriberProcesses
}
return 1
}
// ListParams 列出全部运营商账户的调度参数(未配置的账户返回全局默认并标记默认)。
func (s *CarrierSchedulerService) ListParams(ctx context.Context) (*dto.CarrierPollingParamListResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
names, err := s.carrierNameMap(ctx)
if err != nil {
return nil, err
}
var rows []model.CarrierPollingParam
if err := s.db.WithContext(ctx).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户调度参数失败")
}
byCarrier := make(map[uint]model.CarrierPollingParam, len(rows))
for _, row := range rows {
byCarrier[row.CarrierID] = row
}
defaults := carrierqueue.MergeParams(model.CarrierPollingParam{}, false, s.cfg)
resp := &dto.CarrierPollingParamListResponse{SubscriberProcesses: s.processes(),
DefaultInflight: defaults.InflightLimit, DefaultMaxInFlight: defaults.CardMaxInFlight, Items: []dto.CarrierPollingParamItem{}}
for _, id := range sortedCarrierIDs(names) {
row, configured := byCarrier[id]
resp.Items = append(resp.Items, s.paramItem(id, names[id], row, configured))
}
return resp, nil
}
func (s *CarrierSchedulerService) paramItem(carrierID uint, name string, row model.CarrierPollingParam, configured bool) dto.CarrierPollingParamItem {
merged := carrierqueue.MergeParams(row, configured, s.cfg)
item := dto.CarrierPollingParamItem{CarrierID: carrierID, CarrierName: name, IsDefault: !configured,
InflightLimit: merged.InflightLimit, CardMaxInFlight: merged.CardMaxInFlight, ConsumeCapacity: merged.CardMaxInFlight * s.processes()}
if configured {
item.PriorityMaxRatio, item.StopResumeMaxInFlight, item.SRInflightLimit = row.PriorityMaxRatio, row.StopResumeMaxInFlight, row.SRInflightLimit
item.StopResumeMinInterval, item.StopResumeConfirmDelay = row.StopResumeMinInterval, row.StopResumeConfirmDelay
item.ConfiguredInflight, item.ConfiguredMaxInFlight = row.InflightLimit, row.CardMaxInFlight
}
return item
}
// GetParam 查询单个运营商账户调度参数。
func (s *CarrierSchedulerService) GetParam(ctx context.Context, carrierID uint) (*dto.CarrierPollingParamItem, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
carrier, err := s.loadCarrier(ctx, carrierID)
if err != nil {
return nil, err
}
row, configured, err := s.loadParam(ctx, carrierID)
if err != nil {
return nil, err
}
item := s.paramItem(carrierID, carrier.CarrierName, row, configured)
return &item, nil
}
// SaveParam 保存运营商账户调度参数;执行上限不得大于当前订阅进程 max_in_flight 之和。
func (s *CarrierSchedulerService) SaveParam(ctx context.Context, req *dto.SaveCarrierPollingParamRequest) (*dto.CarrierPollingParamItem, error) {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return nil, err
}
carrier, err := s.loadCarrier(ctx, req.CarrierID)
if err != nil {
return nil, err
}
row, configured, err := s.loadParam(ctx, req.CarrierID)
if err != nil {
return nil, err
}
before := paramState(row, configured)
row.CarrierID = req.CarrierID
row.InflightLimit, row.CardMaxInFlight, row.StopResumeMaxInFlight = req.InflightLimit, req.CardMaxInFlight, req.StopResumeMaxInFlight
row.SRInflightLimit, row.PriorityMaxRatio = req.SRInflightLimit, req.PriorityMaxRatio
row.StopResumeMinInterval, row.StopResumeConfirmDelay = req.StopResumeMinInterval, req.StopResumeConfirmDelay
merged := carrierqueue.MergeParams(row, true, s.cfg)
if capacity := merged.CardMaxInFlight * s.processes(); merged.InflightLimit > capacity {
return nil, errors.New(errors.CodeInvalidParam, "执行上限 "+strconv.Itoa(merged.InflightLimit)+" 大于当前订阅进程 max_in_flight 之和 "+
strconv.Itoa(capacity)+"("+strconv.Itoa(s.processes())+" 个进程 × "+strconv.Itoa(merged.CardMaxInFlight)+"),请调低执行上限或调高 max_in_flight")
}
if !configured {
row.Creator = operatorID
}
row.Updater = operatorID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Save(&row).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "保存运营商账户调度参数失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
ActionCode: constants.AuditActionCarrierPollingParamSaved, Summary: "保存运营商账户调度参数",
ResourceType: constants.AuditResourceCarrierPollingParam, ResourceID: req.CarrierID,
ResourceKey: "carrier_polling_param:" + uintString(req.CarrierID), DisplayName: carrier.CarrierName, OperatorID: operatorID,
IdentitySnapshot: map[string]any{"carrier_id": req.CarrierID, "inflight_limit": row.InflightLimit,
"card_max_in_flight": row.CardMaxInFlight, "priority_max_ratio": row.PriorityMaxRatio},
BeforeData: before, AfterData: paramState(row, true),
})
})
if err != nil {
return nil, err
}
item := s.paramItem(req.CarrierID, carrier.CarrierName, row, true)
return &item, nil
}
// DeleteParam 删除运营商账户调度参数,该账户恢复使用全局默认。
func (s *CarrierSchedulerService) DeleteParam(ctx context.Context, carrierID uint) error {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return err
}
row, configured, err := s.loadParam(ctx, carrierID)
if err != nil {
return err
}
if !configured {
return errors.New(errors.CodeNotFound, "该运营商账户未单独配置调度参数")
}
return runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Delete(&model.CarrierPollingParam{}, row.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除运营商账户调度参数失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
ActionCode: constants.AuditActionCarrierPollingParamDeleted, Summary: "删除运营商账户调度参数(恢复全局默认)",
ResourceType: constants.AuditResourceCarrierPollingParam, ResourceID: carrierID,
ResourceKey: "carrier_polling_param:" + uintString(carrierID), DisplayName: uintString(carrierID), OperatorID: operatorID,
IdentitySnapshot: map[string]any{"carrier_id": carrierID}, BeforeData: paramState(row, true),
})
})
}
func paramState(row model.CarrierPollingParam, configured bool) map[string]any {
if !configured {
return map[string]any{"configured": false}
}
return map[string]any{"configured": true, "inflight_limit": row.InflightLimit, "card_max_in_flight": row.CardMaxInFlight,
"stop_resume_max_in_flight": row.StopResumeMaxInFlight, "sr_inflight_limit": row.SRInflightLimit,
"priority_max_ratio": row.PriorityMaxRatio, "stop_resume_min_interval": row.StopResumeMinInterval,
"stop_resume_confirm_delay": row.StopResumeConfirmDelay}
}
func (s *CarrierSchedulerService) loadParam(ctx context.Context, carrierID uint) (model.CarrierPollingParam, bool, error) {
var row model.CarrierPollingParam
err := s.db.WithContext(ctx).Where("carrier_id = ?", carrierID).First(&row).Error
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return model.CarrierPollingParam{}, false, nil
}
if err != nil {
return row, false, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户调度参数失败")
}
return row, true, nil
}
func (s *CarrierSchedulerService) loadCarrier(ctx context.Context, carrierID uint) (*model.Carrier, error) {
var carrier model.Carrier
if err := s.db.WithContext(ctx).Select("id", "carrier_name").First(&carrier, carrierID).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(errors.CodeNotFound, "运营商账户不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户失败")
}
return &carrier, nil
}
func (s *CarrierSchedulerService) carrierNameMap(ctx context.Context) (map[uint]string, error) {
var carriers []model.Carrier
if err := s.db.WithContext(ctx).Select("id", "carrier_name").Find(&carriers).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户失败")
}
names := make(map[uint]string, len(carriers))
for _, carrier := range carriers {
names[carrier.ID] = carrier.CarrierName
}
return names, nil
}
// ListScenes 列出请求检查场景。
func (s *CarrierSchedulerService) ListScenes(ctx context.Context) ([]dto.PollingCheckSceneItem, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
var rows []model.PollingCheckScene
if err := s.db.WithContext(ctx).Order("id").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询请求检查场景失败")
}
items := make([]dto.PollingCheckSceneItem, 0, len(rows))
for _, row := range rows {
items = append(items, sceneItem(row))
}
return items, nil
}
func sceneItem(row model.PollingCheckScene) dto.PollingCheckSceneItem {
item := dto.PollingCheckSceneItem{Scene: row.Scene, SceneName: row.SceneName, Cooldown: row.Cooldown, UpdatedAt: row.UpdatedAt,
CheckTypes: []string{}, ConfirmDelays: []int{}}
_ = json.Unmarshal(row.CheckTypes, &item.CheckTypes)
_ = json.Unmarshal(row.ConfirmDelays, &item.ConfirmDelays)
return item
}
// UpdateScene 修改请求检查场景的检查类型、确认延迟与冷却;场景由代码预置,不可新增或删除。
func (s *CarrierSchedulerService) UpdateScene(ctx context.Context, req *dto.UpdatePollingCheckSceneRequest) (*dto.PollingCheckSceneItem, error) {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return nil, err
}
for index := 1; index < len(req.ConfirmDelays); index++ {
if req.ConfirmDelays[index] <= req.ConfirmDelays[index-1] {
return nil, errors.New(errors.CodeInvalidParam, "延迟确认序列必须递增")
}
}
types := uniqueStrings(req.CheckTypes)
var row model.PollingCheckScene
if err := s.db.WithContext(ctx).Where("scene = ?", req.Scene).First(&row).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(errors.CodeNotFound, "请求检查场景不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询请求检查场景失败")
}
before := sceneItem(row)
typesJSON, _ := json.Marshal(types)
delays := req.ConfirmDelays
if delays == nil {
delays = []int{}
}
delaysJSON, _ := json.Marshal(delays)
row.CheckTypes, row.ConfirmDelays, row.Cooldown, row.Updater = datatypes.JSON(typesJSON), datatypes.JSON(delaysJSON), req.Cooldown, operatorID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Model(&model.PollingCheckScene{}).Where("id = ?", row.ID).Updates(map[string]any{
"check_types": row.CheckTypes, "confirm_delays": row.ConfirmDelays, "cooldown": row.Cooldown, "updater": operatorID, "updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "修改请求检查场景失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
ActionCode: constants.AuditActionPollingCheckSceneUpdated, Summary: "修改请求检查场景",
ResourceType: constants.AuditResourcePollingCheckScene, ResourceID: row.ID,
ResourceKey: "polling_check_scene:" + row.Scene, DisplayName: row.SceneName, OperatorID: operatorID,
IdentitySnapshot: map[string]any{"scene": row.Scene, "scene_name": row.SceneName},
BeforeData: map[string]any{"check_types": before.CheckTypes, "confirm_delays": before.ConfirmDelays, "cooldown": before.Cooldown},
AfterData: map[string]any{"check_types": types, "confirm_delays": delays, "cooldown": req.Cooldown},
})
})
if err != nil {
return nil, err
}
item := sceneItem(row)
return &item, nil
}
// ListAlerts 分页查询卡轮询失败告警。
func (s *CarrierSchedulerService) ListAlerts(ctx context.Context, req *dto.PollingFailureAlertListRequest) (*dto.PollingFailureAlertPageResult, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
page, size := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PollingFailureAlert{})
if req.CarrierID != nil {
query = query.Where("carrier_id = ?", *req.CarrierID)
}
if req.CheckType != "" {
query = query.Where("check_type = ?", req.CheckType)
}
if req.ErrorCategory != "" {
query = query.Where("error_category = ?", req.ErrorCategory)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.AlertLevel != "" {
query = query.Where("alert_level = ?", req.AlertLevel)
}
if req.ICCID != "" {
query = query.Where("iccid = ?", req.ICCID)
}
start, end, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return nil, err
}
if start != nil {
query = query.Where("last_failed_at >= ?", *start)
}
if end != nil {
query = query.Where("last_failed_at <= ?", *end)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计卡轮询失败告警失败")
}
var rows []model.PollingFailureAlert
if err := query.Order("last_failed_at DESC NULLS LAST, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡轮询失败告警失败")
}
names, err := s.carrierNameMap(ctx)
if err != nil {
return nil, err
}
result := &dto.PollingFailureAlertPageResult{Total: total, Page: page, PageSize: size, List: make([]dto.PollingFailureAlertItem, 0, len(rows))}
for _, row := range rows {
result.List = append(result.List, alertItem(row, names))
}
return result, nil
}
func alertItem(row model.PollingFailureAlert, names map[uint]string) dto.PollingFailureAlertItem {
return dto.PollingFailureAlertItem{
ID: row.ID, AlertLevel: row.AlertLevel, CardID: row.CardID, ICCID: row.ICCID, CarrierID: row.CarrierID,
CarrierName: names[row.CarrierID], CheckType: row.CheckType, CheckTypeName: constants.CheckTypeName(row.CheckType),
ErrorCategory: row.ErrorCategory, FailCount: row.FailCount, FirstFailedAt: row.FirstFailedAt, LastFailedAt: row.LastFailedAt,
LastError: row.LastError, Status: row.Status, StatusName: constants.PollingAlertStatusName(row.Status),
HandledBy: row.HandledBy, HandledAt: row.HandledAt, HandleRemark: row.HandleRemark, RecoveredAt: row.RecoveredAt, CreatedAt: row.CreatedAt,
}
}
// AlertSummary 未处理卡级告警按运营商账户 × 错误类别汇总。
func (s *CarrierSchedulerService) AlertSummary(ctx context.Context) ([]dto.PollingFailureAlertSummaryItem, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
var rows []struct {
CarrierID uint
ErrorCategory string
OpenCount int64
}
if err := s.db.WithContext(ctx).Model(&model.PollingFailureAlert{}).
Select("carrier_id, error_category, COUNT(*) AS open_count").
Where("status = ? AND alert_level = ?", constants.PollingAlertStatusOpen, constants.PollingAlertLevelCard).
Group("carrier_id, error_category").Order("open_count DESC").Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "汇总卡轮询失败告警失败")
}
names, err := s.carrierNameMap(ctx)
if err != nil {
return nil, err
}
items := make([]dto.PollingFailureAlertSummaryItem, 0, len(rows))
for _, row := range rows {
items = append(items, dto.PollingFailureAlertSummaryItem{CarrierID: row.CarrierID, CarrierName: names[row.CarrierID],
ErrorCategory: row.ErrorCategory, OpenCount: row.OpenCount})
}
return items, nil
}
// GetAlert 查询告警详情。
func (s *CarrierSchedulerService) GetAlert(ctx context.Context, id uint) (*dto.PollingFailureAlertItem, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
row, err := s.loadAlert(ctx, id)
if err != nil {
return nil, err
}
names, err := s.carrierNameMap(ctx)
if err != nil {
return nil, err
}
item := alertItem(*row, names)
return &item, nil
}
// HandleAlert 把未处理告警标记为已处理。
func (s *CarrierSchedulerService) HandleAlert(ctx context.Context, req *dto.HandlePollingFailureAlertRequest) error {
operatorID, err := requireSuperAdminWriterOrPlatform(ctx)
if err != nil {
return err
}
row, err := s.loadAlert(ctx, req.ID)
if err != nil {
return err
}
if row.Status != constants.PollingAlertStatusOpen {
return errors.New(errors.CodeInvalidStatus, "只有未处理的告警可以标记已处理")
}
now := time.Now()
return runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
result := tx.WithContext(ctx).Model(&model.PollingFailureAlert{}).Where("id = ? AND status = ?", row.ID, constants.PollingAlertStatusOpen).
Updates(map[string]any{"status": req.Status, "handled_by": operatorID, "handled_at": now, "handle_remark": req.Remark, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "处理卡轮询失败告警失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeConflict, "告警状态已变化,请刷新后重试")
}
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
ActionCode: constants.AuditActionPollingFailureAlertHandled, Summary: "标记卡轮询失败告警已处理",
ResourceType: constants.AuditResourcePollingFailureAlert, ResourceID: row.ID,
ResourceKey: "polling_failure_alert:" + uintString(row.ID), DisplayName: row.ICCID, OperatorID: operatorID,
IdentitySnapshot: map[string]any{"id": row.ID, "alert_level": row.AlertLevel, "card_id": row.CardID,
"carrier_id": row.CarrierID, "check_type": row.CheckType, "status": row.Status},
BeforeData: map[string]any{"status": row.Status}, AfterData: map[string]any{"status": req.Status, "handle_remark": req.Remark},
})
})
}
func (s *CarrierSchedulerService) loadAlert(ctx context.Context, id uint) (*model.PollingFailureAlert, error) {
var row model.PollingFailureAlert
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(errors.CodeNotFound, "卡轮询失败告警不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡轮询失败告警失败")
}
return &row, nil
}
// CardState 查询单卡轮询状态(只读)。
func (s *CarrierSchedulerService) CardState(ctx context.Context, cardID uint) (*dto.CardPollingStateResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
var card model.IotCard
if err := s.db.WithContext(ctx).Select("id", "iccid", "carrier_id", "enable_polling").First(&card, cardID).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
resp := &dto.CardPollingStateResponse{CardID: card.ID, ICCID: card.ICCID, CarrierID: card.CarrierID,
SchedulerMode: s.settings.Current(ctx).Mode, EnablePolling: card.EnablePolling, Queue: "none", Types: []dto.CardPollingTypeState{}}
due, err := s.store.ReadDue(ctx, card.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "读取卡轮询到期信息失败")
}
carrierID := due.CarrierID
if carrierID == 0 {
carrierID = card.CarrierID
}
member := strconv.FormatUint(uint64(card.ID), 10)
if score, err := s.redis.ZScore(ctx, constants.RedisCarrierPriorityQueueKey(carrierID), member).Result(); err == nil {
resp.Queue, resp.QueueScoreAt = "priority", msTime(int64(score))
} else if score, err := s.redis.ZScore(ctx, constants.RedisCarrierQueueKey(carrierID), member).Result(); err == nil {
resp.Queue, resp.QueueScoreAt = "queue", msTime(int64(score))
}
if ttl, err := s.redis.PTTL(ctx, constants.RedisCardClaimKey(card.ID)).Result(); err == nil && ttl > 0 {
resp.Running = true
expire := time.Now().Add(ttl)
resp.ClaimExpireAt = &expire
}
resp.PriorityMark = due.Priority
for _, checkType := range constants.AllCheckTypes() {
item, ok := due.Types[checkType]
if !ok || (!item.HasDue && item.FailCount == 0 && item.LastOK.IsZero()) {
continue
}
state := dto.CardPollingTypeState{CheckType: checkType, CheckTypeName: constants.CheckTypeName(checkType),
FailCount: item.FailCount, LastError: item.LastError, PendingConfirm: []int{}}
if item.HasDue {
state.NextDueAt = timePtr(item.Due)
}
if !item.LastOK.IsZero() {
state.LastSuccessAt = timePtr(item.LastOK)
}
if !item.FirstFailed.IsZero() {
state.FirstFailedAt = timePtr(item.FirstFailed)
}
for _, delay := range item.Confirm {
state.PendingConfirm = append(state.PendingConfirm, int(delay/time.Second))
}
resp.Types = append(resp.Types, state)
}
return resp, nil
}
// CarrierStats 按运营商账户的轮询监控。
func (s *CarrierSchedulerService) CarrierStats(ctx context.Context) (*dto.CarrierPollingStatsResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
names, err := s.carrierNameMap(ctx)
if err != nil {
return nil, err
}
var params []model.CarrierPollingParam
if err := s.db.WithContext(ctx).Find(&params).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户调度参数失败")
}
paramByCarrier := map[uint]model.CarrierPollingParam{}
for _, row := range params {
paramByCarrier[row.CarrierID] = row
}
var alertRows []struct {
CarrierID uint
ErrorCategory string
Count int64
}
if err := s.db.WithContext(ctx).Model(&model.PollingFailureAlert{}).Select("carrier_id, error_category, COUNT(*) AS count").
Where("status = ? AND alert_level = ?", constants.PollingAlertStatusOpen, constants.PollingAlertLevelCard).
Group("carrier_id, error_category").Scan(&alertRows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计卡轮询失败告警失败")
}
state, _ := s.redis.Get(ctx, constants.RedisCarrierSchedulerInitStateKey).Result()
resp := &dto.CarrierPollingStatsResponse{SchedulerMode: s.settings.Current(ctx).Mode, InitState: state, Items: []dto.CarrierPollingStatsItem{}}
now := time.Now()
nowMS := strconv.FormatInt(now.UnixMilli(), 10)
lastMinute := now.Add(-time.Minute).In(time.FixedZone("CST", 8*3600)).Format("200601021504")
for _, carrierID := range sortedCarrierIDs(names) {
row, configured := paramByCarrier[carrierID]
item := dto.CarrierPollingStatsItem{CarrierID: carrierID, CarrierName: names[carrierID],
InflightLimit: carrierqueue.MergeParams(row, configured, s.cfg).InflightLimit, OpenAlertsByType: map[string]int64{}}
pipe := s.redis.Pipeline()
priorityLen := pipe.ZCard(ctx, constants.RedisCarrierPriorityQueueKey(carrierID))
queueLen := pipe.ZCard(ctx, constants.RedisCarrierQueueKey(carrierID))
priorityDue := pipe.ZCount(ctx, constants.RedisCarrierPriorityQueueKey(carrierID), "-inf", nowMS)
queueDue := pipe.ZCount(ctx, constants.RedisCarrierQueueKey(carrierID), "-inf", nowMS)
running := pipe.ZCount(ctx, constants.RedisCarrierRunningKey(carrierID), nowMS, "+inf")
expired := pipe.HGetAll(ctx, constants.RedisCarrierExpiredClaimsKey(carrierID))
var statCmds []*redis.MapStringStringCmd
for _, op := range []string{constants.GatewayOpQueryStatus, constants.GatewayOpQueryFlow, constants.GatewayOpQueryRealname} {
statCmds = append(statCmds, pipe.HGetAll(ctx, constants.RedisGatewayCallStatKey(carrierID, op, lastMinute)))
}
_, _ = pipe.Exec(ctx)
item.PriorityLength, item.QueueLength = priorityLen.Val(), queueLen.Val()
item.DueCount, item.Running = priorityDue.Val()+queueDue.Val(), running.Val()
cutoff := now.Add(-time.Hour).Unix() / 60
for minute, value := range expired.Val() {
if m, err := strconv.ParseInt(minute, 10, 64); err == nil && m >= cutoff {
count, _ := strconv.ParseInt(value, 10, 64)
item.ExpiredClaims1h += count
}
}
var attempts int64
for _, cmd := range statCmds {
values := cmd.Val()
attempts += atoi64(values["attempts"])
item.RateLimitWaits += atoi64(values["rate_wait"])
item.RateLimited += atoi64(values[constants.GatewayErrorRateLimited])
}
item.QPSLastMinute = float64(attempts) / 60
for _, alert := range alertRows {
if alert.CarrierID == carrierID {
item.OpenAlerts += alert.Count
item.OpenAlertsByType[alert.ErrorCategory] += alert.Count
}
}
resp.Items = append(resp.Items, item)
}
return resp, nil
}
// requireSuperAdminWriterOrPlatform 校验告警处理权限:超级管理员与平台账号均可,返回操作者 ID。
func requireSuperAdminWriterOrPlatform(ctx context.Context) (uint, error) {
if err := requirePlatformReader(ctx); err != nil {
return 0, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeUnauthorized, "未授权访问")
}
return operatorID, nil
}
func sortedCarrierIDs(names map[uint]string) []uint {
ids := make([]uint, 0, len(names))
for id := range names {
ids = append(ids, id)
}
for i := 1; i < len(ids); i++ {
for j := i; j > 0 && ids[j] < ids[j-1]; j-- {
ids[j], ids[j-1] = ids[j-1], ids[j]
}
}
return ids
}
func msTime(ms int64) *time.Time {
t := time.UnixMilli(ms)
return &t
}
func timePtr(t time.Time) *time.Time {
return &t
}
func atoi64(s string) int64 {
value, _ := strconv.ParseInt(s, 10, 64)
return value
}

View File

@@ -602,6 +602,14 @@ const (
AuditActionGatewayErrorRuleStatusUpdated = "gateway_error_rule.update_status"
// AuditActionGatewayErrorRuleDeleted 表示删除上游错误分类规则。
AuditActionGatewayErrorRuleDeleted = "gateway_error_rule.delete"
// AuditActionCarrierPollingParamSaved 表示保存运营商账户调度参数。
AuditActionCarrierPollingParamSaved = "carrier_polling_param.save"
// AuditActionCarrierPollingParamDeleted 表示删除运营商账户调度参数(恢复全局默认)。
AuditActionCarrierPollingParamDeleted = "carrier_polling_param.delete"
// AuditActionPollingCheckSceneUpdated 表示修改请求检查场景配置。
AuditActionPollingCheckSceneUpdated = "polling_check_scene.update"
// AuditActionPollingFailureAlertHandled 表示处理卡轮询失败告警。
AuditActionPollingFailureAlertHandled = "polling_failure_alert.handle"
// AuditActionPollingConcurrencyUpdated 表示更新轮询任务并发配置。
AuditActionPollingConcurrencyUpdated = "polling_concurrency.update"
// AuditActionPollingConcurrencyReset 表示人工重置轮询并发计数。
@@ -781,6 +789,12 @@ const (
AuditResourceRateLimitGroup = "rate_limit_group"
// AuditResourceGatewayErrorRule 表示上游错误分类规则资源。
AuditResourceGatewayErrorRule = "gateway_error_rule"
// AuditResourceCarrierPollingParam 表示运营商账户调度参数资源。
AuditResourceCarrierPollingParam = "carrier_polling_param"
// AuditResourcePollingCheckScene 表示请求检查场景资源。
AuditResourcePollingCheckScene = "polling_check_scene"
// AuditResourcePollingFailureAlert 表示卡轮询失败告警资源。
AuditResourcePollingFailureAlert = "polling_failure_alert"
// AuditResourcePollingConcurrencyConfig 表示轮询并发配置资源。
AuditResourcePollingConcurrencyConfig = "polling_concurrency"
// AuditResourcePollingAlertRule 表示轮询告警规则资源。