diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 6fd1c1e9..5bcfff1c 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -31,6 +31,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) + // 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 + handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil) // 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) // 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 diff --git a/cmd/api/main.go b/cmd/api/main.go index b001114c..cab54dcb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -22,6 +22,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/internal/service/verification" @@ -71,6 +72,11 @@ func main() { // 9. 初始化 Gateway 客户端(可选) gatewayClient := initGateway(cfg, appLogger) + // 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。 + gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger) + if gatewayClient != nil { + gatewayClient.SetCallGuard(gatewayLimit.Guard) + } // 10. 初始化所有业务组件(通过 Bootstrap) result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{ @@ -83,6 +89,7 @@ func main() { QueueClient: queueClient, StorageService: storageSvc, GatewayClient: gatewayClient, + GatewayLimit: gatewayLimit, }) if err != nil { appLogger.Fatal("初始化业务组件失败", zap.Error(err)) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 438233e3..aab1ae7f 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -40,6 +40,8 @@ func generateAdminDocs(outputPath string) error { handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) + // 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 + handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil) // 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) // 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 10069d19..a9abad5d 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -34,6 +34,7 @@ import ( cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold" commissionDelivery "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" @@ -99,6 +100,8 @@ type workerRuntime struct { carrierThresholdService *carrierThresholdApp.Service // assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。 assetAutoRenewalService *assetAutoRenewalApp.Service + // gatewayLimit 是卡网关运营商账户限流组件(限流、统计、停复机记录)。 + gatewayLimit *gatewaylimit.Runtime } func main() { @@ -178,6 +181,8 @@ func runWorker(cfg *config.Config) { registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger) outboxHandler := outbox.NewHandler(runtime.outboxConsumers) 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) startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger) rescuePendingImportTasks(ctx, runtime, appLogger) @@ -266,6 +271,11 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L storageSvc := initStorage(cfg, appLogger) gatewayClient := initGateway(cfg, appLogger) + // 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。 + gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger) + if gatewayClient != nil { + gatewayClient.SetCallGuard(gatewayLimit.Guard) + } asynqClient := asynq.NewClient(asynq.RedisClientOpt{ Addr: redisAddr, @@ -282,6 +292,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L QueueClient: workerQueueClient, StorageService: storageSvc, GatewayClient: gatewayClient, + GatewayLimit: gatewayLimit, } workerResult, err := bootstrap.BootstrapWorker(workerDeps) @@ -289,7 +300,13 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L appLogger.Fatal("Worker Bootstrap 失败", zap.Error(err)) } - workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger) + workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger, queue.WithRateLimitedRetry(queue.RateLimitedRetry{ + Match: gateway.IsRateLimited, + Delay: func() time.Duration { + return gatewayLimit.Settings.Current(context.Background()).RetryDelay + }, + OnDeferred: gatewayLimit.TrackDeferral, + })) outboxQueueClient := workerQueueClient pollingConfigStore := postgres.NewPollingConfigStore(db) @@ -324,6 +341,9 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L cfg.Polling.VerboseLog, cfg.Worker.PollingTotalMaxConcurrency, ) + pollingBase.SetRateLimitedDelay(func() time.Duration { + return gatewayLimit.Settings.Current(context.Background()).RetryDelay + }) if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok { stopResumeSvc.SetPollingCallback(lifecycleSvc) } @@ -337,6 +357,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L db: db, storageSvc: storageSvc, gatewayClient: gatewayClient, + gatewayLimit: gatewayLimit, asynqClient: asynqClient, workerResult: workerResult, workerServer: workerServer, @@ -996,6 +1017,14 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna )); err != nil { return fmt.Errorf("注册数据清理定时任务失败: %w", err) } + if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 2 * * *", asynq.NewTask( + constants.TaskTypeStopResumeRecordPartition, + nil, + asynq.MaxRetry(3), + asynq.Queue(constants.QueueDataCleanup), + )); err != nil { + return fmt.Errorf("注册停复机记录分区维护定时任务失败: %w", err) + } if _, err := asynqScheduler.Register("15 2 * * *", asynq.NewTask( constants.TaskTypeNotificationCleanup, nil, diff --git a/docs/engineering/工程约束.md b/docs/engineering/工程约束.md index d03717c9..7f61f8bc 100644 --- a/docs/engineering/工程约束.md +++ b/docs/engineering/工程约束.md @@ -283,9 +283,9 @@ - **最小正例**:外部失败写脱敏 Integration Log,HTTP 请求写 Access Log。 - **最小反例**:把完整凭证写 app.log。 - **机械检查/人工原因**:人工核对 Logger 字段、Sanitizer 与事实存储;本地按 request_id/correlation_id 查询日志。 -- **例外条件**:第三方要求回显的非敏感渠道码可保留。 +- **例外条件**:第三方要求回显的非敏感渠道码可保留。卡网关调用(add-carrier-gateway-rate-limit):普通轮询查询不写 Integration Log,失败原因与调用量由 Redis 按运营商账户的分钟计数与卡最近错误承载(同时是优先执行的轮次仍写);系统自动停复机不写 Integration Log 与 Audit Event,每次上游调用写停复机记录表 `tb_card_stop_resume_record`;后台人工点击、通道阈值、自动续费及账户、个人客户、OpenAPI 发起的停复机保持原写法。 - **Owner**:审计与安全负责人 -- **最后验证日期**:2026-08-07 +- **最后验证日期**:2026-10-24 - **更新触发条件**:日志字段、保留策略或外部集成变化 ## ENG-SECRET-001 diff --git a/docs/engineering/轮询按运营商账户限流技术方案.md b/docs/engineering/轮询按运营商账户限流技术方案.md new file mode 100644 index 00000000..28fcfa18 --- /dev/null +++ b/docs/engineering/轮询按运营商账户限流技术方案.md @@ -0,0 +1,1222 @@ +# 轮询按运营商账户限流技术方案 + +最后更新:2026-10-24 +状态:技术方案(未实现)。已定事项与后续事项见第七部分。 + +文档结构: +- 第一部分:现状逻辑(As-Is) +- 第二部分:方案总览 +- 第三部分:详细设计(To-Be) +- 第四部分:清理项 +- 第五部分:改动范围与实施拆分 +- 第六部分:与老系统对比 +- 第七部分:待确认与后续事项 + +--- + +## 第一部分:现状逻辑(As-Is) + +### 1. 队列结构 + +- 固定 16 个分片:`PollingShardCount = 16`(`pkg/constants/polling.go:7`),分片号 `card_id % 16`,与运营商账户无关。 +- 每个分片下每种任务类型一个 ZSET:`polling:shard:{shardID}:queue:{taskType}`,score 为下次检查时间戳(`pkg/constants/redis.go:491`)。 +- 5 种任务类型(`pkg/constants/constants.go:64-68`): + +| 类型 | 含义 | 是否调网关 | +|---|---|---| +| `polling:realname` | 实名状态检查 | 是,查实名 | +| `polling:carddata` | 卡流量检查 | 是,查流量 | +| `polling:card_status` | 卡开停机状态检查 | 是,查状态 | +| `polling:protect` | 保护期一致性检查 | 仅在需要时调停机或复机 | +| `polling:package` | 套餐检查(定期重新判断停复机) | 不查网关,判断出要停复机时调停机或复机 | + +- 另有两个按任务类型划分的 List:优先轮询提示通道 `polling:priority:{taskType}`;手动触发队列 `polling:manual:{taskType}`(接口 `/polling-manual-trigger`,`internal/routes/admin.go:149`)。 + +### 2. 入队 + +- 启动全量初始化:`internal/polling/initializer.go:268`,分批读库,按 last_check_at 和间隔算下次时间,Pipeline 写 ZADD 和卡缓存。 +- 执行后重入队:`Requeue`(ZADD,覆盖时间);`EnsureQueued`(ZADD NX,只补缺失)。 +- 卡状态变化:`OnCardStatusChanged`(`internal/polling/lifecycle_service.go:66`)先从所有队列移除,再按"当前时间 + 0~interval/10 秒抖动"重新入队全部类型。 + +### 3. 调度(`internal/polling/scheduler.go:158`,每 1 秒) + +1. 先清空优先提示通道和手动触发队列(每类型最多 1000 条),不受初始化状态和背压影响。 +2. 初始化未完成则返回。 +3. 16 个分片各起一个 goroutine,整轮超时 0.9 秒。 +4. 每个分片内按 5 种类型依次:ZCARD 查深度,超过 50 万跳过(背压);Lua 原子出队到期卡(最多 7000 条);逐条 `Enqueue` 到 Asynq(`MaxRetry(0)`、超时 60 秒、按类型分队列);投递失败则按当前时间 Requeue。 + +### 4. 执行与并发控制 + +- Asynq Worker 协程数来自 `queue.concurrency`(默认 10),5 个轮询队列权重都为 1,与其他业务队列共用协程池(`pkg/constants/constants.go:365-376`)。 +- handler 执行时抢 Redis 令牌(`internal/task/polling_base.go:108`):一次 Lua 同时 INCR 全局计数(`polling_total_max_concurrency`,默认 100)和该任务类型计数(后台可配,默认 300),任一超限即回滚并 Requeue;执行完 DECR;key TTL 10 分钟。 +- 配置表 `tb_polling_concurrency_config` 按 task_type 存上限,`ConcurrencyService` 同步到 Redis。 +- 没有任何按运营商账户的并发或 QPS 控制。 + +### 5. 停复机 + +- 统一入口 `StopResumeService.EvaluateAndAct`(`internal/service/iot_card/stop_resume_service.go:113`): + - 在线卡:停机原因优先级为 流量用完 > 无有效套餐 > 实名不满足;设备维度原因会抢设备锁(SetNX 30 秒),逐张停掉设备下所有在线卡。 + - 停机卡:须同时满足 有效套餐、流量未用完、实名满足 才复机;设备卡同样按设备批量复机。 + - 停复机新规则(`Iteration/8-11` 已实现,`internal/service/iot_card/stop_resume_unified.go`,总开关 `stop_resume.unified_rule_enabled` 默认关闭): + - 开启后:复机 = 有生效套餐 + 流量充足 +(运营商账户未开实名管控,或开了实名管控且已实名),不看停机原因;实名管控为运营商账户字段 `tb_carrier.realname_control_enabled`,不区分行业卡、不看卡或设备的 `realname_policy`;卡或所在设备暂停轮询时不自动停复机;持通道阈值锁仍拒绝复机。 + - 关闭时(旧口径):只对轮询停机原因自动复机;实名满足 = 行业卡或已实名,否则所属资产 `realname_policy = none`。 + - 实名轮询仍按卡类别跳过行业卡(`internal/task/polling_realname_handler.go:70`),未随新规则调整。 + - 每次网关调用失败重试 3 次,每次 sleep 2 秒(`stop_resume_service.go:99-100`)。 +- 触发路径: + - 轮询任务内同步执行:`package` 调 `EvaluateAndAct`;`protect` 保护期内调 `ForceStopCard` / `ForceStartCard`,保护期外调 `EvaluateAndAct`。 + - 经 Outbox 异步执行:查流量、查状态、查实名写入观测结果后产生 Outbox 事件,由卡观测消费者(`cmd/worker/main.go:658-678`)调用 `EvaluateAndAct`。"流量用完停机"走这条路径。 + - 套餐激活(`internal/service/package/activation_service.go:718` `resumeAfterActivation`,实名激活、排队激活、指定激活、队首激活 4 处调用)、流量重置(`internal/service/package/reset_service.go:400`,每个套餐一个 goroutine)、订单支付后(`internal/service/order/service.go:2196`):事务提交后起裸 goroutine 调 `ResumeCardIfStopped` 当场复机,失败只打日志,不重试。 + - 退款后资产处理(`internal/service/refund/service.go:1243` `stopAsset`):退款后资产无可用主套餐时调 `ManualStopCard` 当场停机,停机原因记为 `manual`;`ManualStopCard` 内部 sleep 重试,失败后由 Outbox 重投与补偿扫描(`internal/infrastructure/commissiondelivery/event.go` `RecoverRefundPostProcessing`)反复补发,卡已停机也照样调网关。 +- 停复机没有独立队列,在哪个任务里触发就在哪个任务里同步调网关。 + +### 5.1 业务事件触发的卡观测序列 + +- 机制:`SeriesTrigger`(`internal/application/cardobservation/series.go`)为每张卡、每种检查(状态、流量、实名)创建固定的"立即、3 分钟、5 分钟"三次尝试(`pkg/constants/card_observation.go:38`),作为 Asynq 任务执行,不经过轮询调度,不受任何限流约束。 +- 业务事件入口(经 Outbox 写入 `AppendSeriesRequested`):订单支付后(`internal/service/order/service.go:2276`)、套餐激活(`internal/service/package/activation_service.go:744`)、停复机成功后(`internal/service/iot_card/stop_resume_service.go:1104`)、设备操作(`internal/service/device/service.go:1751`)、自动续费购包(`internal/task/auto_purchase.go:288`)。 +- 读入口直接分发(`BestEffortSeriesDispatcher`):C 端资产详情(`internal/handler/app/client_asset.go:366`)、后台资产实时状态(`internal/handler/admin/asset.go:313`)、实名链接(`internal/handler/app/client_realname.go:158`、`internal/service/iot_card/gateway_service.go:76`)。OpenAPI 查询不触发观测序列(`agent-open-api` 规范明确要求,2026-10-24 按代码核实)。 +- 与优先轮询重复:套餐生效时已产生优先轮询项,卡会被立即轮询一次;观测序列又额外查 3 轮。 +- 生产实例(2026-10-22):代理 11:40:58~11:41:08 线下支付 27 笔设备套餐订单,约 70 张卡 × 3 种检查 × 3 次,约 600 次网关调用集中在 11:41、11:44、11:46 三个瞬间,11:46:04 单秒 177 次。 + +### 6. 保护期 + +- 来源:后台对设备停机或复机时设置 1 小时保护期(`internal/service/device/service.go:1506,1663`),key `protect:device:{id}:stop|start`,二者互斥。 +- 作用:保护期内阻止相反的人工操作;`protect` 轮询发现方向不一致时强制停复机。 +- 问题:自动停复机 `EvaluateAndAct` 不检查保护期,保护期内会出现"自动停机 → 保护期强制复机 → 再自动停机"的反复翻转;且与"规则优先"原则冲突。 + +### 7. 运营商账户 + +- `tb_carrier` 一行实际是一个上游账户(通道),名称中带上游账号,例如 `联通31-710WLW17915117`、`移动15-BE20030985258`。 +- 上游的频率限制以账户为单位,现有并发控制既不按账户,数值(300、100)也没有依据。 + +### 8. 现状问题汇总 + +1. 并发控制不以上游账户为单位,数值没有依据,无法防止上游超频或繁忙。 +2. 背压按 ZCARD 统计整个集合(含未到期),单分片超过 50 万卡后永久停止出队。 +3. 出队后到投递 Asynq 前进程崩溃,卡会丢失直到下次全量初始化。 +4. `Enqueue` 不受 0.9 秒超时控制,单轮可能逐条投递数十万任务,阻塞主循环;回退 Requeue 用的是可能已过期的 ctx。 +5. 任务进入 Asynq 后不区分账户;慢账户会占满 Worker 协程,影响其他账户。 +6. 同一张卡的不同类型可以并行执行。 +7. 停复机在执行过程中 sleep 重试,长时间占用协程;设备停复机可能跨账户。 +8. 保护期机制与"规则优先"冲突,且实现上会反复翻转。 +9. 手动刷新后 `OnCardStatusChanged` 会让所有类型几秒内重新轮询一遍,网关调用翻倍(本方案不处理,见第七部分)。 +10. 开启轮询开关的卡全部按配置轮询,配置只能区分网络与实名状态,库存卡、无套餐卡与在用卡同频调网关;无套餐仍在跑流量的卡没有专门兜底。 +11. 停复机新规则靠总开关与旧口径并存,代码里同时保留两套实名判断与复机条件;实名轮询仍按行业卡跳过,与新规则的运营商账户实名管控口径不一致。 +12. 套餐激活、流量重置、订单触发的复机在裸 goroutine 中当场执行:失败只打日志,客户付费后卡可能一直停机且无人知晓;流量重置日集中复机,瞬间打满上游;与流量入账后的停机可能并发反向操作同一张卡。 +13. 无套餐期间若不查流量,按月对账(本月最大快照 − 本月已结算)会把无套餐期间、以及二次销售前上一用户本月的用量,算进新激活的套餐。 +14. 流量查询稀疏(平均每卡约 1.5 天一次),每张卡在运营商流量重置日清零前最后一次查询到清零之间的用量查不到、扣不到套餐上(旧周期尾部丢失)。 +15. 退款触发的停机重试风暴:退款走人工停机接口(`ManualStopCard` 内部 sleep 重试),失败后 Outbox 重投与每分钟补偿扫描反复补发;所有上游返回一律按普通失败处理。生产 2026-10-22:5 笔退款涉及 5 张广电卡,其中 4 张处于上游终态(号码封存禁止办理、已销户、风险停机仅支持风险复机),根本无法停机;另 1 张被上游单卡频率限制("3 分钟内同一张卡相同操作仅允许一次""卡状态变更每十分钟只允许调用 1 次")拦截,重试间隔远小于窗口,每次重试都重置窗口。结果停机请求 24 小时失败约 26.8 万次,单卡每 1~2 秒调用一次,持续冲击上游;且错误原因只在统一审计中可见。 +16. 业务事件触发的卡观测序列固定"立即、3 分钟、5 分钟"三连查且不受限流约束,批量下单等批量操作在同一瞬间形成波峰;与优先轮询重复查询。 +17. 失败原因与调用量不可见:轮询调用网关失败时,集成日志 `provider_code`、`provider_message` 为空、`duration_ms` 为 0;网关业务错误码只拼在错误字符串里(`internal/gateway/client.go:274`);轮询成功且数据未变化时不记录。无法区分限频、卡错误与超时,也算不出各账户成功率与真实调用量(生产 2026-10-22:24 小时轮询失败约 37 万次,原因均为空)。 +18. 投递无去重:调度器投递 Asynq 时未设置 TaskID(`internal/polling/scheduler.go:282`),优先提示与手动触发 List 可重复 push、查状态时"补齐缺失的套餐任务"(`ensureMissingTask`)会把正在 Asynq 中排队的任务当作缺失重新补入、生命周期事件会把排队中的卡重新放回 ZSET,同一张卡同一检查类型可能被重复投递执行;退款重试风暴即重复投递的一例。 + +--- + +## 第二部分:方案总览 + +### 1. 目标 + +- 轮询按运营商账户(`tb_carrier` 一行即一个上游账户)隔离调度。 +- 每个运营商账户的网关 QPS 不超过配置上限,后台修改即时生效。 +- 同一张卡的各类检查串行执行。 +- 停复机优先级最高。 +- 规则优先:只要符合规则就自动停复机,人工操作不享有豁免期。 +- 所有数值与分组由后台配置,代码中不出现运营商账户名称、分组方式或业务数值。 + +### 2. 范围 + +在范围内: +- 所有自动触发的卡检查与停复机:轮询、业务事件与读入口触发的检查、激活前流量结算,以及轮询、Outbox 消费者、套餐激活、流量重置、订单、退款、套餐到期、实名管控修改触发的自动停复机。 +- 卡的 5 个网关操作的限流:查流量、查状态、查实名、停机、复机。 +- 保护期机制的整体清理。 +- 分层轮询与到期即停(第三部分第 11 节)。 +- 自动停复机统一出口:轮询、Outbox 消费者、套餐激活、流量重置、订单、退款触发的自动停复机都只请求评估,统一由停复机指令队列执行(第三部分第 6 节)。 +- 业务事件触发的卡检查并入卡任务:不再起观测序列,改为请求检查(第三部分第 5.1 节)。 +- 套餐激活前流量结算,保证无套餐期间的用量不计入新套餐(第三部分第 11.9 节)。 + +不在范围内: +- 批量查询:gateway 目前不支持(`internal/gateway/flow_card.go:62` 为预留未实现),后期 gateway 支持后再增加;本方案在步骤接口上预留查询结果外部传入的能力(见第三部分第 5 节)。 +- 手动刷新(`/assets/:identifier/refresh`)。 +- 后台人工点击的停复机、通道阈值、自动续费直接发起的停复机、手动刷新:照旧直接调网关,不限流;其余所有自动触发的检查与停复机均受运营商账户限流约束。 +- 手动触发队列接口保留,只调整消费方式。 +- 停复机业务规则不新增:沿用 `Iteration/8-11` 已实现的停复机新规则,本方案只将其转正(第三部分第 12 节);提前停机、续费联动等不在范围。 + +### 3. 核心模型 + +``` + ┌──────────── 每个运营商账户(Redis 延时队列)────────────┐ + 规则判断 ──生成指令──▶ stop_resume ─┐ + 优先提示 ───────────▶ priority ─┼─▶ 分发器(每秒,按 停复机 > 优先 > 普通、剩余 QPS 与执行上限领取) + 到期检查 ───────────▶ queue ─┘ 领取即写处理中标记(token + TTL),卡留在队列中 + └──────────────────────────┬─────────────────────────────┘ + ▼ 发布 + NSQ topic:polling.stop_resume.{cid}(专用消费者) + polling.card.{cid} + ▼ 消费(max_in_flight 控制每进程并发) + 网关限流器(运营商账户 → 限流组 → 操作,QPS) + │ + ▼ + gateway + + 设备维度判断请求 ──▶ device_eval 队列 ──▶ 分发器 ──▶ NSQ polling.device_eval ──▶ 生成设备下各卡的停复机指令 +``` + +分工:Redis 负责到期调度(含长延迟)、同卡去重与串行、执行上限、QPS 限流、设备锁,是唯一权威状态;NSQ 替代 Asynq 负责执行分发、消费者并发与执行超时重投。 + +--- + +## 第三部分:详细设计(To-Be) + +### 1. 轮询类型 + +| 名称 | 是否调网关 | 说明 | +|---|---|---| +| 卡状态 `card_status` | 是 | 查状态 | +| 流量 `carddata` | 是 | 查流量 | +| 实名 `realname` | 是 | 查实名 | +| 套餐 `package` | 否 | 定期按规则重新判断停复机 | +| 停复机指令 | 是 | 独立队列,优先级最高 | + +`protect` 类型下线(见第四部分)。 + +### 2. Redis 数据结构 + +每个运营商账户 `{cid}`: + +| Key | 类型 | 内容 | +|---|---|---| +| `polling:carrier:{cid}:stop_resume` | ZSET | 停复机指令,member=card_id,score=可执行时间;被领取后 score 推后为处理中标记到期时间 | +| `polling:carrier:{cid}:priority` | ZSET | 优先卡,member=card_id,score=可执行时间;被领取后同样推后 | +| `polling:carrier:{cid}:queue` | ZSET | 普通卡,member=card_id,score=该卡所有检查类型中最早的到期时间;被领取后同样推后 | +| `polling:carrier:{cid}:running` | ZSET | 仅用于计数:执行中的卡任务,member=派发 token,score=到期时间;用于执行上限与监控 | +| `polling:carrier:{cid}:sr_running` | ZSET | 仅用于计数:执行中的停复机指令,结构同上 | + +每张卡: + +| Key | 类型 | 内容 | +|---|---|---| +| `polling:card:{id}:due` | Hash | 各检查类型的下次到期时间、优先标记、各检查类型的连续失败次数 / 首次失败时间 / 最近错误 | +| `polling:card:{id}:stop_resume_cmd` | Hash | 停复机指令:动作、原因、来源、场景、生成时间、版本号 `ver`(每次生成或覆盖指令时 `HINCRBY`)、连续失败次数、首次失败时间、最近错误、待确认标记(上游返回已受理类频率限制时置位,下次执行前先查卡状态) | +| `polling:card:{id}:sr_last_call` | String | 本卡最近一次调用上游停机或复机的时间(不分操作,停机与复机合计),用于单卡最小重试间隔;TTL 不小于单卡最小重试间隔 | +| `polling:card:{id}:claim` | String | 卡任务处理中标记,值=派发 token,TTL=租约时长 | +| `polling:card:{id}:sr_claim` | String | 停复机指令处理中标记,值=派发 token,TTL=租约时长 | +| `polling:card:{id}:exec` / `sr_exec` | String | 执行权:值=派发 token + 消费者实例 ID,TTL=续期间隔×2,随续期延长;防止 NSQ 重投时同一 token 被两个消费者同时执行(见 3.3 节) | + +设备维度判断: + +| Key | 类型 | 内容 | +|---|---|---| +| `polling:device_eval:queue` | ZSET | 待判断设备,member=device_id,score=可执行时间;同一设备只有一个成员;被领取后 score 推后 | +| `polling:device:{id}:eval_ver` | String | 判断请求版本号,每次请求 `INCR` | +| `polling:device:{id}:eval_scenes` | Set | 待处理的请求场景(轮询、到期、退款、激活等),多次请求取并集(见 6.1 节) | +| `polling:device:{id}:eval_claim` | String | 设备判断处理中标记,值=派发 token,TTL=租约时长 | +| `polling:device:{id}:eval_lock` | String | 设备维度判断锁,值为持有者 token,带 TTL | + +分发锁: + +| Key | 类型 | 内容 | +|---|---|---| +| `polling:carrier:{cid}:dispatch_lock` | String | 账户分发锁,值=分发器实例 token,TTL 略小于分发间隔;同一账户每轮只由一个实例领取(见 3.2 节) | +| `polling:device_eval:dispatch_lock` | String | 设备判断分发锁,同上 | + +网关限流: + +| Key | 类型 | 内容 | +|---|---|---| +| `gateway:qps:{cid}:{group}` | ZSET | 滑动窗口内每次请求的毫秒时间戳 | +| `gateway:qps:{cid}:{group}:high_waiting` | String | 正在等待名额的高优先级请求数 | +| `gateway:qps:global:{group}` | ZSET | 可选的全局组窗口 | + +去重与串行原则(处理中标记): + +- 卡**始终留在**延时队列中(priority 与 queue 二者之一,由优先标记决定),不因领取而移出。 +- 领取时写处理中标记 `SET claim token NX PX 租约`,写成功才算领取到,同时把该卡在队列中的 score 推后到标记到期时间,避免重复扫描。 +- 处理结束(无论成功或失败)由收尾 Lua 校验 token、删除处理中标记,并把 score 改为下次执行时间:成功按检查间隔,失败按退避间隔(见 6.2 节)。失败不会丢卡,而是带着重试时间留在队列里。 +- 处理中崩溃:处理中标记 TTL 到期自动消失,score 已是到期时间,下一轮自然被重新领取,不需要额外的租约收回动作。 +- `running` 集合只用于计数(执行上限与监控),其过期成员在每次领取前清理,不承担恢复职责。 +- 停复机指令、设备判断使用各自的处理中标记,与卡任务互不阻塞。 +- 停复机指令领取时记下指令版本号 `ver`,随消息发布;收尾时版本号未变才移除指令,变了说明执行期间有新指令覆盖,保留指令并把 score 设为 now(见第 6 节)。 + +内存估算:每张参与轮询的卡新增 `due` Hash(约 10 个字段),停复机指令、处理中标记、`sr_last_call` 只在有指令或执行中时存在。上线前按生产参与轮询的卡量估算 `due` 总内存(含现有卡缓存),并确认 Redis `maxmemory` 与淘汰策略不会淘汰轮询 key(轮询 key 不得被 LRU 淘汰)。 + +### 3. 分发与执行(Redis + NSQ,不经过 Asynq) + +卡轮询(卡任务、停复机指令、设备维度判断)不再经过 Asynq,执行分发改用 NSQ。NSQ 只替换卡轮询这一块;其他业务(Outbox `outbox:deliver`、导出、佣金、自动续费、通道阈值、套餐排队激活等)继续使用 Asynq,不受影响。原调度器每 10 秒执行的套餐激活检查与流量重置调度(`scheduler.go` 的 `processActivationTasks`)不属于卡轮询,保持现有逻辑,随调度器改造迁入分发器所在进程的定时动作,行为不变。 + +分工原则:**Redis 是唯一权威状态,NSQ 只是传输**。NSQ 的延迟消息上限受 `max-req-timeout` 限制(默认 1 小时),不支持去重、不保证顺序、没有全局限流,因此长延迟调度、同卡去重与串行、执行上限、QPS 限流、设备锁都留在 Redis;NSQ 承担执行分发、消费者并发控制与执行超时重投。 + +#### 3.1 NSQ topic 与消费者 + +| topic | 内容 | 消费者 | +|---|---|---| +| `polling.stop_resume.{cid}` | 停复机指令 | 专用消费者,与卡任务分开,保证停复机不排在卡任务后面 | +| `polling.card.{cid}` | 卡任务 | 普通消费者 | +| `polling.device_eval` | 设备维度判断 | 设备判断消费者 | + +- 每个运营商账户独立 topic,channel 统一为 `worker`;慢账户只积压在自己的 topic,不影响其他账户。 +- 消息体:卡 ID(或设备 ID)、运营商账户、派发 token、派发时间。 +- 每个 Worker 进程按配置订阅 topic,`max_in_flight` 控制该进程对该账户的并发:`polling.card.{cid}` 在 `scheduler_mode = carrier` 时订阅全部账户;`polling.stop_resume.{cid}` 与 `polling.device_eval` 在 `stop_resume_queue_enabled` 开启后订阅全部账户(见第 9 节)。运营商账户新增、停用或配置变更时随配置刷新动态订阅、取消订阅。 +- NSQ 消息超时(`msg_timeout`)大于对应任务的执行超时;长任务执行中用 `Touch` 延长。 +- 部署:nsqd + nsqlookupd,消费者经 nsqlookupd 发现 nsqd;topic 由分发器首次发布时自动创建。 + +#### 3.2 分发器(每秒,Worker 进程内,多实例安全) + +领取范围:卡任务在 `scheduler_mode = carrier` 时领取全部账户;停复机指令与设备判断在 `stop_resume_queue_enabled` 开启后领取全部账户(见第 9 节)。每个账户每秒只由一个分发器实例领取:领取前抢账户分发锁 `polling:carrier:{cid}:dispatch_lock`(`SET NX PX`,TTL 略小于分发间隔,值为实例 token),抢到的实例执行本轮领取,其余实例跳过该账户。避免多个 Worker 实例按同一个"本秒剩余 QPS"各领一遍、领到实例数倍。各账户的锁相互独立,多个实例可以分担不同账户。 + +抢到锁后执行一次 Lua 原子领取(停复机指令与卡任务分属两个开关,未开启的部分跳过): + +1. 停复机指令:先清理 `sr_running` 中过期成员;领取数 = min(停复机所在限流组本秒剩余 QPS, `sr_inflight_limit − ZCARD(sr_running)`);取 `stop_resume` 中 score ≤ now 的卡,先校验单卡最小重试间隔:`sr_last_call + 单卡最小重试间隔 > now` 的不领取,把 score 推后到可调用时间;其余逐个 `SET sr_claim token NX PX 租约`,成功者推后 score、写入 `sr_running`,并记下指令当前版本号 `ver` 随消息发布。执行器实际调用上游前写 `sr_last_call = now`。 +2. 卡任务:先清理 `running` 中过期成员;领取上限 = `inflight_limit − ZCARD(running)`;先 priority 后 queue,priority 领取数不超过本轮领取上限 × `priority_max_ratio`(为空表示不限),剩余名额给 queue,queue 不足时 priority 可补满,避免读入口与批量业务事件长时间占满账户 QPS、饿死普通轮询;只取已到期的卡。按卡累计预计调用数:读该卡 `due`,统计本次已到期且需调网关的检查类型(卡状态、流量、实名;未开实名管控的账户不计实名),计入各类型所属限流组;任一限流组累计达到该组本秒剩余 QPS,或领取数达到领取上限,即停止领取。逐个 `SET claim token NX PX 租约`,成功者推后 score、写入 `running`。领取不占用限流名额,执行时仍由网关限流器把关;按预计调用数领取只是避免超领后任务在限流器中空等。 +3. 设备判断:不分账户,由抢到 `polling:device_eval:dispatch_lock` 的实例领取;取 `device_eval:queue` 中 score ≤ now 的设备,逐个 `SET eval_claim token NX PX 租约`,成功者推后 score。 +4. 写标记失败(已有处理中标记,说明正在处理):跳过,并把 score 推后到该标记剩余 TTL 之后。 +5. 领取结果发布到对应 topic;发布失败用独立短超时 ctx 按 token 删除处理中标记并把 score 恢复为 now;恢复也失败则等处理中标记 TTL 到期后自然被重新领取。 + +执行上限是全局的:领取时校验 `ZCARD(running) < inflight_limit`(停复机为 `ZCARD(sr_running) < sr_inflight_limit`),无论部署几个 Worker 实例,同一账户同时执行的卡任务数都不超过配置值。 + +执行上限与消费能力的约束:执行上限不得大于该账户所有订阅进程的 `max_in_flight` 之和,否则领出的消息在 NSQ 中排队,排队时间超过租约后处理中标记到期、卡被重新领取,NSQ 中堆积过期重复消息。卡任务为 `inflight_limit ≤ Σ card_max_in_flight`,停复机为 `sr_inflight_limit ≤ Σ stop_resume_max_in_flight`。保存配置时按当前订阅进程数校验并提示;运行中由分发器每轮比较,不满足时按 Σ `max_in_flight` 收紧本轮领取上限并上报监控告警。 + +#### 3.3 消费与幂等 + +1. 消费者收到消息后,先核对 Redis:对应处理中标记存在且值等于消息中的派发 token,才执行;否则说明是过期或重复投递(标记已到期并被重新派发),直接 `FIN` 丢弃。 +2. 执行(卡任务见第 5 节,停复机指令见第 6 节,设备判断见第 6.1 节)。 + - 租约续期:执行期间每隔"租约时长 / 3"(系统参数,见 7.5 节)执行一次续期 Lua:校验处理中标记的值仍为本 token,是则 `PEXPIRE` 延长租约,并把该卡在队列中的 score、`running` / `sr_running` 中的到期时间同步推后;同时 `Touch` NSQ 消息。续期发现 token 已不一致(标记已到期并被重新派发)时,立即取消后续步骤(尚未发起的网关调用不再发起),按第 4 条只写已产生的业务结果。卡任务串行多个步骤、每步都可能在限流器中等待到单步超时,总时长可能超过单个租约,必须续期才能保证同卡串行。 + - 执行总时长上限:卡任务总超时 = 各步骤超时之和 + 余量(系统参数),到达后取消剩余步骤,按失败路径收尾;续期最多延长到总超时,进程卡死时租约仍会到期。 +3. 执行结果一律先写回 Redis,再 `FIN`。收尾 Lua:校验 token → 删除处理中标记 → 从 `running` / `sr_running` 移除 → 写回 `due` 与失败计数 → 按下次执行时间(成功为检查间隔,失败为退避间隔)设置 score。停复机指令收尾另比较指令版本号:与领取时记下的 `ver` 相同,成功或结束时才移除指令;不同说明执行期间有新指令覆盖(如停机执行中客户付费产生复机指令),保留新指令、失败计数按新指令重新计算,并把 score 设为 max(now, 本卡 `sr_last_call` + 单卡最小重试间隔)。重试与退避由 Redis 的 score 表达,不使用 NSQ 的 `REQ` 做业务重试。 +4. 收尾时 token 不一致(标记已到期且已被重新派发):只写入本次执行产生的业务结果,不改 score、不动处理中标记。 +5. 消费者崩溃或执行超时未 `FIN`:NSQ 在 `msg_timeout` 后重投,处理中标记仍有效则重新执行;处理中标记 TTL(租约)大于 `msg_timeout` × 允许的重投次数,NSQ 消息丢失(如 nsqd 异常)时由标记到期兜底。NSQ 重投时原消费者可能仍在执行(如进程假死后恢复),新消费者核对 token 一致也会执行;为此消费者执行前先 `SET polling:card:{id}:exec token NX PX 续期间隔×2` 抢执行权,抢不到说明同一 token 已有执行者,直接 `FIN`,执行权随续期一起延长。 + +#### 3.4 其他定时动作(每秒,Worker 进程内) + +1. 消费手动触发队列与优先提示(见第 8 节)。 +2. 统计各账户 `running` / `sr_running` 中过期成员数(处理中崩溃或超时的次数)并上报监控。 +3. 写分发器心跳。 + +#### 3.5 进程生命周期 + +- 每个任务有执行超时与 panic 恢复;panic 视同失败,按失败路径写回。 +- 优雅退出:先停止分发与订阅,等待执行中的消息在宽限期内完成并 `FIN`;未完成的由 NSQ 重投或处理中标记到期后重新领取。 + +### 4. 网关 QPS 限流 + +- 生效范围:所有自动触发的检查与停复机(轮询、业务事件与读入口请求检查、激活前流量结算、各自动入口生成的停复机指令)都受运营商账户限流约束;只有后台人工点击的停复机、通道阈值、自动续费、手动刷新不限流。实现上**默认限流、显式豁免**:gateway Client 外层限流器对卡的 5 个操作一律限流,只有上述 4 个入口在 ctx 中打"豁免限流"标记(带豁免来源,便于监控统计豁免调用量)。这样以后新增的自动入口忘记处理时仍受限流,不会出现漏网的不限流调用。限流组按**目标卡**的 carrier_id + 操作类型查找(设备停复机拆分后每张卡各自查找)。 +- 计量单位:按**每次实际发出的 HTTP 请求**扣额度,而不是按方法调用。gateway Client 一次方法调用内部最多重试 `maxRetries` 次(默认 2,共 3 次请求,仅对超时、连接失败等网络错误重试,`internal/gateway/client.go:141`),若只在方法入口扣一次,重试的请求会漏计。限流器挂在现有逐次尝试钩子 `AttemptObserver.BeforeAttempt` 上(每次 HTTP 尝试前调用),每次尝试各自取名额;拿不到名额时按所在模式等待或立即返回,立即返回时本次方法调用结束,不再发起后续尝试。 +- 算法:滑动窗口。Lua 删除 1 秒前的记录后判断 `ZCARD < max_qps`,满足则记录并放行,否则返回需等待的毫秒数。 +- 等待:按返回毫秒数等待后重试,总等待不超过所在步骤的超时;超时返回"限流等待超时"错误。在 Asynq handler 内调用时使用立即返回模式,不等待(见第五部分第 3 节第 1 步说明)。 +- 高优先级操作(由限流组配置,默认停机、复机),仅在与其他操作同组时生效: + - 普通操作最多用到 `max_qps − reserved_qps`,高优先级操作可用满。 + - 高优先级请求等待期间登记 `high_waiting`,大于 0 时普通操作拿不到新名额。 +- 全局组:若配置了全局组,每次调用同时检查运营商账户组与全局组,两者都满足才放行。 +- 分发器领取时读取的"本秒剩余 QPS"即同一窗口,口径一致。 +- 多 Worker 共用 Redis,限流全局生效。 +- 未被任何启用的限流组覆盖的操作(含所在组已停用)使用默认 QPS,不存在不限流的自动调用。 + +### 5. 卡任务 + +1. 读卡缓存和 `due`,找出已到期的检查类型。 +2. 按配置的步骤顺序串行执行(默认 卡状态 → 流量 → 实名 → 套餐,见 7.3)。原 4 个 handler 的核心逻辑拆成步骤函数: + + ```go + type StepInput struct { + Card *CardSnapshot + Result *GatewayResult // 可选:外部已取得的查询结果,非空时跳过网关查询 + } + step(ctx, input) (nextDue time.Time, err error) + ``` + + - `Result` 为后期批量查询、推送回调预留:查询与"观测入账 + 规则判断"分离,有外部结果时直接复用后半段。 + - 每步单独超时;某一步失败只影响本步,按 6.2 节记录失败并计算退避后的 nextDue,后续步骤照常执行。 + - 限流等待超时:本步 nextDue 设为短延迟(可配置),不计失败。 + - 套餐步骤:本轮流量步骤已触发评估时可跳过(可配置)。 +3. 对应步骤沿用现有优先轮询认领与收尾逻辑(`internal/task/polling_priority_claim.go`)。 +4. 收尾 Lua(见 3.3):写回各类型 `due` 与失败计数,删除处理中标记,按最早 nextDue 设置该卡在 queue 中的 score;有优先标记则改放 priority。 +5. 保留卡流量同步锁,用于与手动刷新互斥。 +6. 检查间隔沿用轮询配置(`MergedTaskIntervals`,按卡匹配,含第 11 节分层条件)。 + +### 5.1 业务事件触发检查(替代卡观测序列) + +业务事件不再起观测序列,改为把卡的对应检查设为立即到期并放进优先队列,统一走卡任务。业务事件、轮询、优先轮询为同一入口,不会重复查。 + +1. 请求检查:业务事件提交后调用"请求检查(卡、检查类型、场景)",一次 Lua:把 `due[type]` 设为 now,卡放入所属运营商账户的 `priority`;卡正在执行时打优先标记(同第 8 节优先轮询)。同一张卡多次请求只合并为一次,不新增队列成员。 + - 冷却:场景配置了冷却时长(`cooldown`,见 7.6 节)时,该检查类型最近一次成功检查距今未超过冷却时长则不请求(Lua 内读 `due` 中的最近成功时间判断)。读入口场景应配置冷却,避免 C 端用户反复刷新页面持续占用优先队列;订单支付、套餐激活等业务事件场景通常不配冷却。 +2. 执行:分发器按"停复机 > 优先 > 普通"领取,经运营商账户限流与执行上限,按卡任务步骤串行执行,失败按 6.2 节退避。 +3. 延迟确认:需要"过几分钟再确认一次"的场景,按配置在本次检查完成后追加一次延迟检查:该类型下次到期 = min(正常间隔, 当前 + 确认延迟)。确认延迟按场景配置(可为空、一次或多次);观测结果已满足预期(如停机后已是停机状态)时取消后续确认。 +4. 场景配置(后台,全局): + +| 字段 | 说明 | +|---|---| +| scene | 业务场景:订单支付、套餐激活、停复机成功、设备操作、自动续费购包等 | +| check_types | 需要检查的类型(状态 / 流量 / 实名) | +| confirm_delays | 延迟确认序列,如"3 分钟";为空表示只立即检查一次 | +| cooldown | 冷却时长:最近一次成功检查未超过该时长则不请求;为空表示不冷却 | + +5. 业务事件入口(第一部分 5.1 节列出的 5 处)改为请求检查;设备操作按设备下各卡分别请求检查。 +6. 读入口(C 端资产详情、后台资产实时状态、实名链接)同样改为请求检查;OpenAPI 查询保持不触发任何检查,不再起观测序列;页面照常展示数据库中的数据。同一张卡反复打开页面只合并为一次检查,冷却时长内不再请求,受运营商账户限流约束。读入口场景默认不配置延迟确认,应配置冷却。 +7. 设备信息同步(`query_device_info`,非运营商上游)不在本节范围,保持现状。 +8. 效果:批量下单 27 笔、约 70 张卡,只产生约 70 次立即检查(加按配置的确认检查),并按各运营商账户 QPS 平滑执行,不再形成单秒波峰;与优先轮询不再重复。 + +### 6. 停复机指令 + +生成: +- 统一判断函数:输入卡与资产当前数据,输出目标状态与停机原因或"不动作",按第 12 节口径(暂停轮询 → 停机条件 → 复机条件 → 通道阈值锁)。`EvaluateAndAct`、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 都改为调用它,不再各自判断。 +- `EvaluateAndAct` 改为只判断、不执行:判断出需要停机或复机即生成指令。 +- 调用方(只请求评估,不调网关): + - 套餐步骤、Outbox 三个卡观测消费者(流量增加、网络状态变化、实名变化)。Outbox 消费者只负责扣减与请求评估,不再同步调网关、不 sleep 重试;"流量用完停机"由此受运营商账户限流约束(2026-10-22 已定)。 + - 套餐激活、流量重置、订单支付后:去掉裸 goroutine,在业务事务提交后请求评估(`ResumeCardIfStopped` 改为请求评估)。请求本身只写 Redis,失败时记录日志并由该卡下一次套餐步骤兜底。 + - 套餐到期(第 11.5 节到期即停)。 + - 退款后资产处理:不再调用 `ManualStopCard`,改为请求评估;资产无可用主套餐时按 `no_package` 生成停机指令(设备按设备维度判断)。请求评估写入成功即视为退款资产后处理完成(标记 `asset_reset`),停机本身的失败由停复机指令的退避重试与告警处理,不再触发 Outbox 重投与补偿扫描反复补发。 + - 运营商账户实名管控修改(`internal/service/carrier/service.go`):保存后对该账户下受影响的卡批量请求评估,按账户 QPS 平滑执行。0 → 1 筛出在线且未实名的卡;1 → 0 筛出停机且未实名的卡。设备卡按设备请求设备维度判断。 + - 卡或设备恢复轮询:不另加触发。生命周期服务恢复轮询时(现有 `OnCardEnabled`,`internal/polling/lifecycle_service.go:116`)把各检查类型 `due` 设为当前时间加少量抖动,套餐步骤随即执行评估。 +- 单卡维度:在调用方当场判断,生成该卡的指令。 +- 设备维度:由卡找到绑定设备,不在调用方当场判断,改为请求一次设备维度判断(见第 6.1 节),由设备判断任务统一生成设备下每张卡的指令,每条进入该卡自身所属运营商账户的队列。 +- 同卡同一时刻只保留一条指令,新指令覆盖旧指令;方向相反时以最新判断为准,失败计数清零。每次生成或覆盖指令都递增指令版本号 `ver`;指令正在执行时也可以覆盖,执行完的收尾按版本号判断是否移除(见 3.3 节第 3 条),保证执行期间生成的新指令不会被收尾删掉。 + +执行(NSQ 消费者收到 `polling.stop_resume.{cid}` 消息后执行): +1. 执行前按规则重新判断,已是目标状态或条件不再满足则结束,指令从 `stop_resume` 移除。 +2. 调网关只调用一次,不在执行中 sleep 重试,也不使用 gateway Client 的内部重试:停复机是外部状态写入,超时或连接失败时上游可能已经受理,内部重试会在几百毫秒内对同一张卡连发 2~3 次,正好撞上上游单卡频率限制("3 分钟内同一张卡相同操作仅允许一次""卡状态变更每十分钟只允许调用 1 次")。现状 `StopCard` / `StartCard`(`internal/gateway/flow_card.go:30-40`)沿用默认 `maxRetries = 2`,改为与限速接口 `SetCardSpeedTier` 相同的 `maxRetries = 0`;该改动对所有停复机入口生效(含后台人工点击、通道阈值、自动续费),人工入口失败由用户重新操作。超时、连接失败按"结果未知"处理:置待确认标记,下次执行前先查卡状态,已是目标状态则结束指令,否则按单卡最小重试间隔再调用。 +3. 成功:沿用现有回写(卡状态、停机原因、网络状态序列、审计);指令版本号与领取时一致才从 `stop_resume` 移除,否则保留执行期间生成的新指令(见 3.3 节第 3 条)。 +4. 失败:按 6.2 节分类处理。可重试的失败指令保留在 `stop_resume`,score = max(当前时间 + 退避间隔, 本卡上次调用停机或复机的时间 + 单卡最小重试间隔);达到告警阈值后写失败告警,指令**不丢弃**,按最长退避间隔继续重试(停机失败意味着卡可能仍在跑流量)。上游终态不可操作的不再重试(见 6.2 节)。 +5. 结果未知(超时、连接失败、上游返回已受理类频率限制):不沿用现有恢复扫描。现有普通停复机的结果未知只有人工核对策略(`pkg/constants/card_observation.go:135` `GatewayCardCommandUnknownRecoveryStrategy`),通道阈值、自动续费各有专项恢复,都不能直接复用。指令队列内的完整路径: + 1. 置待确认标记,记下本次调用时间;指令保留在 `stop_resume`,score = now + 确认延迟(按运营商账户配置 `stop_resume_confirm_delay`,见 7.2 节)。 + 2. 再次领取时,先查一次卡状态(查状态受限流,计入停复机所在限流组;不受单卡最小重试间隔约束,因为查询不改变上游状态): + - 已是目标状态:按成功回写,结束指令,记录结果为"确认成功"。 + - 不是目标状态:清除待确认标记;距本卡 `sr_last_call` 已满单卡最小重试间隔则本次直接重新调用停复机,否则 score 推后到可调用时间。 + - 查状态失败:保持待确认,按查询退避间隔重试,不发起停复机调用。 + 3. 连续结果未知(含确认查询失败)次数达到告警阈值,按 6.2 节写失败告警,错误类型为"结果未知",指令不丢弃。 + 4. 执行前的规则重新判断仍在查状态之前进行:条件已不满足(如期间客户付费不需要停机)则结束指令,不再确认。 + +单卡最小重试间隔:上游按单卡限制停复机频率(生产 2026-10-22 实测返回"3 分钟内同一张卡相同操作仅允许一次""5 分钟内最多重复调用 3 次""卡状态变更每十分钟只允许调用 1 次")。停复机指令对同一张卡的两次停复机网关调用(停机与复机合计,不分操作)间隔不得小于"单卡最小重试间隔"(按运营商账户配置,见 7.2 节);按卡合计是因为上游"卡状态变更每十分钟只允许调用 1 次"对停机、复机共同计数,例如停机后几分钟内购买套餐触发复机也会被拦截,领取时校验,未到间隔则把 score 推后到可调用时间,不发起调用。 + +不变的部分:后台人工点击的停复机(用户在等结果)、通道阈值(有独立可靠任务与锁)、自动续费调用的停机、复机接口照旧直接执行,不进队列、不限流。 + +好处: +- 不风暴:执行前按目标状态重新判断,已停机的卡不再调停机接口;业务流程(如退款)只负责请求评估,不再因上游停机失败被整体重试。 +- 不翻转:同卡只有一条指令、新覆盖旧,执行前重新判断;激活复机与流量入账停机同时发生时以最新数据为准。 +- 不丢:激活、重置、订单后的复机失败退避重试,持续失败进告警,不再只打日志。 +- 不超频:流量重置日集中复机按运营商账户 QPS 平滑执行;停复机预留 QPS、优先出队。 +- 规则只有一处:所有自动入口共用同一判断函数。 +- 代价:停复机比现在晚约 1~2 秒(每秒领取、优先出队)。 + +#### 6.1 设备维度判断(Redis 延时队列 + 锁 + NSQ 分发) + +问题:设备下多张卡(通常分属不同运营商账户)的卡任务、Outbox 消费者可能同时做设备维度判断。例如卡 A 流量入账后设备流量用完、判断整机停机,同时卡 B 读到扣减前的数据判断可以复机,后者覆盖前者,导致停复机翻转或设备下卡状态不一致。设备套餐的流量扣减已在数据库层加行锁(`internal/service/package/usage_service.go:345`),冲突只在设备维度的停复机判断。 + +设计:Redis 延时队列负责排队、去重与合并,处理中标记保证同一设备同一时刻只有一个判断,设备锁负责与停复机指令执行器的重新判断互斥。 + +1. 请求判断:卡任务(套餐步骤)、Outbox 三个卡观测消费者、套餐到期、退款等需要做设备维度判断时,不当场判断,而是一次 Lua:`INCR eval_ver`,把本次请求的场景 `SADD` 进 `polling:device:{id}:eval_scenes`,并 `ZADD LT polling:device_eval:queue now {device_id}`(不存在则加入,存在则只会把 score 提前)。同一设备只有一个队列成员,多次请求自然合并,场景按并集合并、不丢失。 + - 场景合并的意义:暂停轮询的设备只对套餐失效场景(到期、退款)放行停机(第 12 节)。若只按设备 ID 合并、不保存场景,"到期"请求与普通"轮询"请求合并后场景丢失,判断时会按暂停轮询拒绝停机,造成漏停。 + - 领取时随消息带上当时的场景集合;收尾 Lua 只从 `eval_scenes` 中移除本次已处理的场景(`SREM`),判断期间新加入的场景保留,与 `eval_ver` 一起触发再判断一次。 +2. 领取与分发:分发器取 score ≤ now 的设备,`SET eval_claim token NX PX 租约` 成功者推后 score,并记下领取时的 `eval_ver`,随消息发布到 NSQ `polling.device_eval`。同一设备同一时刻只会被一个消费者判断。 +3. 执行判断: + 1. 抢 `polling:device:{id}:eval_lock`(SetNX + token + TTL)。 + 2. 读取设备最新数据(设备套餐、流量、设备下各卡的网络状态与实名状态、各卡所属运营商账户的实名管控),按第 12 节口径判断;实名按卡逐张判断。暂停轮询这一步按场景集合判断:集合中含套餐失效场景(到期、退款)时,停机方向放行。 + 3. 需要停复机时,为设备下每张卡写停复机指令(指令记录触发它的场景,供执行前重新判断沿用),进入各卡所属运营商账户的停复机队列。 + 4. 按 token 释放锁;收尾 Lua 删除处理中标记,并比较当前 `eval_ver` 与领取时记下的版本:相同则把设备从 `device_eval:queue` 移除;更大说明判断期间有新请求,保留并把 score 设为 now,用最新数据再判断一次。 +4. 约束: + - 锁内不调网关,真正的停机、复机由停复机指令执行,锁持有时间为毫秒级。 + - 多次判断请求合并为"基于最新数据的最后一次判断",不丢失、不交错。 + - 消费者崩溃时,锁 TTL 到期释放;NSQ 超时重投,或处理中标记到期后设备被重新领取判断。 +5. 停复机指令执行器执行设备下某张卡的指令前重新判断时,涉及设备维度条件的同样经由此锁读取,读到不再满足则结束。 +6. 设备下不同卡的停复机指令可以并行执行,各自独立调用上游。 + +#### 6.2 失败处理与告警(卡任务各步骤、停复机指令通用) + +0. 失败原因与调用量记录(失败分类与告警的前提) + - gateway Client 返回结构化错误:错误类别(网络 / 超时 / HTTP 异常 / 响应无效 / 上游业务错误)、网关业务码与信息、耗时;不再只拼在错误字符串里。 + - 现状:上游返回的错误信息目前只在统一审计表 `tb_audit_event` 中停复机动作(`action_code` 为 `iot_card.auto_stop`、`iot_card.auto_start`、`iot_card.manual_stop` 等)的 `error_summary` 中可见(如 `Gateway 业务错误: code=400, msg=...`),查询类调用不写审计,集成日志中为空;网关对各类错误统一返回 code=400,分类需按返回信息匹配。统一审计表与集成日志(生产约 1,500 万行)数据量大、积压严重,不再承载轮询与自动停复机的调用记录。 + - 停复机:每次实际调用上游停机或复机写入单独的停复机记录表 `tb_card_stop_resume_record`(见下方),系统自动停复机不再写统一审计与集成日志;后台人工点击的停复机仍写统一审计(操作人审计职责),同时写停复机记录。 + - 查询(状态、流量、实名):失败不逐条落库,不单独建表。按"运营商账户 × 操作 × 结果类别"在 Redis 做分钟级计数与耗时分布,供监控计算成功率、实际 QPS、p95 耗时;每张卡在 `due` 中保留各检查类型最近一次错误(类别、码、信息、时间);持续失败达到阈值的写入失败告警表 `tb_polling_failure_alert`(第 3 条)。 + - 分类规则(错误信息关键字 → 类别)作为配置维护,可后台增补,不写死在代码中。 + - 用途:区分限频、卡错误与超时,作为 6.2 失败分类、告警与各运营商账户 QPS 配置调整的依据。 + + 停复机记录表 `tb_card_stop_resume_record`: + + | 字段 | 说明 | + |---|---| + | card_id、iccid、carrier_id | 卡与所属运营商账户 | + | action | 停机 / 复机 | + | source、scene | 来源与场景:轮询、业务事件、退款、激活、重置、订单、实名管控修改、后台人工点击、通道阈值等 | + | reason | 停机原因 | + | command_id | 对应停复机指令,同一指令的多次尝试归为一组 | + | attempt | 尝试序号 | + | result | 成功 / 失败 / 结果未知 / 已是目标状态 / 跳过(未到单卡最小重试间隔) | + | error_category | 频率限制 / 终态不可操作 / 临时故障 / 业务错误 / 网络超时 | + | provider_code、provider_message | 上游返回码与信息(脱敏截断) | + | duration_ms | 耗时 | + | created_at | 记录时间 | + + - 索引:(card_id, created_at)、(carrier_id, created_at)、(result, created_at),支持单卡历史、按账户统计失败类别、告警页跳转查看。 + - 按月分区,保留期可配置,到期整区删除。主键包含 `created_at`;建 DEFAULT 分区,并由定时任务提前建好未来若干个月的分区(提前月数为系统参数,见 7.5 节),避免某月分区缺失导致插入失败。 + - 记录写入与停复机状态回写不在同一事务:写记录失败只打日志并计数,不影响卡状态、停机原因等回写。 + +1. 失败重回队列、退避重试 + - 按"卡 × 检查类型"(停复机指令按卡)记录连续失败次数、首次失败时间、最近错误。 + - 失败后带着重试时间留在队列:下次执行时间 = 当前时间 + 退避间隔,退避间隔随连续失败次数逐级拉长(序列可配置),最长不超过该检查类型的正常检查间隔。 + - 成功一次即清零。 + +2. 失败分类 + +| 类型 | 例子 | 是否计入连续失败 | +|---|---|---| +| 限流等待超时 | 本账户 QPS 用满 | 不计入:属于自身限流,不是卡的问题 | +| 网络或超时 | 上游超时、连接失败 | 计入 | +| 上游业务错误 | 卡不存在、卡不属于该账户、账户无权限、"当前用户不允许办理""防范实名" | 计入;可配置为首次出现即告警;不视为已是目标状态 | +| 账户配置错误 | "密码错误""参数缺失" | 计入;首次出现即产生账户级告警(通常是账户凭证或接入参数问题,影响该账户全部卡),不视为已是目标状态 | +| 上游频率限制 | "操作进行中,3 分钟内同一张卡相同操作仅允许一次""调用成功-5 分钟内最多重复调用 3 次""调用成功-卡状态变更每十分钟只允许调用 1 次""请求被合并,以最新一次为准""超频访问" | 不计入:按单卡最小重试间隔与上游窗口退避;返回含"调用成功""操作进行中""请求被合并"的,上游可能已受理,下次执行前先查卡状态确认,已是目标状态则结束指令 | +| 上游终态不可操作 | "当前用户已销户,无法操作""当前卡为风险停机状态,仅支持风险复机""用户状态是物联网号码封存,禁止办理物联网停机" | 不重试:结束指令,写失败告警(错误类型为终态);已销户、风险停机类自动关闭该卡轮询(第 12 节),其余终态建议运营关闭该卡轮询转人工;停机方向且上游已不可用的,按目标状态视为已停 | +| 上游临时故障 | "湖北省网获取到的 token 为空""请求失败" | 计入,按普通退避重试 | +| 上游表示已是目标状态 | 停机时明确返回已停机、复机时明确返回已开机 | 不计入:视为成功,结束停复机指令并按目标状态回写;只收录明确表示卡已处于目标状态的返回,具体错误码按失败原因落库后的统计整理,作为配置维护。不能以老系统的"结束停机任务"作为依据:老系统 `TingjiViewComp.java:255-271` 把"已存在""当前用户不允许办理""防范实名""参数缺失""密码错误"都置为 `tingjiOk` 并结束任务,但停复机记录 `status` 仍记为失败(2);电信卡只要停机或断网(达量)任一成功即结束任务(第 308 行)。这些只代表老系统不再重试,不代表停机成功 | + +3. 告警 + - 触发条件(可配置):连续失败次数达到阈值,或持续失败时长达到阈值,或命中"首次即告警"的错误。 + - 写入失败告警记录(新表 `tb_polling_failure_alert`):卡、运营商账户、检查类型(或停复机)、连续失败次数、首次与最近失败时间、最近错误、状态(未处理 / 已恢复 / 已关闭轮询)、处理人与处理时间。同一卡同一检查类型只保留一条未关闭的告警,后续失败更新计数。 + - 通知走现有告警通道(`internal/service/polling/alert_service.go`)。 + - 告警后卡继续按最长退避间隔轮询,不自动停止;上游恢复后首次成功即清零,告警自动转为"已恢复"。 + +4. 运营处理 + - 联系上游:无需在系统操作,恢复后自动关闭告警。 + - 加入不轮询:告警页一键或勾选批量关闭该卡(或其设备)的 `enable_polling`(13.1 节),写审计与不轮询清单,告警转为"已关闭轮询";生命周期服务据此将卡移出轮询(`ShouldEnqueue`)。停机被拒告警单独成列表并可导出(13.3 节)。 + - 关闭轮询的影响按第 12 节(沿用老系统不轮询名单口径):不再查上游,流量用完、实名不满足不自动停机,激活、流量重置不自动复机;套餐到期、退款后无套餐仍自动停机;订单套餐生效、后台改有效期后有生效套餐会自动恢复轮询并按规则复机(风险停机、已销户的卡除外);人工停复机照常。关闭设备时设备下所有卡同样适用。告警页与关闭轮询的确认框须明确提示上述影响。 + +5. 账户级汇总 + - 告警列表支持按运营商账户、错误类型汇总。 + - 同一运营商账户在时间窗口内的失败告警数量超过阈值时,额外产生账户级告警:通常是账户本身问题(额度、凭证、上游故障),应联系上游处理账户,而不是逐卡处理。 + +### 7. 配置 + +原则:只提供灵活的配置位置,具体数值与分组由运营按实际情况配置;后台配置修改后同步 Redis,下一轮调度即生效,无需重启。 + +#### 7.1 限流组(后台,按运营商账户) + +新表 `tb_carrier_rate_limit_group`: + +| 字段 | 说明 | +|---|---| +| carrier_id | 运营商账户;为空表示全局组(可选,用于聚合网关总限流,不配则不生效) | +| group_code / group_name | 组标识与名称 | +| max_qps | 该组每秒请求上限 | +| operations | 组内操作:查流量 / 查状态 / 查实名 / 停机 / 复机 | +| high_priority_operations | 组内高优先级操作(默认停机、复机) | +| reserved_qps | 预留给高优先级操作的额度 | +| status | 启用 / 停用;停用后组内操作回落为默认 QPS,不是不限流 | + +示例:联通配两组(查询:查流量/查状态/查实名;停复机:停机/复机);广电配一组(全部 5 个操作,高优先级为停机/复机)。 + +规则:同一运营商账户下每个操作只能属于一个组,保存时校验;`reserved_qps < max_qps`;高优先级操作必须属于本组操作。 + +#### 7.2 运营商账户调度参数(后台,按运营商账户,可选) + +存储:新表 `tb_carrier_polling_param`(`carrier_id` 唯一,一个账户一行,未配置的账户没有记录、用全局默认),不改 `tb_carrier`。 + +| 参数 | 说明 | +|---|---| +| inflight_limit | 该账户同时执行卡任务的上限(全局,跨 Worker 实例,按 `running` 计数),不配用全局默认 | +| card_max_in_flight | 每个 Worker 进程消费该账户 `polling.card.{cid}` 的 `max_in_flight`,不配用全局默认 | +| stop_resume_max_in_flight | 每个 Worker 进程消费该账户 `polling.stop_resume.{cid}` 的 `max_in_flight`,默认 1 | +| sr_inflight_limit | 该账户同时执行停复机指令的上限(全局,按 `sr_running` 计数),不配用全局默认;须不大于各订阅进程 `stop_resume_max_in_flight` 之和(见 3.2 节) | +| priority_max_ratio | 卡任务每轮领取中 priority 的最大占比(0~1),为空表示不限;queue 不足时 priority 可补满(见 3.2 节) | +| stop_resume_min_interval | 单卡最小重试间隔:同一张卡两次停复机网关调用(停机与复机合计)的最小间隔,不小于上游单卡限制(如 10 分钟),不配用全局默认 | +| stop_resume_confirm_delay | 停复机结果未知后,过多久查卡状态确认(见第 6 节第 5 条),不配用全局默认 | + +#### 7.3 卡任务步骤(后台,全局) + +存储:现有受控系统配置表 `tb_system_config`(键值,`module = polling`),不新建表;7.4 的标量参数同样存放于此。 + +| 参数 | 说明 | +|---|---| +| step_order | 步骤执行顺序,默认 卡状态 → 流量 → 实名 → 套餐 | +| step_timeout | 每种检查类型单步超时 | +| rate_limited_retry_delay | 限流等待超时后的短延迟 | +| skip_package_after_flow | 同轮流量已触发评估时是否跳过套餐步骤 | + +#### 7.4 失败处理与告警(后台,全局,见 6.2) + +| 参数 | 说明 | +|---|---| +| backoff | 退避间隔序列,可按检查类型与停复机分别配置;最长不超过该类型正常检查间隔 | +| alert_fail_count | 连续失败次数告警阈值,可按检查类型与停复机分别配置 | +| alert_fail_duration | 持续失败时长告警阈值 | +| immediate_alert_errors | 首次出现即告警的上游错误类型 | +| error_classification | 上游错误信息关键字 → 类别(频率限制 / 终态不可操作 / 已是目标状态 / 临时故障 / 业务错误 / 账户配置错误)的映射,见 6.2 节;条目多、需逐条增补,单独建表 `tb_gateway_error_classification`(关键字、匹配方式、类别、适用操作、是否自动关闭轮询、启用状态;"是否自动关闭轮询"只允许终态类别勾选,用于已销户、风险停机) | +| account_alert_threshold | 账户级告警:时间窗口与窗口内失败告警数量阈值 | + +#### 7.5 系统参数(yaml) + +默认 QPS、默认执行上限(`inflight_limit`、`sr_inflight_limit`)、默认 `max_in_flight`、设备判断 `max_in_flight`、分发间隔、卡任务总超时(各步骤超时之和 + 余量,见 3.3 节)、NSQ 地址(nsqd / nsqlookupd)、NSQ `msg_timeout`(须大于续期间隔,执行中靠 `Touch` 延长)、处理中标记 TTL 即租约时长(须大于 `msg_timeout` × 允许重投次数)、租约续期间隔(默认租约时长 / 3)、QPS 最长等待、优雅退出宽限期、重置日前加密的收尾提前量(11.10 节)、激活前流量结算总超时(11.9 节)、停复机记录表分区提前创建月数(6.2 节)。停复机出口全局开关 `stop_resume_queue_enabled` 不放 yaml,放受控系统配置 `polling.stop_resume_queue_enabled`(默认关闭),开关与回退即时生效、无需重新部署(第 9 节、14.1 节)。 + +#### 7.6 业务事件检查场景(后台,全局,见 5.1 节) + +存储:新表 `tb_polling_check_scene`(`scene` 唯一,一个场景一行)。 + +| 参数 | 说明 | +|---|---| +| scene | 业务场景:订单支付、套餐激活、停复机成功、设备操作、自动续费购包、各读入口等 | +| check_types | 该场景需要检查的类型(状态 / 流量 / 实名) | +| confirm_delays | 延迟确认序列,为空表示只立即检查一次;读入口默认为空 | +| cooldown | 冷却时长,为空表示不冷却;读入口应配置(见 5.1 节) | + +### 8. 需要同步适配 + +| 模块 | 改动 | +|---|---| +| 优先轮询 | 数据库为准、Redis 为提示不变;下发提示改为设置 `due[type]=now` 并放入运营商账户 priority,卡在执行中则打优先标记 | +| 手动触发队列 | 接口不变;由第 3.4 节的定时动作取出后设置 `due[type]=now` 并放入运营商账户 queue(score=now),走普通通道 | +| Initializer | 按各类型 last_check_at 计算 `due`,以最早时间写入运营商账户 queue | +| 生命周期 | 增删卡、卡状态变化时操作该卡所属运营商账户的各队列与 `due`;恢复轮询时各检查类型 `due` 设为当前时间加抖动(沿用 `calcInitialDelay`) | +| 监控 | 按运营商账户展示各队列长度、到期数、执行中数、实际 QPS、限流等待次数与超时次数、处理中标记到期次数、停复机成功/失败/重试、失败告警数(按错误类型)。数据来源:队列与执行数读 Redis;查询成功率、QPS、耗时读 Redis 分钟计数;停复机结果读停复机记录表 `tb_card_stop_resume_record`;持续失败读失败告警表 `tb_polling_failure_alert` | +| 后台 | 并发配置页改为限流组配置页;新增运营商账户调度参数、步骤与失败告警参数配置;新增失败告警页(按账户与错误类型汇总、一键关闭轮询,停机被拒视图与导出);新增不轮询清单页、批量开关轮询与文件导入(第 13 节);卡、设备列表增加是否轮询筛选;资产页去掉保护期展示 | +| 开关轮询入口 | 单资产接口、风险停机或已销户自动关闭、设备级联、订单套餐生效与改有效期的自动恢复,同一事务写不轮询清单 `tb_card_polling_pause`(13.2 节) | + +### 9. 切换(不灰度,整体切换) + +原则(2026-10-24 已定):不按运营商账户、设备或卡灰度。每个 change 上线时对全部运营商账户整体生效,先在测试环境验证,再上生产。只保留全局开关,用于出问题时整体回退,不用于逐步放量。 + +| 全局开关(受控系统配置,后台即时生效,见 14.1 节) | 所属 change | 开启 | 关闭(回退) | +|---|---|---|---| +| `polling.rate_limit_mode`(`observe` / `enforce`) | 1 | 拦截:按限流组执行 | 只统计:计数但不拦截 | +| `polling.scheduler_mode`(`legacy` / `carrier`) | 5 | 全部卡任务走新调度(按账户队列 + NSQ) | 全部回到旧分片调度 | +| `polling.stop_resume_queue_enabled`(bool) | 6 | 全部自动停复机经指令队列与设备判断 | 各自动入口恢复当场执行 | + +- 新 key 前缀,与旧 key 不冲突;新旧调度不双跑:任一时刻只有 `scheduler_mode` 指定的一套调度在领取与执行。 +- 入口写入:所有写入轮询结构的入口(Initializer、生命周期、优先提示、手动触发、业务事件与读入口请求检查)按 `scheduler_mode` 写新结构或旧结构,不再按账户分流。 +- 切到新调度(`legacy` → `carrier`): + 1. 旧调度器停止出队;旧 handler 执行前检查模式,已切换则直接结束、不重入旧队列(处理 Asynq 中已排队的任务)。 + 2. 从数据库按批初始化全部账户的新结构(`due` 与各账户 queue);初始化期间新分发器不领取,初始化完成后开始领取。初始化按 98,430 张卡规模估算耗时,在测试环境实测后写入上线步骤。 + 3. 初始化后各卡到期时间按 last_check_at 计算,已逾期的卡按账户 QPS 与执行上限逐步领取,不形成波峰。 +- 回退(`carrier` → `legacy`):新分发器停止领取,执行中的任务按收尾规则完成;按数据库重新初始化旧分片队列,旧调度恢复出队;新结构保留不删,再次切换时重新初始化覆盖。 +- 停复机出口开关关闭(回退):停止领取 `stop_resume` 与 `device_eval`,各自动入口恢复旧的当场执行;队列中未执行的指令保留,重新开启后继续执行,执行前按规则重新判断,过期指令自然结束。开关切换时正在执行的指令按收尾规则完成;旧路径正在执行的设备停复机仍持现有设备锁 `RedisPollingDeviceOpLockKey`,设备判断消费者执行前同样检查该锁,持有时 score 推后、不并行执行。 +- 跨账户设备:生产 2026-10-24 实查,有效绑卡设备 20,109 台,其中 11,607 台(约 58%)的卡分属不同运营商账户。整体切换下同一设备的卡始终走同一套调度与同一个停复机出口,不存在一半新一半旧;仍作为测试环境核心验收场景:设备下各卡分属不同账户时,停复机全部经指令队列、各卡最终状态一致,开关开、关、再开过程中无重复停复机与漏停。 +- 稳定运行后删除 `polling:shard:*`、`polling:priority:*`,下线旧调度代码与 `scheduler_mode` 开关。 + +不灰度的代价与对应措施: +- 新调度一旦有问题,影响全部 9 万张轮询卡。措施:测试环境按生产规模压测(批量造卡或回放生产卡 ID),覆盖第五部分测试要求全部场景后再上生产;上线后首日重点监控各账户队列积压、执行中数、处理中标记到期次数、停复机成功率,异常时用全局开关回退。 +- 限流参数没有逐账户试运行的机会。措施:change 1 先以 `observe` 上线采集至少一周数据,按数据配好全部账户的限流组后,再整体切到 `enforce`。 + +### 10. 新方案对现状问题的处理 + +| 现状问题 | 处理方式 | +|---|---| +| 并发不以上游账户为单位、数值无依据 | 运营商账户限流组,数值由运营配置 | +| 背压按 ZCARD 判断 | 删除;按剩余 QPS 与执行上限领取,积压只留在 ZSET | +| 出队后崩溃丢卡 | 卡始终留在队列,处理中标记带 TTL,崩溃后标记到期自动重新领取 | +| 持续失败无人处理 | 退避重试 + 失败告警 + 账户级汇总,运营联系上游或一键关闭轮询(6.2 节) | +| 调度超时管不住入队 | 单轮领取数受 QPS 与执行上限约束,发布量有上界 | +| 慢账户占满协程 | 每个运营商账户独立 NSQ topic 与 `max_in_flight`,轮询不再与其他业务共用 Asynq 协程池 | +| 同卡不同类型并行 | 一张卡一个卡任务,串行执行 | +| 停复机 sleep 重试、跨账户 | 停复机指令队列,按目标卡运营商账户入队,失败退避重试 | +| 保护期反复翻转 | 去掉保护期,规则优先 | +| 全量卡同频轮询、无套餐仍在跑流量 | 分层轮询 + 无套餐层必配卡状态检查 + 到期即停(第 11 节) | +| 激活、重置、订单复机裸 goroutine 当场执行 | 改为请求评估,统一由停复机指令队列执行,失败退避重试与告警(第 6 节) | +| 重置日前尾部用量丢失 | 重置日前加密查流量(第 11.10 节) | +| 失败原因与调用量不可见 | gateway 结构化错误;停复机写单独的停复机记录表(按月分区);查询失败不落库,Redis 按账户 × 操作 × 类别计数 + 卡上保留最近错误 + 持续失败进告警表(6.2 节第 0 条) | +| 业务事件观测序列三连查、不限流、与优先轮询重复 | 改为请求检查,统一走卡任务与限流,延迟确认按场景配置(第 5.1 节) | +| 投递无去重、可能重复执行 | 每个账户队列中一张卡只占一个成员,优先、手动、生命周期只改 `due` 与 score;领取写处理中标记(`SET NX`)才发布,消费时核对 token;停复机指令一卡一条、设备判断按设备合并(第 2、3、6 节) | +| 退款停机走人工接口形成重试风暴 | 退款后改为请求评估,停机走停复机指令队列(第 6 节) | +| 无套餐期间用量计入新套餐 | 套餐激活前流量结算(第 11.9 节) | +| 新旧停复机规则并存、实名轮询口径不一致 | 以新规则为唯一口径,删除旧口径与总开关;实名步骤按运营商账户实名管控决定是否查询(第 12 节) | + +### 11. 分层轮询 + +#### 11.1 目标 + +- 按卡当前业务状态决定轮询频率:在用套餐的卡高频,无套餐、库存卡低频巡检。 +- 兜住"无套餐或套餐过期仍在跑流量"的卡,以及"库存卡在上游开机"的卡:发现开机即停机。 +- 分层规则全部由配置决定,代码不写死层级与频率。 + +参考老系统:老系统高频轮询只覆盖有未过期套餐的卡(从 `tbl_card_life` 出发),另有全量巡检任务对无有效套餐的卡只查上游状态、开机即停,以及套餐到期即停任务。 + +#### 11.2 分层维度 + +在现有匹配维度(网络与实名状态、卡类别、运营商账户)之上新增两个: + +| 维度 | 取值 | 数据来源 | +|---|---|---| +| 套餐状态 `package_state` | 有生效套餐 / 套餐已用完 / 只有待生效套餐 / 无套餐 | `package_usage`:绑定设备的卡按设备、独立卡按卡计算,口径与 `hasValidPackage` 一致(状态 0 待生效、1 生效中、2 已用完、3 已过期、4 已失效) | +| 资产状态 `asset_status` | 在库 / 已销售 / 已换货 / 已停用 | 卡表现有字段 | + +#### 11.3 配置 + +`tb_polling_config` 新增两列 `package_state`、`asset_status`(为空表示不限)。匹配规则:按优先级遍历,每种检查类型取第一个填了间隔的配置。 + +间隔取值的三种含义(修正现状歧义): + +| 取值 | 含义 | 合并时 | +|---|---|---| +| 正数(秒) | 按此间隔检查 | 采用,停止向下查找 | +| `0` | **不检查** | 采用(该类型不检查),停止向下查找 | +| 空(NULL) | 继承 | 跳过,继续找优先级更低的配置 | + +- 现状:`MergedTaskIntervals`(`internal/polling/config_manager.go:176`)实际把 NULL 当"继承",对每种类型取最高优先级的非 NULL 间隔;但字段注释与接口文档(`internal/model/polling.go:17-21`、`internal/model/dto/polling_config_dto.go:10-14`)写的是"NULL 表示不检查",两者不一致。 +- 不修正的后果:示例中"无套餐层流量 —"本意是不查流量,若填 NULL,会继承优先级更低的配置(如"未实名"层或兜底配置)的流量间隔,无套餐卡照样查流量,分层节省调用量失效。 +- 处理:保持 NULL = 继承(不改变生产现有配置的行为),新增 `0` = 不检查;接口校验由 `min=30/60` 改为"等于 0 或不小于最小间隔";字段注释与接口文档改为上表口径。生产 2026-10-24 实查 7 条配置均无 0 值,引入 0 不影响现有数据。 +- 下表示例中"—"即填 `0`(不检查),不是留空。 + +优先级: +- 后台可调整:单条修改 `PUT /polling-configs/:id` 的 `priority`;整体调整顺序用 `PUT /polling-configs/priorities`(一次提交全部配置的顺序,事务内重排,前端拖拽排序用)。 +- 优先级可以重复,相同时按 ID 升序,保证匹配结果确定(现状仅按 priority 排序,相同优先级的顺序不确定;生产 2026-10-24 有 3 条配置同为优先级 1)。保存时若与其他启用配置优先级相同且匹配条件可能重叠,提示但不拦截。 +- 匹配预览:`GET /polling-configs/match-preview?iccid=`,返回该卡当前的匹配维度取值(网络、实名、卡类别、运营商账户、套餐状态、资产状态)、命中的配置列表(按优先级),以及每种检查类型最终采用的间隔与来源配置(含"不检查""继承自 xx""未匹配")。调整优先级或间隔前用于确认效果。可选传入 `config_overrides`(未保存的修改)做预演。 + +调整后生效: +- 配置修改后,现有 Redis 通知机制让各进程立即刷新内存配置(`config_manager.go` 订阅变更通知,另每 5 分钟定时刷新);卡的层级与间隔在该卡下次执行时按新配置重新计算(11.4 节)。 +- 已排好的到期时间不会自动变化:间隔从 1 天改为 1 小时,已排程的卡最多仍要等 1 天才按新间隔执行。为此提供"立即重算到期时间":保存配置时可勾选,或单独调用 `POST /polling-configs/recalculate-due`(可按运营商账户限定范围),后台异步任务按批读取受影响的卡,新到期 = min(当前到期, 上次检查时间 + 新间隔),只提前不推后(推后的情况等卡下次执行时自然生效)。重算只写 Redis 的 `due` 与 score,不调网关;提前到期的卡仍按账户 QPS 与执行上限领取,不会形成波峰。返回任务 ID,可查询进度。 + +配置思路示例(数值由运营配置): + +| 配置 | 匹配条件 | 卡状态 | 流量 | 实名 | 套餐 | +|---|---|---|---|---|---| +| 在用卡 | 有生效套餐 | 高 | 高 | 低 | 中 | +| 已用完 | 套餐已用完 | 中 | 低 | — | 中 | +| 待生效 | 只有待生效套餐 | 低 | — | 中 | 中 | +| 无套餐 | 无套餐、已销售 | 低(必配) | — | — | 低 | +| 库存 | 在库 | 极低(必配) | — | — | 低 | +| 未实名 | 未实名 | — | — | 中 | — | + +保存校验:匹配"无套餐、已销售"或"在库"的配置,卡状态检查间隔必须为正数(不得为空,也不得为 `0`)。 + +"未实名"层只对开了实名管控的运营商账户有意义:未开实名管控的账户,实名结果不影响停复机,实名步骤直接跳过(第 12 节),即使匹配到该层也不查实名。 + +#### 11.4 分层计算时机 + +套餐状态的修改点分散在约 15 个文件、45 处,不逐处挂钩子,改为使用时计算: + +1. 每次卡任务收尾时:查询该卡当前套餐状态,匹配配置,计算各类型下次到期时间。 +2. 全量初始化时:按批查询套餐状态后匹配。 +3. 生命周期事件(`OnCardStatusChanged` 等)重新匹配时一并计算。 + +因此卡的层级最迟在下一次执行时更新。 + +#### 11.5 层级切换 + +- 升层(买了套餐):购买生效、续费生效、排队生效、加油包生效已产生优先轮询项,卡进入 priority 立即执行一次,执行时按新套餐状态重新匹配进入高频层,无需新增代码。 +- 降层(套餐到期)——到期即停:在套餐到期失效处理处(`package_activation_handler.go`),若该资产已无其他有效套餐,直接以场景"套餐到期"请求停复机评估,不经过卡任务的套餐步骤: + - 独立卡:当场调用统一判断函数(场景 = 套餐到期),判断需要停机即生成停复机指令(指令记录场景)。 + - 设备:请求设备维度判断,场景"套餐到期"写入 `eval_scenes`(6.1 节)。 + - 不走套餐步骤的原因:暂停轮询的卡不在轮询队列中(生命周期 `ShouldEnqueue` 排除),套餐步骤不会执行;即使执行,套餐步骤属于周期评估,在暂停规则下也被禁止。到期即停必须以独立场景直接生成指令,暂停轮询的卡或设备才能按第 12 节停机。 + - 该卡下次执行卡任务时(未暂停轮询的卡)自然落入无套餐层。 + - 上线顺序:依赖第 6 个 change 的停复机指令队列与设备判断场景合并;第 6 个 change 上线前不实施到期即停,沿用现有套餐轮询发现过期后停机的逻辑。 + +#### 11.6 兜底:无套餐仍在跑流量、库存卡开机 + +- `EvaluateAndAct` 依据数据库中的 `network_status`。停机失败、结果未知或上游被重新开机时,数据库可能记为停机而上游实际开机,只有卡状态检查查询上游后才能发现。 +- 因此无套餐层与库存层必须配置卡状态检查;查到开机后进入停复机判断。 +- 库存卡上游开机应停机:库存卡通常无套餐,按现有规则命中"无有效套餐"停机原因,无需新增停机规则;售出并有生效套餐后按现有复机规则恢复。 +- 已销售且无套餐、或在库的卡若未匹配到任何配置,监控告警,避免漏掉兜底。 + +#### 11.7 边界 + +| 情况 | 处理 | +|---|---| +| 设备卡 | 套餐状态按设备计算,设备下所有卡同层 | +| 套餐已用完 | 单独一层,中频查状态,确认未继续跑流量 | +| 只有待生效套餐 | 主要等待实名或激活条件,实名检查频率单独配置 | +| 行业卡 | 不单独处理,按第 12 节口径判断;轮询频率如需区分仍可用卡类别维度配置 | +| 未匹配任何配置 | 不入队;已销售无套餐、在库的卡告警 | + +#### 11.8 与新调度的关系 + +- 分层只影响每张卡计算出的下次到期时间,不新增队列,不影响 NSQ 分发与限流。 +- 不依赖新调度,可在现有调度上单独上线,直接减少调用量。 + +#### 11.9 套餐激活前流量结算 + +问题:流量计量(`Iteration/8-11`,已切换日快照计量的卡)的增量 = 本月最大快照 − 本月已结算;卡没有生效套餐时增量只记为已结算、不扣(`internal/application/cardobservation/traffic_flow.go:91`)。这要求无套餐期间有观测。无套餐层不查流量时,无套餐期间跑的流量(以及二次销售前上一用户本月的用量)会在激活后第一次查流量时算进新套餐。 + +设计:资产从"无生效套餐"变为"有生效套餐"之前,先做一次流量结算观测,再激活。 + +1. 触发:`ActivationService` 的实名激活、排队激活、指定激活、队首激活,以及订单购买即生效(`internal/service/order/service.go:2567,2714`),在激活事务开始前判断该资产当前无生效主套餐时执行。已有生效套餐(续费、排队衔接)与流量重置(套餐本身不变)不执行。 +2. 结算:对独立卡查一次流量;对设备逐张查设备下各卡(各卡按所属运营商账户)。查询结果走现有观测入账(`ApplyTrafficObservation`),此时资产无生效套餐,增量只记为已结算。 + - 结算观测**不触发停复机评估**:观测带"激活前结算"标记,入账照常,但不产生触发停复机评估的 Outbox 事件(或消费者识别标记后跳过评估)。否则资产此刻无套餐,会按"无有效套餐"生成停机指令并在 1~2 秒内执行;激活提交后的复机又被单卡最小重试间隔(停机与复机合计)挡住,客户付费后卡要停机一个间隔(约 10 分钟)。 + - 按观测时间归属,不按入账时刻的套餐状态:结算观测带上网关返回时记录的观测时间 `observed_at`;入账时若资产已有生效套餐,但 `observed_at` 早于该套餐的激活时间 `activated_at`,增量仍只记为已结算、不扣减。原因是各激活入口的并发保护不统一(排队、指定、队首激活用 Redis 锁 `RedisPackageActivationLockKey`;订单购买用卡或设备行锁 `FOR UPDATE`,`internal/service/order/service.go:2300`;实名激活 `ActivateByRealname` 两者都没有),结算查询返回慢时,另一入口可能已先激活套餐;若按入账时刻判断有套餐,就会把激活前的旧用量扣到新套餐上。该规则对所有流量观测通用(轮询查流量返回慢于激活同样适用),不只限于结算。 + - 统一资产级激活锁:所有"无生效套餐 → 有生效套餐"的激活入口(实名、排队、指定、队首激活,订单购买即生效)统一使用同一把资产级锁(沿用 `RedisPackageActivationLockKey(carrierType, carrierID)`,实名激活与订单购买补上),结算与激活事务都在锁内执行:抢锁 → 结算 → 激活事务 → 释放锁。锁 TTL 须大于结算总超时 + 激活事务耗时。订单路径原有的行锁保留。观测时间归属是正确性的根本保证,锁用于减少并发激活与重复结算。 +3. 然后执行激活事务;提交后请求停复机评估(第 6 节),这是激活链路唯一的一次评估。 +4. 结算查询失败或超时:不阻塞激活,照常激活,记录告警(卡、资产、错误),该卡本月可能多计无套餐期间用量,由运营核对。 +5. 结算查询属于自动触发的检查,受运营商账户限流约束(批量下单时不形成波峰);限流等待超过单步超时则跳过结算,照常激活并按第 4 条告警。 +6. 总超时:结算可能处于支付回调、实名回调等同步链路中,整次结算设总超时(系统参数,见 7.5 节)。设备逐张查询,总超时到达时剩余未查的卡跳过结算,照常激活,并按第 4 条逐卡告警,避免设备卡多时拖长回调。 +7. 未切换日快照计量的卡沿用老算法,不做结算。 + +效果:无套餐层可以只查状态、不查流量,分层轮询的调用量节省不以计费准确性为代价。 + +#### 11.10 重置日前加密查流量 + +问题:运营商返回本周期累计流量,到重置日清零。每张卡"清零前最后一次查询"到"清零"之间的用量永远查不到,扣不到套餐上;查询越稀,丢得越多(`docs/卡流量计算技术方案-参考老卡管.md` 第 9 章)。 + +设计:在重置时刻前的一段窗口内,把流量检查间隔临时调短,让最后一次查询尽量贴近清零时刻。 + +1. 重置时刻:卡所属运营商账户 `tb_carrier.data_reset_day`(1~28)当天 0 点(业务时区),与流量计量的重置日口径一致。 +2. 配置:`tb_polling_config` 新增两列,为空表示该配置不加密: + - `pre_reset_window`:重置前窗口长度; + - `pre_reset_carddata_interval`:窗口内的流量检查间隔。 + 收尾提前量(最后一次查询相对重置时刻的提前量)为系统参数,见 7.5 节。 + 通常只在有生效套餐的层配置。 +3. 计算流量步骤下次到期时间(卡任务收尾、全量初始化、生命周期重新匹配时): + - 当前已在窗口内:下次到期 = 当前 + 窗口内间隔;若这一时间越过重置时刻,则改为"重置时刻 − 收尾提前量"(已过则不再加查),保证最后一次查询贴近清零; + - 当前在窗口前、按正常间隔算出的下次到期会越过窗口起点:下次到期截断到窗口起点,保证按时进入窗口; + - 越过重置时刻后恢复正常间隔。 +4. 高峰:同一运营商账户的卡重置日相同,窗口内查询集中。仍受运营商账户 QPS 限流约束,不突破上限;限流内查不完的卡按到期先后执行。运营可按账户卡量与 QPS 调整窗口长度与间隔。 +5. 监控:按运营商账户统计每个重置周期"有生效套餐、但窗口内未查到流量"的卡数,以及最后一次查询距重置时刻的分布,用于调整配置。 +6. 适用于全部计量方式(日快照与老算法都存在尾部丢失)。 + +### 12. 停复机规则口径 + +沿用 `Iteration/8-11` 已实现的停复机新规则(`stop_resume_unified.go`),作为唯一口径,不再保留总开关与旧口径: + +> 实名满足 = 卡所属运营商账户未开实名管控(`tb_carrier.realname_control_enabled = 0`),**或** 开了实名管控且卡已实名。 +> 不区分行业卡,不看卡或设备的 `realname_policy`。 + +- 停机原因:流量用完 > 无有效套餐 > 实名不满足(未实名且运营商账户开了实名管控)。 +- 复机:有生效套餐 且 流量充足 且 实名满足;不看停机原因;持通道阈值锁仍拒绝。 + - 不看停机原因包括运营商侧主动停机 `carrier_stopped`(2026-10-24 已定):是否复机只由本系统规则决定,不以外部停机为依据。 + - 风险停机、已销户的卡**必须关闭轮询**(2026-10-24 已定),关闭后不查上游、不自动停复机、不被套餐生效类入口自动恢复、批量开启时跳过: + - 现有:查卡状态返回扩展状态 `gateway_extend` 为"风险停机""已销户"时自动关闭轮询(`ShouldStopPollingForRisk`,`internal/domain/cardobservation/network.go:88`)。生产 2026-10-24 实查共 8,420 张(已销户 5,832、风险停机 2,588),全部为独立卡且均已关闭轮询。 + - 补齐一:现有判断要求 `is_standalone`,绑定设备的卡命中时不关闭。改为不论是否绑定设备都关闭**该卡自身**的 `enable_polling`(不级联关闭设备与设备下其他卡);该卡因此不再参与自动停复机(`autoControlSuspended` 按卡判断),设备维度判断对其余卡照常进行。 + - 补齐二:停复机调用直接返回终态(如"当前用户已销户,无法操作""当前卡为风险停机状态,仅支持风险复机")而查状态尚未返回对应扩展状态时,同样自动关闭该卡轮询,并回写 `gateway_extend`;错误分类表中这类关键字的类别为"终态不可操作",并标记"自动关闭轮询"(6.2 节)。 + - 自动关闭同事务写不轮询清单,来源"风险停机或已销户自动关闭"(13.2 节)。其他终态(如"物联网号码封存")不自动关闭,按 6.2 节写告警,由运营决定。 + - 开启影响(生产 2026-10-24 实查,停机、开启轮询且有生效套餐的卡,未计流量是否充足,为上限):`not_realname` 15,059 张、`carrier_stopped` 998 张、停机原因为空 83 张、`manual` 22 张。所有运营商账户 `realname_control_enabled` 默认为 0(迁移 242),新规则下等于全部账户不要求实名,15,059 张未实名停机卡将满足复机条件。开启前必须按第五部分第 3 节"上线前演练"处理。 +- 暂停轮询(卡或所在设备 `enable_polling` 关闭)沿用老系统"不轮询名单"`tbl_no_loop_card` 的口径(2026-10-23 已定,取代 2026-10-22 的"关闭轮询即人工接管"): + - 不查上游:卡状态、流量、实名都不轮询;由检查结果或周期评估触发的自动停复机(流量用完、实名不满足、卡状态检查后的停复机、套餐步骤评估)不做。老系统对不轮询卡跳过阈值停复机判断(`CardFazhiService.java:258`),全量巡检也排除(`FindTingjiViewComp.java:253`)。 + - 套餐失效停机照常:套餐到期即停(11.5 节)、退款后资产无可用主套餐的停机,不看 `enable_polling`。老系统到期停机任务 `CardJob.stopCard` 不排除不轮询名单。 + - 套餐生效类入口自动恢复轮询:先自动恢复(卡或设备 `enable_polling` 置为开启,写审计与不轮询清单,见 13.2 节),再按规则请求评估。入口:订单套餐生效(客户支付成功、后台下单、批量下单、OpenAPI 钱包下单,均经 `internal/service/order/service.go` `activatePackage`);后台修改套餐有效期(`PATCH /assets/:identifier/packages/:package_usage_id/expires-at`)后资产有生效套餐(2026-10-23 已定)。老系统支付成功、后台充值套餐、批量或单张改有效期后有有效套餐,均把卡移出不轮询名单(`WeixinPayComp.java:3021-3025`,`CardViewComp.java` 中 `zht_rec`、`aght_rec`、`batch expire`、`update expire`)。卡的 `gateway_extend` 为风险停机或已销户时不恢复该卡轮询:这类卡由现有逻辑自动关闭轮询(`internal/domain/cardobservation/network.go:88` `ShouldStopPollingForRisk`),恢复后只会再次撞上游终态错误。 + - 套餐失效停机对暂停轮询的卡只触发一次(到期或退款时请求评估一次,之后由停复机指令按 6.2 节重试);请求写入失败或 Redis 数据丢失时不做补偿扫描(2026-10-23 已定,接受:有套餐的卡关闭轮询由运营自行负责)。 + - 其他自动复机入口(排队激活、实名激活、流量重置、运营商账户实名管控修改)对暂停轮询的卡不自动复机。 + - 实现:请求评估带上场景;判断函数在"暂停轮询"这一步只对套餐失效场景(到期、退款)的停机方向放行;停复机指令记录场景,执行器执行前重新判断时沿用同一场景。 + - 后台人工点击的停复机不受影响。 +- 人工停机不享有豁免(2026-10-23 已定,接受):轮询开启的卡被后台人工停机后,只要有生效套餐、流量充足、实名满足,下一次套餐步骤即按规则自动复机;需要保持停机的,运营须同时关闭轮询。 +- 实名步骤:卡所属运营商账户未开实名管控时跳过查实名(结果不影响停复机),不再按卡类别跳过。 +- 设备维度判断(6.1 节)、停复机指令执行前的重新判断使用同一口径;设备下各卡按各自所属运营商账户判断实名。 + +### 13. 批量开关轮询与不轮询清单 + +现状:开关轮询只有单资产接口 `PATCH /assets/:identifier/polling-status`(`internal/routes/asset.go:140`)和系统自动规则(风险停机或已销户自动关闭、设备开关级联到卡、支付成功自动恢复);没有人工批量操作,卡列表也不能按是否轮询筛选。`iot_card.Service.BatchUpdatePollingStatus` 只在设备级联时内部使用(`internal/service/polling/asset_polling_service.go:96`)。老系统"不轮询网卡"页面(`tbl_no_loop_card`,`NoLoopCardViewComponent`)提供列表筛选、Excel 导入加入或移出、单条删除、导出,以及导卡时直接加入。 + +#### 13.1 批量开关轮询(核心) + +- 勾选批量:新增 `POST /iot-cards/batch-update-polling-status`、`POST /devices/batch-update-polling-status`,参照 `batch-update-realname-policy`:资产 ID 列表(单次最多 500)、目标状态、原因;数据权限校验;同一事务写 `enable_polling`、不轮询清单(13.2 节)与审计;提交后调用生命周期移入或移出轮询(卡复用 `BatchUpdatePollingStatus` 的 `OnCardEnabled` / `OnCardDisabled`;设备沿用现有设备级联到卡)。 +- 文件导入:上传 ICCID(或设备 IMEI)文件并选择开启或关闭,走异步导入任务,逐行处理、逐行返回成功或失败原因,部分成功不回滚;参照手机号资产解绑导入任务(`internal/routes/phone_asset_association.go` `/unbind-imports`),提供任务创建、列表、详情。对应老系统 Excel 导入加入或删除不轮询。 +- 批量开启时跳过 `gateway_extend` 为风险停机或已销户的卡,返回逐项跳过原因,不提供强制开启参数(2026-10-23 已定)。 +- 关闭、开启的影响按第 12 节;确认框须提示。 + +#### 13.2 不轮询清单表 `tb_card_polling_pause` + +调度只认卡与设备的 `enable_polling`,本表不参与调度判断,只承载"谁、何时、为什么关闭"以及列表、筛选与导出,避免为此扫描卡大表,也避免列表被系统自动关闭的卡淹没(生产约 8,456 张关闭轮询的卡中约 8,420 张是风险停机或已销户自动关闭)。 + +| 字段 | 说明 | +|---|---| +| asset_type、asset_id、identifier | 卡或设备,ICCID 或 IMEI | +| carrier_id | 卡所属运营商账户(设备为空) | +| status | 已关闭 / 已恢复 | +| pause_source | 关闭来源:人工单条、勾选批量、文件导入、风险停机或已销户自动关闭、设备级联、停机被拒告警处理、历史数据 | +| pause_reason、paused_by、paused_at | 关闭原因、操作人(系统为空)、时间 | +| import_task_id | 文件导入时的任务 | +| resume_source、resumed_by、resumed_at | 恢复来源:人工、批量、支付或下单生效、改有效期、设备级联 | + +- 每个资产只保留一条状态为"已关闭"的记录(部分唯一索引);恢复时把该记录标为"已恢复"并保留,再次关闭新增一条,保留历史(2026-10-23 已定)。 +- 所有改 `enable_polling` 的入口在同一事务内写本表:单资产接口、13.1 节批量与导入、`ShouldStopPollingForRisk` 自动关闭(`internal/application/cardobservation/network.go:69`)、设备级联、第 12 节各自动恢复入口、6.2 节告警页关闭轮询。 +- 存量:上线时一次性补写当前 `enable_polling = false` 的卡与设备,`gateway_extend` 为风险停机或已销户的记为"风险停机或已销户自动关闭",其余记为"历史数据"。 +- 后台页面:列表按运营商账户、来源、状态、ICCID、操作人、时间筛选;导出复用统一导出任务,新增场景 `polling_pause`;列表勾选可批量开启。 +- 卡列表、设备列表同时增加"是否轮询"筛选,导出带上该字段。 + +#### 13.3 停机被拒告警列表 + +- 告警页(6.2 节)增加"停机被拒"视图:`tb_polling_failure_alert` 中停复机方向为停机、错误类型为终态或业务错误,或停机失败达到阈值的告警。 +- 展示:ICCID、运营商账户、所属资产、网络状态、套餐状态、停机原因、上游返回码与信息、首次与最近失败时间、失败次数、是否已关闭轮询、处理状态与处理人。 +- 导出复用统一导出任务,新增场景 `polling_stop_refused_alert`;列表勾选可批量关闭轮询(按 13.1 节,单次 500),清单来源记为"停机被拒告警处理"。 + +不在本方案范围:导卡时直接设为不轮询(老系统导卡页面"导入不轮询卡"选项),需要时导卡后用 13.1 节文件导入关闭。 + +不在本方案范围:激活状态计算(`internal/store/postgres/iot_card_store.go:274,314`、`internal/application/cardobservation/apply.go:139-140`)仍按"在线且(行业卡或已实名)"判断,是否改为同一口径另行讨论。 + +### 14. 后台接口清单(供前端) + +原则:第 7 节所有后台配置、第 6.2 节告警、第 13 节不轮询清单都提供查询与修改接口,前端据此做页面;只有 yaml 系统参数(7.5 节)不开放接口。路由沿用现有轮询接口风格(`internal/routes/polling_*.go`,`Register` + `RouteSpec` 自动生成 OpenAPI 文档),均需登录;修改类接口仅超级管理员可用并写统一审计(配置变更属于人工操作审计,不受第 6.2 节"系统自动停复机不写统一审计"影响)。修改后同步 Redis,下一轮调度生效。 + +#### 14.1 配置类 + +| 配置 | 接口 | 说明 | +|---|---|---| +| 限流组(7.1) | `GET /polling-rate-limit-groups`(按运营商账户筛选,含全局组)、`POST`、`GET /:id`、`PUT /:id`、`PUT /:id/status`、`DELETE /:id` | 保存时校验:同账户同一操作只属于一个组、`reserved_qps < max_qps`、高优先级操作属于本组操作;停用提示"回落默认 QPS" | +| 运营商账户调度参数(7.2) | `GET /polling-carrier-params`(列表:全部运营商账户,未配置的返回全局默认值并标记"默认")、`GET /:carrier_id`、`PUT /:carrier_id`(不存在则创建)、`DELETE /:carrier_id`(恢复全局默认) | 保存时按当前订阅进程数校验执行上限与 `max_in_flight`(3.2 节);不提供按账户切换调度的接口(不灰度,见第 9 节) | +| 卡任务步骤、失败告警标量参数(7.3、7.4) | 复用现有 `GET /system-configs?module=polling`、`PUT /system-configs/:key` | 需改动:注册 `module = polling` 的配置项;`SystemConfigListRequest.Module` 枚举增加 `polling`;`step_order`、`backoff`、`alert_fail_count` 等 JSON 值现只校验 JSON 合法,需为注册项增加结构校验(如步骤只能是 4 种类型且不重复、退避序列递增且不超过检查间隔) | +| 限流模式 | 复用 `/system-configs`,键 `polling.rate_limit_mode`(`observe` / `enforce`) | 全局生效,不按账户覆盖 | +| 调度模式 | 复用 `/system-configs`,键 `polling.scheduler_mode`(`legacy` / `carrier`) | 切换触发第 9 节初始化流程;`GET /polling-stats/init-progress`(现有接口)查看新结构初始化进度 | +| 停复机出口全局开关 | 复用 `/system-configs`,键 `polling.stop_resume_queue_enabled`(bool) | 从 yaml 改为受控系统配置:开关与回退需要即时生效,不能依赖重新部署(第 9 节) | +| 上游错误分类(7.4) | `GET /gateway-error-classifications`(按类别、操作、启用状态、关键字筛选)、`POST`、`PUT /:id`、`PUT /:id/status`、`DELETE /:id`;`POST /gateway-error-classifications/test`(输入一段上游返回信息,返回命中的规则与类别) | 测试接口用于运营新增规则前验证 | +| 业务事件检查场景(7.6) | `GET /polling-check-scenes`、`PUT /polling-check-scenes/:scene` | 场景由代码预置(入口在代码里),不提供新增与删除,只改检查类型、确认延迟、冷却 | +| 轮询配置(分层与重置日前加密,11.3、11.10) | 现有 `/polling-configs` 增加 `package_state`、`asset_status`、`pre_reset_window`、`pre_reset_carddata_interval` 四个字段,去掉 `protect_check_interval`;间隔支持 `0` = 不检查;新增 `PUT /polling-configs/priorities`(整体排序)、`GET /polling-configs/match-preview`(匹配预览)、`POST /polling-configs/recalculate-due` 与 `GET /polling-configs/recalculate-due/:task_id`(重算到期时间及进度) | 保存校验与三种间隔含义见 11.3 节;前端以"按间隔 / 不检查 / 继承"三态展示每种检查类型,并支持拖拽排序 | +| 旧并发配置 | 现有 `/polling-concurrency` | 下线(第四部分),前端去掉页面 | + +#### 14.2 枚举与选项 + +`GET /polling-options`:一次返回前端下拉所需的枚举——操作类型(查流量 / 查状态 / 查实名 / 停机 / 复机)、错误类别、业务事件场景、检查类型、套餐状态、资产状态、不轮询清单来源、告警状态与错误类型。运营商账户下拉沿用现有运营商账户列表接口。前端不写死枚举值。 + +#### 14.3 监控与查询类 + +| 功能 | 接口 | 说明 | +|---|---|---| +| 按账户监控(8 节) | 现有 `/polling-stats` 改造:`GET /polling-stats/carriers`(各账户队列长度、到期数、执行中数、实际 QPS、限流等待与超时次数、处理中标记到期次数、停复机成功 / 失败 / 重试)、`GET /polling-stats/carriers/:carrier_id/trend`(分钟级趋势) | 原按任务类型的统计在全部切换后下线 | +| 分层统计(11 节) | `GET /polling-stats/tiers` | 各层卡数、漏配告警卡数 | +| 重置日前加密统计(11.10) | `GET /polling-stats/pre-reset` | 按账户统计窗口内未查到流量的卡数与最后一次查询距重置时刻分布 | +| 停复机记录(6.2) | `GET /card-stop-resume-records`(按卡 ICCID、运营商账户、动作、来源、结果、错误类别、时间范围筛选)、`GET /iot-cards/:id/stop-resume-records`(单卡历史) | 时间范围必填且有上限,避免跨多个月分区扫描 | +| 停复机规则演练 | `GET /stop-resume-rule/dry-run`(按运营商账户、停机原因筛选;返回按账户 × 停机原因 × 判断结果的汇总与明细);明细导出复用统一导出任务,场景 `stop_resume_rule_dry_run` | 只判断、不执行、不写任何状态;可传入"假设的实名管控配置"预演;上线前演练用(第五部分第 3 节) | +| 卡当前停复机指令 | `GET /iot-cards/:id/polling-state` | 卡的各检查类型下次到期时间、最近错误、当前停复机指令(动作、原因、场景、失败次数、待确认)、是否在执行中;排查用,只读 | + +#### 14.4 告警类(6.2、13.3) + +| 功能 | 接口 | +|---|---| +| 失败告警列表 | `GET /polling-failure-alerts`(按运营商账户、检查类型、错误类型、状态、时间筛选) | +| 账户维度汇总 | `GET /polling-failure-alerts/summary`(按运营商账户 × 错误类型计数) | +| 停机被拒视图 | `GET /polling-failure-alerts?view=stop_refused` | +| 告警详情 | `GET /polling-failure-alerts/:id`(含最近停复机记录) | +| 处理 | `PUT /polling-failure-alerts/:id/status`(标记已处理);`POST /polling-failure-alerts/close-polling`(勾选告警批量关闭对应卡或设备的轮询,单次 500,清单来源记为"停机被拒告警处理") | +| 导出 | 复用统一导出任务,场景 `polling_stop_refused_alert` | + +现有 `/polling-alert-rules`、`/polling-alert-history`(按队列指标的告警规则)保留,与本节失败告警互不替代。 + +#### 14.5 不轮询清单与批量开关(13 节) + +| 功能 | 接口 | +|---|---| +| 勾选批量 | `POST /iot-cards/batch-update-polling-status`、`POST /devices/batch-update-polling-status` | +| 文件导入 | `POST /polling-status-imports`(上传 ICCID 或 IMEI 文件 + 目标状态 + 原因)、`GET /polling-status-imports`、`GET /polling-status-imports/:id`(逐行结果) | +| 不轮询清单 | `GET /card-polling-pauses`(按运营商账户、来源、状态、ICCID、操作人、时间筛选);导出复用统一导出任务,场景 `polling_pause` | +| 卡、设备列表筛选 | 现有卡列表、设备列表接口增加 `enable_polling` 筛选参数,导出带上该字段 | + +#### 14.6 前端改动汇总 + +新增页面:限流组、运营商账户调度参数(含新调度切换)、错误分类(含测试)、业务事件场景、失败告警(含账户汇总与停机被拒视图)、停复机记录、不轮询清单与文件导入。 +改造页面:轮询配置(加 4 个字段、去掉保护期间隔)、系统配置(增加 `polling` 模块)、轮询监控(按运营商账户)、卡与设备列表(是否轮询筛选、批量开关)、资产详情(去掉保护期状态,后台与 C 端)。 +下线页面:轮询并发配置。 + +--- + +## 第四部分:清理项 + +数据库原则:清理只针对代码,数据库只加不删。废弃的表与列保留,代码中去掉读写,表或列注释标记已废弃;不执行删表、删列迁移。 + +保护期整套机制: + +| 位置 | 清理内容 | +|---|---| +| `polling:protect` 类型 | `internal/task/polling_protect_handler.go`、`pkg/queue/handler.go:290-300` 注册、统计与监控;`StopResumeService.ProtectCheckSkipsForRealname`(`stop_resume_unified.go`,仅保护期轮询使用) | +| 轮询配置 `protect_check_interval` | 模型字段、DTO(`internal/model/dto/polling_config_dto.go:13,28`)、`config_manager.go` 匹配逻辑;数据库列保留不删,列注释标记已废弃 | +| 设备停复机(`internal/service/device/service.go`) | 不再设置保护期,不再因保护期拒绝相反操作 | +| 手动停复机单卡(`stop_resume_service.go:969,1036`) | 去掉保护期检查 | +| 资产接口 `device_protect_status` | `asset_dto.go:38,144`、`client_asset_dto.go:75`、`internal/service/asset/service.go`:**字段保留不删**,固定返回"无保护期",接口文档标记已废弃;后台前端与 C 端(含老版本小程序)都不再读取后,另行删除。避免直接删字段导致老版本客户端出错 | +| 常量 | `RedisDeviceProtectKey`、`DeviceProtectPeriodDuration` 删除;`StopReasonProtectPeriod` 保留(历史数据与 `pkg/constants/agent_open_api.go:65` 对外开放接口映射仍需要),不再产生新值 | +| 卡字段 `last_protect_check_at` | 从模型去掉,不再读写(`initializer.go:322`、`polling_protect_handler.go:159`);数据库列保留不删,列注释标记已废弃 | + +旧分片与兼容代码: +- `PollingShardCount`、`polling:shard:*` 相关方法。 +- 旧非分片队列 key `RedisPollingQueue{Realname,Carddata,Package,Protect}Key` 与监控兼容模式 `getLegacyAverageWaitTime`。 +- `PollingBackpressureThreshold` 及按 ZCARD 的背压逻辑。 + +旧并发控制: +- `internal/service/polling/concurrency_service.go`、`PollingConcurrencyConfig` 模型、按类型的并发配置接口与页面;表 `tb_polling_concurrency_config` 保留不删,表注释标记已废弃。 +- `acquireConcurrency` / `releaseConcurrency` 及写死的默认并发数。 + +旧提示通道: +- `polling:priority:{taskType}` List,改为运营商账户 priority ZSET。 + +停复机 sleep 重试: +- 轮询路径不再使用 `stopCardWithRetry` / `resumeCardWithRetry` 的"3 次重试、每次 sleep 2 秒";非轮询入口保持不变。 + +业务事件触发的卡观测序列: +- 订单、激活、停复机成功、设备操作、自动续费购包 5 处 `AppendSeriesRequested` 改为请求检查;对应的观测序列 Outbox 事件、`SeriesTrigger` 的卡检查序列与"立即、3 分钟、5 分钟"固定延迟不再用于卡检查。 +- 读入口的 `BestEffortSeriesDispatcher` 卡检查分发(`client_asset.go`、`admin/asset.go`、`client_realname.go`、`gateway_service.go`)改为请求检查;OpenAPI 查询本来就不触发,保持不变。 + +退款后资产处理的停机: +- `refund/service.go` `stopAsset` 调 `ManualStopCard` 当场停机(停机原因记为 `manual`)的写法,改为请求评估。 + +激活、重置、订单后的裸 goroutine 复机: +- `activation_service.go` `resumeAfterActivation`、`reset_service.go:400`、`order/service.go:2196` 中起 goroutine 当场调 `ResumeCardIfStopped` 的写法,改为事务提交后请求评估。 + +停复机旧口径与总开关: +- 总开关 `stop_resume.unified_rule_enabled`(`JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`)及各处分支。 +- `isRealnameOK` 旧分支(行业卡判断、卡或设备 `realname_policy` 判断);`isPollingStopReason` 限定自动复机的逻辑;`ListByDeviceIDAndPollingStopReasons`。 +- `internal/task/polling_realname_handler.go:70` 按行业卡跳过实名查询,改为按运营商账户实名管控跳过。 + +调用记录: +- 系统自动停复机不再写统一审计 `tb_audit_event` 与集成日志 `tb_integration_log`,改写停复机记录表;轮询查询不再写集成日志(含现有"数据有变化时写成功记录")。 + +设备锁: +- 轮询路径上现有设备锁 `RedisPollingDeviceOpLockKey`(SetNX 30 秒,抢不到即跳过、包住整段网关调用)由第三部分 6.1 节的设备判断锁 + 待判断标记 + `polling:device_eval` 任务取代;非轮询入口(人工设备停复机)保持不变。 + +--- + +## 第五部分:改动范围与实施拆分 + +### 1. 现有代码规模 + +- 轮询相关约 7,500 行:`internal/polling` 2,184 行、`internal/task/polling_*` 1,742 行、`internal/service/polling` 3,598 行。 +- 牵扯停复机、Outbox、设备、资产、网关、Worker 装配约 6,900 行。 +- 仓库仅 1 个测试文件(`internal/bootstrap/worker_services_test.go`),上述代码基本无测试兜底,验证依赖测试环境。 + +测试要求(每个 change 的 tasks 中必须包含):本方案的正确性主要落在十几段 Lua(领取、收尾、续期、限流、请求检查、设备判断),这些是最容易出并发问题的地方,必须用真实 Redis(测试容器或测试环境实例,不用 mock)做脚本级测试,至少覆盖: + +| 场景 | 验证点 | +|---|---| +| 多实例同时领取 | 同卡只被领取一次;分发锁下同一账户每轮只领取一次;执行上限不被突破 | +| 处理中崩溃 | 不收尾时租约到期后卡被重新领取,不丢卡 | +| 长任务续期 | 执行超过单个租约时续期成功,期间不被重新领取;token 不一致时续期失败并停止后续步骤 | +| NSQ 重投 | 同一 token 两个消费者只有一个拿到执行权 | +| 停复机指令覆盖 | 执行中写入反向指令,收尾后新指令保留且 score 正确;无覆盖时指令被移除 | +| 单卡最小重试间隔 | 间隔内不领取,score 推后到可调用时间 | +| 设备判断合并 | 判断期间新请求使 `eval_ver` 变化,收尾后重新判断一次 | +| 限流窗口 | 窗口边界、高优先级预留与 `high_waiting`、全局组与账户组同时生效、停用组回落默认 QPS | +| 请求检查 | 多次请求合并为一个队列成员;冷却时长内不请求;执行中请求打优先标记 | +| 激活前结算 | 结算观测不产生停机指令,激活提交后只评估一次;结算返回晚于另一入口激活时,激活前用量只记已结算、不扣新套餐 | +| 内部重试计量 | 一次方法调用内的每次 HTTP 尝试各扣一次额度;停复机超时不发起第二次请求 | +| Asynq 任务限流 | 限流错误经 `IsFailure` 不计入 Asynq 重试次数、按固定短延迟重试,不按 n⁴ 递增、不归档;非限流错误行为不变;只统计模式下不拦截 | +| 停复机结果未知 | 超时后按确认延迟查状态;已是目标状态则结束;不是则按单卡最小重试间隔重发;查询失败不重发 | +| 暂停轮询到期停机 | 暂停轮询的独立卡、设备套餐到期后生成停机指令;到期与普通轮询请求合并后场景不丢失 | +| 风险停机与已销户 | 查状态返回扩展状态、或停复机返回终态时,独立卡与绑定设备的卡都关闭自身轮询,设备与其他卡不受影响;之后不被自动恢复、批量开启跳过 | +| 分层匹配 | 间隔 `0` 停止向下继承、空值继承;相同优先级按 ID 稳定排序;重算到期时间只提前不推后 | +| 跨账户设备 | 设备下各卡分属不同运营商账户时,停复机全部经指令队列、各卡最终状态一致;三个全局开关开、关、再开无重复停复机与漏停 | +| 整体切换与回退 | `scheduler_mode` 切换时旧 Asynq 排队任务直接结束、新结构初始化完成前不领取;按生产卡量(约 10 万)实测初始化耗时;回退后旧分片队列完整、无丢卡 | + +### 2. 改动明细 + +新增: + +| 模块 | 内容 | 估算 | +|---|---|---| +| 网关限流器 | 滑动窗口 Lua、高优先级预留与等待登记、全局组、ctx 豁免标记、立即返回与等待两种模式 | 约 300 行 | +| 失败原因与调用量记录 | gateway 结构化错误;`tb_card_stop_resume_record` 表、模型、Store、按月分区与过期清理、查询接口;查询结果 Redis 分钟计数与监控读取;卡最近错误写入 `due` | 约 450 行 | +| 配置 | 限流组、运营商账户调度参数、步骤与停复机参数:表、模型、Store、Service、后台接口、Redis 同步、校验 | 约 700 行 | +| 分发器 | 账户分发锁、领取 Lua、处理中标记与派发 token、执行计数与执行上限、停复机先出、优先先出与优先占比、发布 NSQ | 约 550 行 | +| 失败处理与告警 | 失败计数与退避、`tb_polling_failure_alert` 表与 Store、告警写入与自动恢复、账户级汇总、后台告警接口与一键关闭轮询 | 约 500 行 | +| NSQ 接入 | 生产者与消费者封装、按账户动态订阅、token 核对、Touch、优雅退出 | 约 400 行 | +| NSQ 部署 | 测试环境部署 nsqd + nsqlookupd,yaml 配置 | 运维 | +| 卡任务 | 步骤编排、收尾 Lua、租约续期与执行权、`due` 维护 | 约 450 行 | +| 停复机指令 | 生成、覆盖与版本号、执行器、退避重试、单卡最小重试间隔、待确认先查状态 | 约 500 行 | +| 上游错误分类 | 关键字 → 类别配置(表、后台接口、Redis 同步)、分类器、终态与频率限制处理 | 约 250 行 | +| 业务事件与读入口请求检查 | 请求检查 Lua、场景配置(表、后台接口)、延迟确认、各入口改造 | 约 400 行 | +| 批量开关轮询与不轮询清单 | 卡、设备批量接口;文件导入任务;`tb_card_polling_pause` 表、Store、各开关入口同事务写入、存量补写;清单页与导出场景 `polling_pause`;停机被拒视图与导出场景 `polling_stop_refused_alert`;卡、设备列表是否轮询筛选(第 13 节) | 约 600 行 | +| 数据库迁移 | 只加不删:新增 7 张表——不轮询清单 `tb_card_polling_pause`、限流组 `tb_carrier_rate_limit_group`、运营商账户调度参数 `tb_carrier_polling_param`、错误分类 `tb_gateway_error_classification`、业务事件检查场景 `tb_polling_check_scene`、停复机记录 `tb_card_stop_resume_record`(按月分区 + DEFAULT 分区)、失败告警 `tb_polling_failure_alert`;步骤与失败告警标量参数写入现有 `tb_system_config`(只插数据);`tb_polling_config` 新增分层与重置日前加密 4 个可空列;废弃的 `tb_polling_concurrency_config`、`protect_check_interval`、`last_protect_check_at` 保留不删,只在代码中去掉读写并在注释中标记已废弃 | 按 change 拆分 | + +重写: + +| 文件 | 改动 | +|---|---| +| `internal/polling/queue_manager.go`(273 行) | 改为运营商账户队列 + `due` | +| `internal/polling/scheduler.go`(298 行) | 改为分发器(领取 + 发布 NSQ) | +| `internal/polling/initializer.go`(348 行) | 改为写 `due` 与运营商账户队列 | +| `internal/task/polling_{realname,carddata,cardstatus,package}_handler.go`(约 550 行) | 拆为步骤函数 | +| `internal/task/polling_base.go`(242 行) | 删令牌,重入队改为写 `due` | +| `internal/service/polling/monitoring_service.go`(317 行) | 改为按运营商账户统计 | + +修改: + +| 文件 | 改动 | +|---|---| +| `internal/service/iot_card/stop_resume_service.go`(1,133 行) | `EvaluateAndAct` 只判断并生成指令;抽出执行部分供执行器复用;去掉保护期检查 | +| `internal/infrastructure/cardobservation/event.go` | 3 个消费者随 `EvaluateAndAct` 调整,接口不变 | +| `internal/service/carrier/service.go` | 实名管控修改后对受影响的卡批量请求评估 | +| `internal/service/order/service.go`、`internal/service/package/activation_service.go`、`internal/service/iot_card/stop_resume_service.go`、`internal/service/device/service.go`、`internal/task/auto_purchase.go`、`internal/application/cardobservation/series*.go` | 业务事件由观测序列改为请求检查;场景延迟确认配置 | +| `internal/handler/app/client_asset.go`、`internal/handler/admin/asset.go`、`internal/handler/app/client_realname.go`、`internal/service/iot_card/gateway_service.go`、`internal/infrastructure/cardobservation/best_effort.go` | 读入口由观测序列改为请求检查 | +| `internal/service/refund/service.go` | 退款后资产处理由 `ManualStopCard` 改为请求评估,请求成功即标记资产后处理完成 | +| `internal/service/package/activation_service.go`、`internal/service/package/reset_service.go`、`internal/service/order/service.go` | 激活、重置、订单后的复机改为请求评估;激活前流量结算(11.9 节) | +| `internal/polling/lifecycle_service.go`、`internal/service/polling/asset_polling_service.go` | 操作运营商账户队列 | +| `internal/task/polling_priority_claim.go`、`internal/service/polling/priority_manual_service.go` | 提示写入方式调整,认领逻辑不变 | +| `internal/service/polling/manual_trigger_service.go` | 仅消费方式调整 | +| `internal/service/polling/config_service.go`、`internal/polling/config_manager.go` | 删除保护期检查间隔 | +| `internal/service/iot_card/stop_resume_unified.go`(98 行)、`pkg/config/config.go:65`、`pkg/config/loader.go:162`、`pkg/config/defaults/config.yaml:166`(`stop_resume.unified_rule_enabled`)、`.gitea/workflows/deploy.yaml:67`(`JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`)、`internal/task/polling_realname_handler.go` | 新规则转正:新规则并入主逻辑,删除总开关配置与部署环境变量、旧口径分支;实名轮询改按运营商账户实名管控跳过 | +| `internal/service/device/service.go`、`internal/service/asset/service.go` 及 DTO | 删除保护期 | +| `internal/gateway/client.go`、`internal/gateway/flow_card.go` | 限流器挂在 `AttemptObserver.BeforeAttempt`,按每次 HTTP 尝试扣额度;返回结构化错误(类别、业务码、信息、耗时);`StopCard` / `StartCard` 设 `maxRetries = 0` | +| `internal/application/cardobservation/traffic_flow.go` | 流量入账按观测时间与套餐激活时间归属,激活前的用量只记已结算 | +| `internal/service/package/activation_service.go`、`internal/service/order/service.go` | 实名激活与订单购买即生效补上资产级激活锁,结算与激活在锁内执行 | +| `pkg/queue/handler.go`、`cmd/worker/main.go`、`internal/bootstrap/*`、`pkg/constants/*` | 移除轮询的 Asynq 注册;装配分发器与 NSQ 消费者;常量 | + +删除:`polling_protect_handler.go`(166 行)、`concurrency_service.go`(293 行)及对应 handler/路由/DTO、分片与兼容代码、卡检查用观测序列分发、新规则总开关与旧口径分支,约 1,100 行。 + +前端:限流组与各项参数配置页;轮询配置页去掉保护期间隔;资产详情(后台与 C 端)去掉保护期状态;监控页按运营商账户展示。 + +合计:新增约 5,000 行(含停复机记录表、错误分类、请求检查、批量开关轮询等),重写约 2,000 行,修改约 25 个文件,删除约 1,100 行;后端粗估 17~22 人日(含分层轮询 4~5 人日、批量开关轮询与不轮询清单 2 人日),不含前端与测试环境验证。 + +### 3. 实施拆分(按 OpenSpec change 依次上线) + +前置条件(运行基线,2026-10-24 两库只读实查): + +| 项目 | 测试库 | 生产库 | +|---|---:|---:| +| 迁移版本 | 242 | 240 | +| 未删除卡数 | 31 | 98,430 | +| 开启轮询的卡 | 28 | 89,962 | +| 有效绑卡设备 | 6 | 20,109 | +| 跨运营商账户设备 | 5 | 11,607 | + +- 生产尚未执行迁移 241(日快照计量 `tb_card_flow_day` / 切换水位)与 242(运营商账户实名管控 `realname_control_enabled`),即 `Iteration/8-11` 的停复机新规则与日快照计量尚未上线生产;测试库虽已有表,切换水位记录为 0,日快照计量也未经测试环境真实卡验证。这两项功能不能视为已获生产验证。 +- 因此:change 4(新规则转正)前置为"`Iteration/8-11` 已上线生产,各运营商账户实名管控已按需配置,新规则在生产开启并稳定运行";change 2 中激活前流量结算只对已切换日快照计量的卡生效,前置为"日快照计量已在生产开启并核对结算结果"。 +- 上线前演练(停复机新规则在生产开启前,即 `Iteration/8-11` 总开关开启与 change 4 转正之前): + 1. 运营先为需要实名的运营商账户设置 `realname_control_enabled = 1`。 + 2. 执行演练:按新规则对全部停机卡做一次只判断、不执行的评估(`GET /stop-resume-rule/dry-run`,见 14.3 节),按运营商账户 × 停机原因 × 判断结果输出"开启后将被复机 / 将被停机"的卡数与明细,可导出。 + 3. 运营核对演练结果,确认无误后再开启;需要保持停机的卡先关闭轮询(第 13 节批量操作)。 + 4. 开启后集中复机受各账户停复机 QPS 与单卡最小重试间隔约束,按账户平滑执行;首日监控复机成功率与终态错误数。 +- 测试库只有 31 张卡,不足以验证吞吐与限流参数;各账户 QPS、执行上限的初始值须按生产卡量、轮询频率与上游允许的 QPS 计算,change 1 上线后用其采集的生产调用量与失败分类数据校准。 + +| 顺序 | change | 价值 | 风险 | 依赖 | +|---|---|---|---|---| +| 1 | 网关限流器(按每次 HTTP 尝试计量)+ 限流组配置 + 失败原因与调用量记录 + 停复机关闭 gateway 内部重试 | 最高:可直接套在现有调度上,先缓解超频与重复重试;失败原因可见,作为各账户 QPS 配置依据 | 低:不动现有调度 | 无 | +| 2 | 分层轮询 + 激活前流量结算(含观测时间归属与统一激活锁)+ 重置日前加密 | 高:直接减少调用量,兜住无套餐跑流量与库存卡开机;无套餐期间用量不计入新套餐;减少重置日前尾部丢失 | 低:只改配置匹配与激活前结算,需前端配合;激活后复机沿用现有路径,change 6 上线后改为请求评估 | 激活前结算依赖日快照计量已上线生产 | +| 3 | 去掉保护期 | 消除反复翻转,简化后续改造 | 低:需前端配合 | 无 | +| 4 | 停复机新规则转正 | 删除总开关与旧口径,实名轮询按运营商账户实名管控跳过 | 高(开启时):生产约 1.5 万张未实名停机卡可能被批量复机,须先完成实名管控配置与上线前演练;转正本身低风险 | `Iteration/8-11` 已上线生产(总开关可仍关闭);分两次发布:第一次发布演练接口与关闭轮询补齐,演练、配置实名管控后开启总开关;稳定后第二次发布删除总开关与旧口径 | +| 5 | 运营商账户调度 + NSQ 分发 + 卡任务 + 租约 + 失败退避与失败告警表 + 业务事件请求检查 | 运营商账户隔离、不丢卡、同卡串行;业务事件检查受限流约束、不再重复 | 高:核心链路重写,需部署 NSQ | 1、3 | +| 6 | 停复机指令队列 + 设备维度判断(含场景合并)+ 自动入口统一 + 到期即停 + 全局开关 `stop_resume_queue_enabled` | 停复机优先、不再 sleep 重试、设备下各卡一致(含跨账户设备);激活、重置、订单复机不丢、不超频;退款停机不再形成重试风暴;暂停轮询的卡到期照常停机 | 中:需拆分 `EvaluateAndAct`;跨账户设备为核心验收场景 | 1、4、5 | +| 7 | 批量开关轮询 + 不轮询清单 + 套餐生效类自动恢复轮询(第 12、13 节);停机被拒视图与导出随失败告警表上线 | 高:补上人工批量操作,运营可按清单处理停机被拒、按账户关闭轮询 | 低:只动开关入口与后台,需前端配合 | 停机被拒视图依赖 5、6(失败告警表在 change 5 建立);自动恢复后的"按规则请求评估"在 change 6 前沿用现有复机路径 | + +依赖说明:到期即停依赖停复机指令队列与设备判断场景合并(11.5 节、6.1 节),从原 change 2 移到 change 6;change 6 依赖 change 4,因为暂停轮询、实名管控等判断口径以新规则为唯一口径。 + +分层轮询改动:`tb_polling_config` 及模型、DTO、后台接口与页面加两个匹配条件与保存校验;`config_manager.go` 匹配逻辑与套餐状态计算;`package_usage_store` 按资产查询套餐状态(单个与批量);`initializer.go`、`lifecycle_service.go`、重入队处传入套餐状态;监控按层统计与漏配告警;`activation_service.go` 与订单即生效处的激活前流量结算;重置日前加密(两列配置、到期时间计算、监控)。后端粗估 4~5 人日。到期即停(`package_activation_handler.go`)计入 change 6。 + +第 1 步上线时,限流器默认对所有卡操作生效,后台人工点击、通道阈值、自动续费、手动刷新 4 个入口补上"豁免限流"标记;现有轮询 handler、观测序列执行、Outbox 消费者触发的停复机等自动入口无需改动即受限流。 + +第 1 步在现有 Asynq 调度上的等待策略:Asynq 总协程数为 `queue.concurrency`(默认 10),轮询与 Outbox、导出、佣金等业务共用。若在 handler 内等待限流名额,轮询任务会占满协程、饿死其他业务。因此第 1 步中凡在 Asynq handler 内发起的受限流调用(现有轮询 handler、观测序列执行、Outbox 消费者触发的停复机)**不等待**:拿不到名额立即返回"限流"结果,轮询任务按 `rate_limited_retry_delay` 短延迟 Requeue、不计失败;Outbox 消费者返回"限流"错误,由 Asynq 按下方规则短延迟重试、不计失败;观测序列任务为 `MaxRetry(0)`,遇限流时记一次"被限流"并结束本次尝试(不归档),由后续尝试与普通轮询兜底,观测序列在 change 5 下线。"等待名额后重试"只在第 5 步的 NSQ 消费者中启用(NSQ 消费者按账户独立并发,不占用 Asynq 协程)。限流器接口因此提供两种模式:立即返回(Asynq handler 用)与等待至超时(NSQ 消费者、激活前结算用)。 + +第 1 步中 Asynq 任务遇到限流的处理(Outbox 消费者、观测序列): +- 现状:Outbox 事件由 Relay 投递为 Asynq 任务 `outbox:deliver`,消费者(如流量入账后调用 `EvaluateAndAct` 停机)返回错误时,由 Asynq 按任务重试;`relay.go` 中的 `retry_count` 只计"投递入队"失败,不计消费失败。`outbox:deliver` 入队未设 `MaxRetry`,使用 Asynq 默认 25 次,重试间隔为 `asynq.DefaultRetryDelayFunc`(`pkg/queue/server.go:44`,约第 n 次等待 n⁴ 秒)。 +- 风险:限流立即返回的错误若按普通失败处理,高峰期连续被限流几次后,"流量用完停机"会被推迟数小时(第 8 次约 1 小时、第 10 次约 2.8 小时),期间卡继续跑流量;25 次用完后任务归档,停机不再执行。 +- 处理:限流器返回专用错误类型"限流",在 Asynq 服务配置(`pkg/queue/server.go`)中: + - `IsFailure` 对"限流"错误返回 false:不计入失败、不增加重试计数,不会因限流归档; + - `RetryDelayFunc` 对"限流"错误返回固定短延迟 `rate_limited_retry_delay`,不按 n⁴ 递增;其他错误仍用默认函数。 + - 同一任务因限流累计推迟超过上限(系统参数)时写告警,任务仍继续重试。 + - 只改 Asynq 服务配置与错误类型,不改各消费者业务代码;对所有 Asynq 任务生效,非限流错误的行为不变。 + +第 1 步的拦截模式:限流器支持"只统计"与"拦截"两种模式,由受控系统配置 `polling.rate_limit_mode`(`observe` / `enforce`,默认 `observe`)全局控制,不按账户覆盖。 +- 只统计:照常计数(各账户实际 QPS、本应被限流的次数),不拦截、不等待。 +- 拦截:按 4 节执行。 +- 上线顺序:change 1 先以只统计模式全量上线,采集至少一周各账户实际调用量与失败分类;按数据配好全部账户的限流组后,整体切到拦截模式。避免上线瞬间所有未配置的账户按默认 QPS 被拦截,导致整体轮询变慢。 + +--- + +## 第六部分:与老系统对比 + +老系统:源码 `/home/zoujin/works/golang/kg/qc/kyhl-src`(Java + Spring `@Scheduled`,定时任务在 `apps/kyhl-monitor`,公共逻辑在 `shared-b`),2026-10-22 按源码复核。 + +### 1. 老系统轮询逻辑摘要 + +- 调度:每个上游平台类型(`category`)一个定时任务 `findCardFlow1(category, threadCount)`(`CardLifeJob`),频率与线程数写死,例如移动(3)每秒 4 线程、普天联通(9)每秒 8 线程、电信(1)每 10 分钟;Spring 调度线程池 16,同一任务不重叠执行。 +- 高频选卡:只取到期日不早于今天的套餐记录(`CardLifeYueViewComp.findCardFlow1`),按 `update_date_time_new` 升序每轮取 2000 条,排除不轮询卡与批量停机名单中的卡;处理后更新时间,形成轮转。每轮从数据库重新选卡,无中间状态。 +- 批量查询:联通 CMP、广电、领网、广电 IoT 先按账户分组批量查询流量与状态,其他平台逐张查询;只有广电 IoT 批次间 sleep 6 秒(`GuangDianIotBatchUtil.java:131`)。 +- 每张卡一个函数串行完成(`CardFazhiService.shezhiFazhiTingfuji`):查询(有批量结果则复用)→ 记录日流量 → 计算剩余流量 → 判断停机(剩余低于阈值、电信 IMEI 不在库、临近到期、代理流量池超额)→ 停机前尝试续费 → 当场调上游停机并写停复机记录。 +- 临近到期:最新生效套餐距到期天数 ≤ 上游账户配置的提前停机天数,且无下月套餐时,判定"接近过期时间停机";停机前先尝试自动续费,成功则不停机(已停机则复机)。综合套餐月套餐流量不足时先尝试续充。 +- 到期停机:`CardJob.stopCard` 每 2 分钟处理当天到期的套餐,停机前复查是否确已过期、有无次月套餐。 +- 全量巡检(无套餐兜底):`FindTingjiViewComp.findTingji` 每秒执行,按 `loop_time_3` 轮转每次取 5000 张卡、4 线程(排除串卡、不轮询卡、批量停机名单)。有未过期套餐的卡不调上游,只更新轮转时间;无未过期套餐的卡查上游状态,开机则写入停机队列 `tbl_tingji`,不当场停机;查询失败跳过,等下次轮转。 +- 停机队列:`TingjiViewComp.tingji` 每 10 秒取 `tbl_tingji` 中待处理(1)与失败(2)的记录最多 1 万条、8 线程执行。执行前复查(有次月套餐、未到提前停机天数则不停)→ 停机前尝试自动续费 → 停机;上游返回"已存在""当前用户不允许办理""防范实名""参数缺失""密码错误"时结束任务、不再重试,但停复机记录仍记为失败,即"结束"不等于停机成功;电信卡停机或断网任一成功即结束任务;其余失败记为 2,每 10 秒无限重试,无退避。 +- 批量停机名单 `tbl_batch_stop_card`:排除名单,名单中的卡不参与轮询与巡检。 +- 不轮询名单 `tbl_no_loop_card`:名单中的卡不查上游(高频轮询、全量巡检、移动流量选卡均排除),阈值停复机判断直接跳过;到期停机 `CardJob.stopCard` 不排除,套餐到期照常停机;客户充值支付成功先移出名单再做停复机判断(`WeixinPayComp.java:3021`),后台充值套餐、改有效期后有有效套餐也移出名单。超级管理员通过"不轮询网卡"页面列表筛选、Excel 导入批量加入或移出、单条删除、导出(`NoLoopCardViewComponent`),导卡时可直接加入。 +- 退款:只处理订单状态、备用金扣回与佣金冲回,不停卡、不作废套餐记录,卡用到套餐到期后由到期停机处理。 +- 限流:每个平台的 HTTP 工具类内一个静态 Guava `RateLimiter`,查询与停复机共用,数值写死(电信 10、普天联通 16、CMP5G 8、领网 5、联通 Jasper 2、瑞信 2、新联通 1、中原 1),单进程内生效,按平台而非账户;广电、广电 IoT、广电网络、甘肃、移动 OneLink、岱川移动无限流。 + +### 2. 对比 + +| 对比项 | 老系统 | 本系统现状 | 本系统新方案 | +|---|---|---|---| +| 队列划分维度 | 按上游平台各一个定时任务 | 16 固定分片 × 5 类型 | 运营商账户 × (停复机 / 优先 / 普通) | +| 选卡方式 | 按最后处理时间轮转,每轮 2000 张 | 每卡每类型到期时间 | 每卡每类型到期时间,按卡汇总最早到期 | +| 轮询范围 | 高频只覆盖有未过期套餐的卡;全量巡检对无套餐卡只查状态、开机即停;到期即停 | 开启轮询开关的卡全部按配置轮询,配置只区分网络与实名状态 | 按套餐状态、资产状态分层;无套餐与库存层必配状态检查;到期即停 | +| 检查类型 | 一个函数内完成全部 | 实名、流量、卡状态、保护期、套餐 | 实名、流量、卡状态、套餐 | +| 同卡多类型 | 串行 | 可并行 | 串行,顺序可配置 | +| 批量查询 | 部分平台按账户批量 | 无 | 暂无,预留外部结果入口,gateway 支持后增加 | +| 限流维度 | 平台,QPS,写死,单进程;广电、移动 OneLink 等平台无限流 | 全局 + 任务类型,并发数,无依据 | 运营商账户 → 限流组 → 操作,QPS,可配置,全局生效 | +| 限流覆盖 | 该平台全部请求 | 仅轮询任务 | 所有自动触发的检查与停复机;后台人工点击、通道阈值、自动续费、手动刷新不限流 | +| 停复机触发 | 高频轮询在卡处理函数内当场停机;全量巡检写停机队列 `tbl_tingji`,由独立任务复查后执行 | 轮询任务内同步 + Outbox 消费者同步 | 生成指令进运营商账户停复机队列 | +| 停复机优先级 | 无 | 无 | 最高:先出队,共用组预留 QPS | +| 停复机失败重试 | 停机队列失败记录每 10 秒无限重试,无退避;上游返回"已存在""密码错误"等时结束任务但记录仍为失败 | 同步 3 次,每次 sleep 2 秒 | 放回队列退避重试 | +| 临近到期 | 按账户提前停机天数提前停机,停机前尝试续费 | 无提前停机;每日 07:00 独立扫描提前续费 | 不变(业务规则不在本方案范围) | +| 保护期 | 无 | 有,1 小时 | 无,规则优先 | +| 崩溃恢复 | 每轮从库重选,天然恢复 | 无(出队即删除) | 卡不出队,处理中标记 TTL 到期后自动重新领取 | +| 持续失败 | 无专门处理 | 30 秒兜底重入队,无告警 | 退避重试 + 失败告警 + 账户级汇总 + 运营处理 | +| 可配置性 | 全部写死 | 间隔与类型并发可配置 | 限流、调度、步骤、重试均可配置 | +| 监控维度 | 日志 + 停复机记录 | 任务类型 | 运营商账户 + 任务类型 | + +### 3. 评分(满分 100,按权重加权;新方案为设计分) + +| 维度(权重) | 老系统 | 现状 | 新方案 | +|---|---|---|---| +| 上游限流是否准确(20) | 5 | 2 | 7 | +| 账户/平台隔离(10) | 7 | 2 | 8 | +| 调用效率(10) | 8 | 3 | 6 | +| 可配置性(10) | 2 | 6 | 9 | +| 可靠性与崩溃恢复(15) | 7 | 3 | 8 | +| 调度时效(10) | 5 | 7 | 8 | +| 并发一致性(5) | 8 | 3 | 10 | +| 停复机处理(10) | 6 | 4 | 10 | +| 可观测性(5) | 5 | 8 | 8 | +| 可维护性(5) | 2 | 6 | 6 | +| **总分** | **56** | **39** | **79** | + +新方案得分说明:并发一致性含设备维度判断加锁;停复机处理含设备下各卡一致与到期即停、无套餐兜底;调用效率含分层轮询;可维护性因新增 NSQ 组件与设备判断链路扣分。 +新方案扣分点:调用效率(无批量查询,gateway 支持后可补,预计 +4);后台人工点击、通道阈值、自动续费、手动刷新的请求不计入额度(扩展到所有入口预计 +2)。 + +--- + +## 第七部分:待确认与后续事项 + +已定(2026-10-22): +- 套餐激活、流量重置、订单触发的复机改走停复机指令队列(第三部分第 6 节)。 +- 无套餐期间的流量归属:套餐激活前做一次流量结算观测(第三部分第 11.9 节)。 +- Outbox 三个卡观测消费者触发的停复机同样走停复机指令队列,自动停复机全部经同一出口(第三部分第 6 节)。 +- 重置日前加密查流量:按运营商账户重置日,在配置的窗口内缩短流量检查间隔(第三部分第 11.10 节)。 +- 运营商账户实名管控修改后主动对受影响的卡批量请求评估;卡或设备恢复轮询沿用现有生命周期重新入队,不另加触发(第三部分第 6 节)。 +- 退款后资产处理不再调用 `ManualStopCard`,改为请求评估,停机走停复机指令队列;"不进队列"的人工入口只保留后台人工点击的停复机(第三部分第 6 节)。 +- 业务事件不再起观测序列,改为把卡的对应检查设为立即到期并放进优先队列,统一走卡任务;需要延迟确认的按场景配置追加一次延迟检查(第三部分第 5.1 节)。 +- 读入口(C 端资产详情、后台资产实时状态、实名链接)同样改为请求检查走卡任务(OpenAPI 查询不触发,2026-10-24 更正),受运营商账户限流约束(第三部分第 5.1 节)。 +- 失败原因与调用量记录放入第 1 个 change:gateway 结构化错误;停复机写单独的停复机记录表 `tb_card_stop_resume_record`(按月分区),不再写统一审计与集成日志;查询失败不建表、不逐条落库,Redis 计数 + 卡上最近错误 + 持续失败进告警表(第三部分第 6.2 节)。 +- 上游表示已是目标状态(已停机、已开机等)视为成功,结束停复机指令,不计入失败(第三部分第 6.2 节,参考老系统)。 +- 上游频率限制、终态不可操作、临时故障分类处理;停复机指令增加按运营商账户配置的单卡最小重试间隔(第三部分第 6、6.2、7.2 节)。 +- 限流覆盖所有自动触发的检查与停复机(含激活前流量结算);只有后台人工点击的停复机、通道阈值、自动续费、手动刷新不限流(第三部分第 4 节)。 +- ~~关闭轮询即人工接管,不再自动停复机~~(已被 2026-10-23 的不轮询口径取代)。 + +已定(2026-10-23): +- 暂停轮询沿用老系统不轮询名单口径:不查上游、不做由检查与周期评估触发的自动停复机;套餐到期、退款后无套餐照常停机;客户购买套餐支付成功自动恢复轮询并按规则复机,`gateway_extend` 为风险停机或已销户的卡不恢复;不拆分为两个开关(第三部分第 12 节)。 +- 暂停轮询的卡套餐到期、退款后的停机只触发一次,不加补偿扫描;有套餐的卡关闭轮询由运营自行负责(第三部分第 12 节)。 +- 人工停机不享有豁免:轮询开启的卡人工停机后,满足复机条件即由套餐步骤自动复机;需保持停机的须同时关闭轮询(第三部分第 12 节)。 +- 单卡最小重试间隔按卡计算,停机与复机合计(第三部分第 2、3.2、6 节,7.2 节)。 +- 卡任务按预计网关调用数领取,避免超领(第三部分第 3.2 节)。 +- 限流组停用后回落为默认 QPS,不存在不限流的自动调用(第三部分第 4 节、7.1 节)。 +- ~~切换期间所有入口按卡所属运营商账户分流,切入、切回账户时清理旧或新结构~~(已被 2026-10-24"不灰度、整体切换"取代,见第三部分第 9 节)。 +- 激活前流量结算设总超时(第三部分第 11.9 节)。 +- 停复机记录表建 DEFAULT 分区并提前建未来分区,记录写入与状态回写分开(第三部分第 6.2 节)。 +- 配置存储:账户调度参数、错误分类、业务事件场景各一张新表;步骤与失败告警标量参数放 `tb_system_config`(第三部分第 7 节)。 +- 批量开关轮询是本方案主体之一:卡、设备勾选批量接口(单次 500)与 ICCID 文件导入任务;批量开启跳过风险停机、已销户的卡并返回原因,不提供强制参数(第三部分第 13.1 节)。 +- 新建不轮询清单表 `tb_card_polling_pause` 记录关闭来源、操作人与时间,调度仍只认 `enable_polling`;所有开关入口同事务写入;恢复时标记已恢复并保留历史(第三部分第 13.2 节)。 +- 停机被拒告警单独成列表,可导出、可勾选批量关闭轮询(第三部分第 13.3 节)。 +- 自动恢复轮询的入口对齐老系统:订单套餐生效(支付成功、后台下单、批量下单、OpenAPI 下单)、后台改有效期后有生效套餐;风险停机、已销户的卡除外(第三部分第 12 节)。 +- 数据库只加不删:`tb_polling_concurrency_config`、`tb_polling_config.protect_check_interval`、`tb_iot_card.last_protect_check_at` 保留不删,代码中去掉读写,注释标记已废弃(第四部分)。 + +已定(2026-10-24,方案评审): +- 停复机指令带版本号,执行期间被覆盖的新指令在收尾时保留,不被删除(第三部分第 2、3.3、6 节)。 +- 激活前流量结算的观测不触发停复机评估,激活提交后只评估一次,避免付费后先停机再被单卡最小重试间隔挡住复机(第三部分第 11.9 节)。 +- 处理中标记在执行期间续期,卡任务设总超时;增加执行权 key,防止 NSQ 重投时同一 token 被并行执行(第三部分第 2、3.3 节)。 +- 停复机领取增加执行上限 `sr_inflight_limit`;执行上限不得大于各订阅进程 `max_in_flight` 之和(第三部分第 3.2、7.2 节)。 +- 第 1 个 change 在 Asynq handler 内的限流调用不等待,拿不到名额立即短延迟重入队,避免占满共用协程池;等待模式只在 NSQ 消费者中启用(第三部分第 4 节、第五部分第 3 节)。 +- 限流改为默认限流、显式豁免,只有后台人工点击、通道阈值、自动续费、手动刷新打豁免标记(第三部分第 4 节)。 +- 优先队列设最大占比 `priority_max_ratio`;请求检查按场景配置冷却时长,读入口应配置(第三部分第 3.2、5.1、7.2、7.6 节)。 +- 分发器按账户加分发锁,同一账户每轮只由一个实例领取(第三部分第 2、3.2 节)。 +- ~~跨账户设备:设备下所有卡所属账户均已切换才走新的设备维度停复机路径,否则整机走旧路径~~(已被同日"停复机出口全局开关 `stop_resume_queue_enabled`"取代,见下方与第三部分第 9 节)。 +- 每个 change 必须包含 Lua 脚本的真实 Redis 并发测试(第五部分第 1 节)。 +- NSQ 保留,作为卡轮询的执行分发组件。 +- 限流按每次实际 HTTP 请求扣额度,挂在 `AttemptObserver.BeforeAttempt`,gateway 内部重试同样计入(第三部分第 4 节)。 +- 停复机不使用 gateway 内部重试(`maxRetries = 0`),超时按结果未知、下次先查状态(第三部分第 6 节)。 +- 流量入账按观测时间归属:观测时间早于套餐激活时间的用量只记已结算;无生效套餐到有生效套餐的激活入口统一资产级激活锁,结算在锁内执行(第三部分第 11.9 节)。 +- 只有上游明确返回已是目标状态才视为停复机成功;老系统"结束停机任务"的关键字不作为依据,"密码错误""参数缺失"归为账户配置错误并立即账户级告警(第三部分第 6.2 节)。 +- 停复机结果未知不沿用现有恢复扫描(现有普通停复机只有人工核对策略),在指令队列内按"确认延迟 → 查状态 → 已是目标状态结束 / 否则按单卡最小重试间隔重发"处理(第三部分第 6 节第 5 条)。 +- 到期即停不走套餐步骤,以"套餐到期"场景直接请求评估;设备判断队列保存并按并集合并请求场景,避免暂停轮询的卡或设备漏停(第三部分第 6.1、11.5 节)。 +- ~~跨账户设备:卡任务按账户灰度,停复机出口改为全局开关~~(已被同日"不灰度"取代)。 +- 不灰度(2026-10-24 已定):不按运营商账户、设备或卡灰度,每个 change 对全部账户整体切换;只保留 `polling.rate_limit_mode`、`polling.scheduler_mode`、`polling.stop_resume_queue_enabled` 三个全局开关用于整体回退;取消账户调度参数中的 `polling_enabled` 与 `rate_limit_mode`;跨账户设备(约 58%)仍为核心验收场景(第三部分第 9 节)。 +- 分层配置的间隔区分三态:正数按间隔、`0` 不检查、空继承;保持空 = 继承不改变现有配置行为。优先级可后台调整(单条、整体排序),相同优先级按 ID 排序;提供匹配预览与"立即重算到期时间"(第三部分第 11.3 节)。 +- 所有后台配置、告警、不轮询清单、停复机记录都提供查询与修改接口,供前端建页面;标量参数复用 `/system-configs`(新增 `polling` 模块与 JSON 结构校验);停复机出口全局开关从 yaml 改为受控系统配置(第三部分第 14 节)。 +- 复机不看停机原因,包括运营商侧主动停机 `carrier_stopped`,只由本系统规则决定(第三部分第 12 节)。 +- 风险停机、已销户的卡必须关闭轮询:保留现有查状态自动关闭;补齐绑定设备的卡(只关该卡自身)与停复机调用直接返回终态两种情况(第三部分第 12 节、6.2 节)。 +- 停复机新规则在生产开启前必须完成实名管控配置与上线前演练(只判断不执行的演练接口),生产约 1.5 万张未实名停机卡可能满足复机条件(第三部分第 12 节、第五部分第 3 节)。 +- Asynq 任务(Outbox 消费者、观测序列)遇到限流不计入重试次数、按固定短延迟重试(Asynq `IsFailure` 与 `RetryDelayFunc`),避免"流量用完停机"在高峰期被推迟数小时或归档丢失(第五部分第 3 节)。 +- change 1 先以"只统计"模式全量上线,按数据配置后逐个账户切到"拦截"(第五部分第 3 节、7.2 节)。 +- 去掉保护期时 `device_protect_status` 字段保留、固定返回无保护期并标记废弃,不直接删除(第四部分)。 +- OpenSpec 拆分(2026-10-24):7 个 change 对应 `openspec/changes/` 下 `add-carrier-gateway-rate-limit`、`add-tiered-polling-and-pre-activation-settlement`、`remove-device-protect-period`、`finalize-unified-stop-resume-rule`、`add-carrier-polling-scheduler`、`add-stop-resume-command-queue`、`add-polling-pause-list-and-batch-toggle`;拆分时更正:OpenAPI 查询不触发观测、不属于读入口;change 4 分两次发布(先演练与补齐、开启总开关,稳定后删除总开关);失败告警表在 change 5 建立,change 7 只做停机被拒视图与告警页批量关闭;测试按项目规则在测试环境逐项核对,不写自动化测试代码。 +- 实施依赖修正:到期即停移到 change 6;change 4 依赖 `Iteration/8-11` 上线生产;激活前结算依赖日快照计量在生产验证;change 6 依赖 change 4(第五部分第 3 节)。 + +待确认:无。 + +后续事项(不在本方案范围): +1. 批量查询:gateway 支持后,在分发器按运营商账户凑批、查询结果经 `StepInput.Result` 传入卡任务。 +2. 限流覆盖剩余入口(后台人工点击、通道阈值、自动续费、手动刷新):去掉这 4 个入口的"豁免限流"标记即可。 +3. 手动刷新后不再立即重新轮询全部类型,改为顺延已刷新类型的 `due`。 +4. 停复机业务规则与老系统的差异(提前停机天数、停机前续费、流量超额续充)单独讨论。 diff --git a/internal/bootstrap/dependencies.go b/internal/bootstrap/dependencies.go index d04f9214..c6931532 100644 --- a/internal/bootstrap/dependencies.go +++ b/internal/bootstrap/dependencies.go @@ -3,6 +3,7 @@ package bootstrap import ( systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" "github.com/break/junhong_cmp_fiber/internal/service/verification" "github.com/break/junhong_cmp_fiber/pkg/auth" @@ -26,6 +27,7 @@ type Dependencies struct { QueueClient *queue.Client // Asynq 任务队列客户端 StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil) GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil) + GatewayLimit *gatewaylimit.Runtime // 卡网关运营商账户限流组件(可选) WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选) SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选) SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port;生产为空时装配统一 Writer diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 9456d29d..82aef063 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -21,6 +21,7 @@ import ( approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval" auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" @@ -120,6 +121,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { } registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger) registerPaymentMethodConfigDefinitions(systemConfigRegistry, deps.Logger) + gatewaylimit.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger) systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger) var systemConfigCache systemConfigInfra.Cache if deps.Redis != nil { @@ -329,6 +331,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { ), WeComApprovalCallback: wecomApprovalCallback, 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), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 40cb7ee6..a97ac9be 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -263,6 +263,9 @@ func initServices(s *stores, deps *Dependencies) *services { stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents) stopResumeService.SetPriorityEventWriter(priorityEvents) stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB)) + if deps.GatewayLimit != nil { + stopResumeService.SetStopResumeRecorder(deps.GatewayLimit.Recorder) + } // 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。 stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService) iotCard.SetRealnameActivator(packageActivation) @@ -293,6 +296,9 @@ func initServices(s *stores, deps *Dependencies) *services { device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB)) device.SetObservationSeriesEventWriter(observationSeriesEvents) device.SetObservationSeriesDispatcher(observationSeries) + if deps.GatewayLimit != nil { + device.SetStopResumeRecorder(deps.GatewayLimit.Recorder) + } operationPassword := operationPasswordSvc.New(deps.Redis) shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger) shopCommission.SetAuditWriter(auditWriter) diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 0312c073..0ddd4aaa 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -64,6 +64,7 @@ type Handlers struct { CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler WeComApprovalCallback *callback.WeComApprovalHandler PollingConfig *admin.PollingConfigHandler + GatewayRateLimit *admin.GatewayRateLimitHandler PollingConcurrency *admin.PollingConcurrencyHandler PollingMonitoring *admin.PollingMonitoringHandler PollingAlert *admin.PollingAlertHandler diff --git a/internal/bootstrap/worker.go b/internal/bootstrap/worker.go index ac49f0f4..3c59f13f 100644 --- a/internal/bootstrap/worker.go +++ b/internal/bootstrap/worker.go @@ -7,6 +7,7 @@ import ( "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" "github.com/break/junhong_cmp_fiber/pkg/queue" "github.com/break/junhong_cmp_fiber/pkg/storage" ) @@ -16,10 +17,11 @@ type WorkerDependencies struct { DB *gorm.DB Redis *redis.Client Logger *zap.Logger - AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务 - QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务 - StorageService *storage.Service // 对象存储(可选) - GatewayClient *gateway.Client // Gateway 客户端(可选) + AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务 + QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务 + StorageService *storage.Service // 对象存储(可选) + GatewayClient *gateway.Client // Gateway 客户端(可选) + GatewayLimit *gatewaylimit.Runtime // 卡网关运营商账户限流组件(可选) } // WorkerBootstrapResult Worker Bootstrap 初始化结果 diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 19b43ea1..337eb0ff 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -187,6 +187,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents) stopResumeService.SetPriorityEventWriter(priorityEvents) stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB)) + if deps.GatewayLimit != nil { + stopResumeService.SetStopResumeRecorder(deps.GatewayLimit.Recorder) + } // 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。 stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService) // 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。 diff --git a/internal/gateway/call_guard.go b/internal/gateway/call_guard.go new file mode 100644 index 00000000..014e8d41 --- /dev/null +++ b/internal/gateway/call_guard.go @@ -0,0 +1,142 @@ +package gateway + +import ( + "context" + stderrors "errors" + "time" + "unicode/utf8" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// GatewayError 是网关调用失败的结构化错误。 +// 它包裹原有的 *errors.AppError:Error() 文本与错误码保持不变,调用方仍可 errors.As 取 AppError; +// 需要类别、上游业务码、上游信息与耗时的调用方用 errors.As 取 *GatewayError。 +type GatewayError struct { + Category string // 错误类别,见 constants.GatewayError* + ProviderCode int // 网关业务码(仅上游业务错误时非 0) + ProviderMessage string // 网关返回信息(已截断,仅上游业务错误时非空) + HTTPStatus int // HTTP 状态码(仅 HTTP 异常时非 0) + Duration time.Duration // 本次 HTTP 尝试耗时 + app *errors.AppError +} + +// Error 返回与原 AppError 一致的文本。 +func (e *GatewayError) Error() string { + if e == nil || e.app == nil { + return "Gateway 调用失败" + } + return e.app.Error() +} + +// Unwrap 返回原 AppError,保持错误码判断兼容。 +func (e *GatewayError) Unwrap() error { + if e == nil || e.app == nil { + return nil + } + return e.app +} + +func newGatewayError(category string, app *errors.AppError) *GatewayError { + return &GatewayError{Category: category, app: app} +} + +// AsGatewayError 从错误链中取出结构化网关错误。 +func AsGatewayError(err error) (*GatewayError, bool) { + var gwErr *GatewayError + if stderrors.As(err, &gwErr) && gwErr != nil { + return gwErr, true + } + return nil, false +} + +// IsRateLimited 判断错误是否为本系统运营商账户限流拒绝(未发出请求)。 +func IsRateLimited(err error) bool { + gwErr, ok := AsGatewayError(err) + return ok && gwErr.Category == constants.GatewayErrorRateLimited +} + +// NewRateLimitedError 创建限流拒绝错误;由限流守卫在拿不到名额时返回。 +func NewRateLimitedError() error { + return newGatewayError(constants.GatewayErrorRateLimited, + errors.New(errors.CodeTooManyRequests, "运营商账户网关调用已达限流上限,请稍后重试")) +} + +// truncateProviderMessage 按字符截断上游信息,避免超长内容落库或展示。 +func truncateProviderMessage(msg string) string { + if utf8.RuneCountInString(msg) <= constants.GatewayProviderMessageMaxLen { + return msg + } + runes := []rune(msg) + return string(runes[:constants.GatewayProviderMessageMaxLen]) +} + +// CallInfo 描述一次卡网关 HTTP 尝试,供调用守卫限流与统计。 +type CallInfo struct { + Operation string // 卡网关操作,见 constants.GatewayOp* + CardNo string // 卡号(ICCID) + CarrierID uint // 调用方已知的运营商账户 ID(本系统 tb_carrier.id),0 表示由守卫按卡号解析 + ExemptSource string // 豁免限流来源,空表示受限流约束 + Attempt int // 本次方法调用内的尝试序号,从 1 开始 +} + +// CallGuard 是卡网关调用守卫:每次 HTTP 尝试前取限流名额,尝试结束后记录统计。 +// BeforeAttempt 返回错误时本次方法调用立即结束,不再发起后续尝试。 +type CallGuard interface { + BeforeAttempt(ctx context.Context, call CallInfo) error + AfterAttempt(ctx context.Context, call CallInfo, callErr error, duration time.Duration) +} + +type cardCallKey struct{} +type carrierKey struct{} +type exemptKey struct{} + +type cardCall struct { + operation string + cardNo string +} + +// WithCarrier 声明本次调用目标卡所属的运营商账户(本系统 tb_carrier.id),避免守卫按卡号再查一次。 +func WithCarrier(ctx context.Context, carrierID uint) context.Context { + if carrierID == 0 { + return ctx + } + return context.WithValue(ctx, carrierKey{}, carrierID) +} + +// WithRateLimitExempt 声明本次调用豁免运营商账户限流,只允许后台人工点击、通道阈值、自动续费、手动刷新使用。 +func WithRateLimitExempt(ctx context.Context, source string) context.Context { + if source == "" { + return ctx + } + return context.WithValue(ctx, exemptKey{}, source) +} + +// RateLimitExemptSource 返回 ctx 中声明的豁免来源,未声明返回空串。 +func RateLimitExemptSource(ctx context.Context) string { + source, _ := ctx.Value(exemptKey{}).(string) + return source +} + +func withCardCall(ctx context.Context, operation, cardNo string) context.Context { + return context.WithValue(ctx, cardCallKey{}, cardCall{operation: operation, cardNo: cardNo}) +} + +// cardCallInfo 从 ctx 取出本次卡网关调用信息;非卡操作返回 false。 +func cardCallInfo(ctx context.Context) (CallInfo, bool) { + call, ok := ctx.Value(cardCallKey{}).(cardCall) + if !ok { + return CallInfo{}, false + } + carrierID, _ := ctx.Value(carrierKey{}).(uint) + return CallInfo{ + Operation: call.operation, CardNo: call.cardNo, CarrierID: carrierID, + ExemptSource: RateLimitExemptSource(ctx), + }, true +} + +// SetCallGuard 设置卡网关调用守卫;为 nil 时不限流、不统计。 +func (c *Client) SetCallGuard(guard CallGuard) { + c.guard = guard +} diff --git a/internal/gateway/client.go b/internal/gateway/client.go index 26fa338d..a7a8324b 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -11,6 +11,7 @@ import ( "net/http" "time" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/bytedance/sonic" "go.uber.org/zap" @@ -36,6 +37,7 @@ type Client struct { timeout time.Duration logger *zap.Logger maxRetries int + guard CallGuard // 卡网关调用守卫(限流与统计),为 nil 时不生效 } type attemptObserverKey struct{} @@ -138,6 +140,7 @@ func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData strin // 带重试的 HTTP 请求 var lastErr error observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver) + call, isCardCall := cardCallInfo(ctx) for attempt := 0; attempt <= c.maxRetries; attempt++ { if attempt > 0 { // 检查用户 Context 是否已取消 @@ -158,12 +161,30 @@ func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData strin } attemptNumber := attempt + 1 + // 卡网关操作每次 HTTP 尝试前各取一次限流名额,内部重试同样计量;拿不到名额时本次调用立即结束。 + if isCardCall && c.guard != nil { + call.Attempt = attemptNumber + if err := c.guard.BeforeAttempt(ctx, call); err != nil { + if attempt > 0 { + c.logger.Warn("Gateway 重试被限流中止", zap.String("path", path), zap.Int("attempt", attemptNumber), zap.NamedError("last_error", lastErr)) + } + return nil, err + } + } if observer != nil { if err := observer.BeforeAttempt(ctx, attemptNumber); err != nil { return nil, err } } + attemptStartedAt := time.Now() result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData) + attemptDuration := time.Since(attemptStartedAt) + if gwErr, ok := AsGatewayError(err); ok { + gwErr.Duration = attemptDuration + } + if isCardCall && c.guard != nil { + c.guard.AfterAttempt(ctx, call, err, attemptDuration) + } if observer != nil { if observeErr := observer.AfterAttempt(ctx, attemptNumber, err); observeErr != nil { return nil, observeErr @@ -238,30 +259,32 @@ func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedD if err != nil { // 用户 Context 已取消 — 不可重试 if ctx.Err() != nil { - return nil, false, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消") + return nil, false, newGatewayError(constants.GatewayErrorNetwork, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消")) } // Client 超时 — 可重试 if reqCtx.Err() == context.DeadlineExceeded { - return nil, true, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时") + return nil, true, newGatewayError(constants.GatewayErrorTimeout, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")) } // 其他网络错误(连接失败、DNS 解析等)— 可重试 - return nil, true, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败") + return nil, true, newGatewayError(constants.GatewayErrorNetwork, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")) } defer resp.Body.Close() // HTTP 状态码错误 — 不可重试 if resp.StatusCode != http.StatusOK { - return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode)) + gwErr := newGatewayError(constants.GatewayErrorHTTP, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode))) + gwErr.HTTPStatus = resp.StatusCode + return nil, false, gwErr } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败") + return nil, false, newGatewayError(constants.GatewayErrorInvalidResponse, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败")) } var gatewayResp GatewayResponse if err := sonic.Unmarshal(body, &gatewayResp); err != nil { - return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败") + return nil, false, newGatewayError(constants.GatewayErrorInvalidResponse, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")) } // Gateway 业务错误 — 不可重试 @@ -271,7 +294,10 @@ func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedD zap.Int("gateway_code", gatewayResp.Code), zap.String("gateway_msg", gatewayResp.Msg), ) - return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg)) + gwErr := newGatewayError(constants.GatewayErrorUpstream, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))) + gwErr.ProviderCode = gatewayResp.Code + gwErr.ProviderMessage = truncateProviderMessage(gatewayResp.Msg) + return nil, false, gwErr } return gatewayResp.Data, false, nil diff --git a/internal/gateway/flow_card.go b/internal/gateway/flow_card.go index 55f5f877..bde51f1e 100644 --- a/internal/gateway/flow_card.go +++ b/internal/gateway/flow_card.go @@ -4,41 +4,74 @@ package gateway import ( "context" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // QueryCardStatus 查询流量卡状态 // POST /flow-card/status func (c *Client) QueryCardStatus(ctx context.Context, req *CardStatusReq) (*CardStatusResp, error) { + ctx = withCardCall(ctx, constants.GatewayOpQueryStatus, cardNoOf(req)) return doRequestWithResponse[CardStatusResp](c, ctx, "/flow-card/status", req) } // QueryFlow 查询流量使用情况 // POST /flow-card/flow func (c *Client) QueryFlow(ctx context.Context, req *FlowQueryReq) (*FlowUsageResp, error) { + ctx = withCardCall(ctx, constants.GatewayOpQueryFlow, flowCardNoOf(req)) return doRequestWithResponse[FlowUsageResp](c, ctx, "/flow-card/flow", req) } // QueryRealnameStatus 查询实名认证状态 // POST /flow-card/realName func (c *Client) QueryRealnameStatus(ctx context.Context, req *CardStatusReq) (*RealnameStatusResp, error) { + ctx = withCardCall(ctx, constants.GatewayOpQueryRealname, cardNoOf(req)) return doRequestWithResponse[RealnameStatusResp](c, ctx, "/flow-card/realName", req) } // StopCard 流量卡停机 // POST /flow-card/cardStop +// 停机是外部状态写入:超时或连接失败时上游可能已受理,内部重试会对同一张卡连发并撞上游单卡频率限制,故只发一次请求。 func (c *Client) StopCard(ctx context.Context, req *CardOperationReq) error { - _, err := c.doRequest(ctx, "/flow-card/cardStop", req) + ctx = withCardCall(ctx, constants.GatewayOpStop, operationCardNoOf(req)) + requestClient := *c + requestClient.maxRetries = 0 + _, err := requestClient.doRequest(ctx, "/flow-card/cardStop", req) return err } // StartCard 流量卡复机 // POST /flow-card/cardStart +// 复机同停机,只发一次请求,不使用内部重试。 func (c *Client) StartCard(ctx context.Context, req *CardOperationReq) error { - _, err := c.doRequest(ctx, "/flow-card/cardStart", req) + ctx = withCardCall(ctx, constants.GatewayOpStart, operationCardNoOf(req)) + requestClient := *c + requestClient.maxRetries = 0 + _, err := requestClient.doRequest(ctx, "/flow-card/cardStart", req) return err } +func cardNoOf(req *CardStatusReq) string { + if req == nil { + return "" + } + return req.CardNo +} + +func flowCardNoOf(req *FlowQueryReq) string { + if req == nil { + return "" + } + return req.CardNo +} + +func operationCardNoOf(req *CardOperationReq) string { + if req == nil { + return "" + } + return req.CardNo +} + // GetRealnameLink 获取实名认证跳转链接 // POST /flow-card/RealNameVerification func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*RealnameLinkResp, error) { diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 9f1d2673..6400ac9c 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -11,6 +11,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/hibiken/asynq" + "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/handler/validation" dto "github.com/break/junhong_cmp_fiber/internal/model/dto" packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" @@ -447,7 +448,7 @@ func (h *AssetHandler) Stop(c *fiber.Ctx) error { switch asset.AssetType { case "device": - result, devErr := h.deviceService.StopDevice(c.UserContext(), asset.AssetID) + result, devErr := h.deviceService.StopDevice(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.AssetID) if devErr != nil { return devErr } @@ -456,7 +457,7 @@ func (h *AssetHandler) Stop(c *fiber.Ctx) error { if asset.ICCID == "" { return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法停机") } - if cardErr := h.iotCardStopResume.ManualStopCard(c.UserContext(), asset.ICCID); cardErr != nil { + if cardErr := h.iotCardStopResume.ManualStopCard(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.ICCID); cardErr != nil { return cardErr } return response.Success(c, nil) @@ -475,7 +476,7 @@ func (h *AssetHandler) Start(c *fiber.Ctx) error { switch asset.AssetType { case "device": - if devErr := h.deviceService.StartDevice(c.UserContext(), asset.AssetID); devErr != nil { + if devErr := h.deviceService.StartDevice(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.AssetID); devErr != nil { return devErr } return response.Success(c, nil) @@ -483,7 +484,7 @@ func (h *AssetHandler) Start(c *fiber.Ctx) error { if asset.ICCID == "" { return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法复机") } - if cardErr := h.iotCardStopResume.ManualStartCard(c.UserContext(), asset.ICCID); cardErr != nil { + if cardErr := h.iotCardStopResume.ManualStartCard(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.ICCID); cardErr != nil { return cardErr } return response.Success(c, nil) diff --git a/internal/handler/admin/gateway_rate_limit.go b/internal/handler/admin/gateway_rate_limit.go new file mode 100644 index 00000000..c9bc9850 --- /dev/null +++ b/internal/handler/admin/gateway_rate_limit.go @@ -0,0 +1,343 @@ +package admin + +import ( + "strconv" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + pollingService "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" +) + +// GatewayRateLimitHandler 运营商账户网关限流、上游错误分类、停复机记录与调用统计 Handler。 +type GatewayRateLimitHandler struct { + service *pollingService.GatewayRateLimitService + redis *redis.Client + validator *validator.Validate +} + +// NewGatewayRateLimitHandler 创建网关限流 Handler。 +func NewGatewayRateLimitHandler(service *pollingService.GatewayRateLimitService, redisClient *redis.Client, validate *validator.Validate) *GatewayRateLimitHandler { + if validate == nil { + validate = validator.New() + } + return &GatewayRateLimitHandler{service: service, redis: redisClient, validator: validate} +} + +func (h *GatewayRateLimitHandler) 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 +} + +func rateLimitPathID(c *fiber.Ctx, name, label string) (uint, error) { + value, err := strconv.ParseUint(c.Params(name), 10, 64) + if err != nil || value == 0 { + return 0, errors.New(errors.CodeInvalidParam, "无效的"+label) + } + return uint(value), nil +} + +// ListGroups 查询网关限流组列表。 +// GET /api/admin/polling-rate-limit-groups +func (h *GatewayRateLimitHandler) ListGroups(c *fiber.Ctx) error { + var req dto.RateLimitGroupListRequest + 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.ListGroups(c.UserContext(), &req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +// GetGroup 查询网关限流组详情。 +// GET /api/admin/polling-rate-limit-groups/:id +func (h *GatewayRateLimitHandler) GetGroup(c *fiber.Ctx) error { + id, err := rateLimitPathID(c, "id", "限流组 ID") + if err != nil { + return err + } + result, err := h.service.GetGroup(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CreateGroup 新建网关限流组。 +// POST /api/admin/polling-rate-limit-groups +func (h *GatewayRateLimitHandler) CreateGroup(c *fiber.Ctx) error { + var req dto.SaveRateLimitGroupRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("网关限流组参数不合法", &req); err != nil { + return err + } + result, err := h.service.CreateGroup(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdateGroup 修改网关限流组。 +// PUT /api/admin/polling-rate-limit-groups/:id +func (h *GatewayRateLimitHandler) UpdateGroup(c *fiber.Ctx) error { + var req dto.SaveRateLimitGroupRequest + 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 + } + result, err := h.service.UpdateGroup(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdateGroupStatus 启停网关限流组。 +// PUT /api/admin/polling-rate-limit-groups/:id/status +func (h *GatewayRateLimitHandler) UpdateGroupStatus(c *fiber.Ctx) error { + var req dto.UpdateRateLimitGroupStatusRequest + 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.UpdateGroupStatus(c.UserContext(), req.ID, *req.Status); err != nil { + return err + } + return response.Success(c, nil) +} + +// DeleteGroup 删除网关限流组。 +// DELETE /api/admin/polling-rate-limit-groups/:id +func (h *GatewayRateLimitHandler) DeleteGroup(c *fiber.Ctx) error { + id, err := rateLimitPathID(c, "id", "限流组 ID") + if err != nil { + return err + } + if err := h.service.DeleteGroup(c.UserContext(), id); err != nil { + return err + } + return response.Success(c, nil) +} + +// ListRules 查询上游错误分类规则列表。 +// GET /api/admin/gateway-error-classifications +func (h *GatewayRateLimitHandler) ListRules(c *fiber.Ctx) error { + var req dto.GatewayErrorRuleListRequest + 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.ListRules(c.UserContext(), &req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +// CreateRule 新建上游错误分类规则。 +// POST /api/admin/gateway-error-classifications +func (h *GatewayRateLimitHandler) CreateRule(c *fiber.Ctx) error { + var req dto.SaveGatewayErrorRuleRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("上游错误分类规则参数不合法", &req); err != nil { + return err + } + result, err := h.service.CreateRule(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdateRule 修改上游错误分类规则。 +// PUT /api/admin/gateway-error-classifications/:id +func (h *GatewayRateLimitHandler) UpdateRule(c *fiber.Ctx) error { + var req dto.SaveGatewayErrorRuleRequest + 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 + } + result, err := h.service.UpdateRule(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdateRuleStatus 启停上游错误分类规则。 +// PUT /api/admin/gateway-error-classifications/:id/status +func (h *GatewayRateLimitHandler) UpdateRuleStatus(c *fiber.Ctx) error { + var req dto.UpdateGatewayErrorRuleStatusRequest + 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.UpdateRuleStatus(c.UserContext(), req.ID, *req.Status); err != nil { + return err + } + return response.Success(c, nil) +} + +// DeleteRule 删除上游错误分类规则。 +// DELETE /api/admin/gateway-error-classifications/:id +func (h *GatewayRateLimitHandler) DeleteRule(c *fiber.Ctx) error { + id, err := rateLimitPathID(c, "id", "规则 ID") + if err != nil { + return err + } + if err := h.service.DeleteRule(c.UserContext(), id); err != nil { + return err + } + return response.Success(c, nil) +} + +// TestRule 用已启用规则(可附带草稿)对一段上游返回信息分类,不写任何数据。 +// POST /api/admin/gateway-error-classifications/test +func (h *GatewayRateLimitHandler) TestRule(c *fiber.Ctx) error { + var req dto.TestGatewayErrorRuleRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("上游错误分类测试参数不合法", &req); err != nil { + return err + } + result, err := h.service.TestRule(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListStopResumeRecords 查询停复机记录。 +// GET /api/admin/card-stop-resume-records +func (h *GatewayRateLimitHandler) ListStopResumeRecords(c *fiber.Ctx) error { + var req dto.StopResumeRecordListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败,时间请使用 RFC3339 格式") + } + if err := h.validate("停复机记录查询参数不合法", &req); err != nil { + return err + } + result, err := h.service.ListStopResumeRecords(c.UserContext(), &req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +// ListCardStopResumeRecords 查询单卡停复机历史。 +// GET /api/admin/iot-cards/:id/stop-resume-records +func (h *GatewayRateLimitHandler) ListCardStopResumeRecords(c *fiber.Ctx) error { + var req dto.CardStopResumeRecordListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败,时间请使用 RFC3339 格式") + } + id, err := rateLimitPathID(c, "id", "IoT 卡 ID") + if err != nil { + return err + } + req.ID = id + if err := h.validate("单卡停复机记录查询参数不合法", &req); err != nil { + return err + } + result, err := h.service.ListCardStopResumeRecords(c.UserContext(), &req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +// CarrierCallStats 按运营商账户汇总最近若干分钟的网关调用。 +// GET /api/admin/polling-stats/carriers +func (h *GatewayRateLimitHandler) CarrierCallStats(c *fiber.Ctx) error { + var req dto.GatewayCallStatsRequest + 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.CarrierCallStats(c.UserContext(), h.redis, &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CarrierCallTrend 查询单个运营商账户的网关调用趋势。 +// GET /api/admin/polling-stats/carriers/:carrier_id/trend +func (h *GatewayRateLimitHandler) CarrierCallTrend(c *fiber.Ctx) error { + var req dto.GatewayCallTrendRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + carrierID, err := strconv.ParseUint(c.Params("carrier_id"), 10, 64) + if err != nil { + return errors.New(errors.CodeInvalidParam, "无效的运营商账户 ID") + } + req.CarrierID = uint(carrierID) + if err := h.validate("网关调用趋势参数不合法", &req); err != nil { + return err + } + result, err := h.service.CarrierCallTrend(c.UserContext(), h.redis, &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// PollingOptions 返回轮询与网关限流相关枚举。 +// GET /api/admin/polling-options +func (h *GatewayRateLimitHandler) PollingOptions(c *fiber.Ctx) error { + result, err := h.service.PollingOptions(c.UserContext()) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/infrastructure/assetautorenewal/commander.go b/internal/infrastructure/assetautorenewal/commander.go index da9d9287..b5750145 100644 --- a/internal/infrastructure/assetautorenewal/commander.go +++ b/internal/infrastructure/assetautorenewal/commander.go @@ -5,7 +5,9 @@ import ( "context" assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" + "github.com/break/junhong_cmp_fiber/internal/gateway" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -35,7 +37,7 @@ func (c *CardCommander) ResumeAssetForAutoRenewal(ctx context.Context, assetType if c == nil || c.service == nil { return assetAutoRenewalApp.ResumeOutcome{}, errCommanderUnavailable() } - outcome, err := c.service.ResumeAssetForAutoRenewal(ctx, assetType, assetID) + outcome, err := c.service.ResumeAssetForAutoRenewal(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptAutoRenewal), assetType, assetID) return assetAutoRenewalApp.ResumeOutcome{ Applied: outcome.Applied, IntegrationID: outcome.IntegrationID, Result: outcome.Result, SafeReason: outcome.SafeReason, @@ -47,7 +49,7 @@ func (c *CardCommander) QueryAutoRenewalResumeState(ctx context.Context, assetTy if c == nil || c.service == nil { return false, false, "", errCommanderUnavailable() } - return c.service.QueryAutoRenewalResumeState(ctx, assetType, assetID) + return c.service.QueryAutoRenewalResumeState(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptAutoRenewal), assetType, assetID) } // errCommanderUnavailable 返回复机执行端口未配置的稳定错误。 diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index b622a3ff..fcdf677b 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -259,6 +259,14 @@ func NewRegistry() *Registry { pollingConfigUpdated := pollingAction(constants.AuditActionPollingConfigUpdated, "更新轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh) pollingConfigDeleted := pollingAction(constants.AuditActionPollingConfigDeleted, "删除轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh) pollingConfigStatusUpdated := pollingAction(constants.AuditActionPollingConfigStatusUpdated, "更新轮询配置状态", constants.AuditResourcePollingConfig, constants.AuditRiskHigh) + rateLimitGroupCreated := pollingAction(constants.AuditActionRateLimitGroupCreated, "创建网关限流组", constants.AuditResourceRateLimitGroup, constants.AuditRiskHigh) + rateLimitGroupUpdated := pollingAction(constants.AuditActionRateLimitGroupUpdated, "更新网关限流组", constants.AuditResourceRateLimitGroup, constants.AuditRiskHigh) + rateLimitGroupStatusUpdated := pollingAction(constants.AuditActionRateLimitGroupStatusUpdated, "更新网关限流组状态", constants.AuditResourceRateLimitGroup, constants.AuditRiskHigh) + rateLimitGroupDeleted := pollingAction(constants.AuditActionRateLimitGroupDeleted, "删除网关限流组", constants.AuditResourceRateLimitGroup, constants.AuditRiskHigh) + gatewayErrorRuleCreated := pollingAction(constants.AuditActionGatewayErrorRuleCreated, "创建上游错误分类规则", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal) + gatewayErrorRuleUpdated := pollingAction(constants.AuditActionGatewayErrorRuleUpdated, "更新上游错误分类规则", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal) + gatewayErrorRuleStatusUpdated := pollingAction(constants.AuditActionGatewayErrorRuleStatusUpdated, "更新上游错误分类规则状态", constants.AuditResourceGatewayErrorRule, 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) pollingAlertRuleCreated := pollingAction(constants.AuditActionPollingAlertRuleCreated, "创建轮询告警规则", constants.AuditResourcePollingAlertRule, constants.AuditRiskNormal) @@ -650,6 +658,14 @@ func NewRegistry() *Registry { constants.AuditActionPollingConfigUpdated: pollingConfigUpdated, constants.AuditActionPollingConfigDeleted: pollingConfigDeleted, constants.AuditActionPollingConfigStatusUpdated: pollingConfigStatusUpdated, + constants.AuditActionRateLimitGroupCreated: rateLimitGroupCreated, + constants.AuditActionRateLimitGroupUpdated: rateLimitGroupUpdated, + constants.AuditActionRateLimitGroupStatusUpdated: rateLimitGroupStatusUpdated, + constants.AuditActionRateLimitGroupDeleted: rateLimitGroupDeleted, + constants.AuditActionGatewayErrorRuleCreated: gatewayErrorRuleCreated, + constants.AuditActionGatewayErrorRuleUpdated: gatewayErrorRuleUpdated, + constants.AuditActionGatewayErrorRuleStatusUpdated: gatewayErrorRuleStatusUpdated, + constants.AuditActionGatewayErrorRuleDeleted: gatewayErrorRuleDeleted, constants.AuditActionPollingConcurrencyUpdated: pollingConcurrencyUpdated, constants.AuditActionPollingConcurrencyReset: pollingConcurrencyReset, constants.AuditActionPollingAlertRuleCreated: pollingAlertRuleCreated, @@ -930,6 +946,14 @@ func NewRegistry() *Registry { Type: constants.AuditResourcePollingConcurrencyConfig, Name: "轮询并发配置", IdentityFields: []string{"id", "task_type", "max_concurrency"}, }, + constants.AuditResourceRateLimitGroup: { + Type: constants.AuditResourceRateLimitGroup, Name: "网关限流组", + IdentityFields: []string{"id", "carrier_id", "group_code", "group_name", "max_qps", "reserved_qps", "status"}, + }, + constants.AuditResourceGatewayErrorRule: { + Type: constants.AuditResourceGatewayErrorRule, Name: "上游错误分类规则", + IdentityFields: []string{"id", "keyword", "match_type", "category", "priority", "status"}, + }, constants.AuditResourcePollingAlertRule: { Type: constants.AuditResourcePollingAlertRule, Name: "轮询告警规则", IdentityFields: []string{"id", "rule_name", "task_type", "metric_type", "operator", "threshold", "alert_level", "status"}, diff --git a/internal/infrastructure/carrierthreshold/commander.go b/internal/infrastructure/carrierthreshold/commander.go index 9f87ca22..5c4a0f22 100644 --- a/internal/infrastructure/carrierthreshold/commander.go +++ b/internal/infrastructure/carrierthreshold/commander.go @@ -9,7 +9,9 @@ import ( carrierthresholdapp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold" carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold" + "github.com/break/junhong_cmp_fiber/internal/gateway" iotcardsvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -34,7 +36,7 @@ func (c *CardCommander) StopCardForThreshold(ctx context.Context, cardID uint) ( if c == nil || c.service == nil { return carrierthresholddomain.CommandOutcome{}, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停机执行能力未配置") } - return c.service.StopCardForChannelThreshold(ctx, cardID) + return c.service.StopCardForChannelThreshold(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptChannelThreshold), cardID) } // ResumeCardForThreshold 执行通道阈值新周期复机。 @@ -42,7 +44,7 @@ func (c *CardCommander) ResumeCardForThreshold(ctx context.Context, cardID uint) if c == nil || c.service == nil { return carrierthresholddomain.CommandOutcome{}, errors.New(errors.CodeServiceUnavailable, "通道流量阈值复机执行能力未配置") } - return c.service.ResumeCardForChannelThreshold(ctx, cardID) + return c.service.ResumeCardForChannelThreshold(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptChannelThreshold), cardID) } // ResumeReady 判断解锁后的卡是否满足自动复机条件。 @@ -58,7 +60,7 @@ func (c *CardCommander) CardNetworkStatus(ctx context.Context, cardID uint) (int if c == nil || c.service == nil { return 0, false, "", errors.New(errors.CodeServiceUnavailable, "通道流量阈值状态查询能力未配置") } - return c.service.QueryChannelThresholdCardStatus(ctx, cardID) + return c.service.QueryChannelThresholdCardStatus(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptChannelThreshold), cardID) } // ConfirmCardState 按已确认的运营商结果补写卡状态。 diff --git a/internal/infrastructure/gatewaylimit/classifier.go b/internal/infrastructure/gatewaylimit/classifier.go new file mode 100644 index 00000000..086037cd --- /dev/null +++ b/internal/infrastructure/gatewaylimit/classifier.go @@ -0,0 +1,139 @@ +package gatewaylimit + +import ( + "context" + "regexp" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// ClassificationLoader 读取全部启用的错误分类规则(按优先级排序)。 +type ClassificationLoader interface { + ListEnabled(ctx context.Context) ([]model.GatewayErrorClassification, error) +} + +// Classification 是一次上游错误的分类结果。 +type Classification struct { + Category string // 见 constants.GatewayClass* + RuleID uint // 命中的规则 ID,未命中为 0 + Keyword string // 命中的关键字 + MaybeAccepted bool // 上游可能已受理 +} + +type compiledRule struct { + row model.GatewayErrorClassification + ops map[string]bool + regex *regexp.Regexp +} + +// Classifier 按后台维护的关键字规则对上游错误分类;规则在进程内缓存,超过 TTL 重新加载。 +type Classifier struct { + loader ClassificationLoader + logger *zap.Logger + mu sync.Mutex + rules []compiledRule + loaded time.Time +} + +// NewClassifier 创建错误分类器;loader 为 nil 时一律归为业务错误。 +func NewClassifier(loader ClassificationLoader, logger *zap.Logger) *Classifier { + if logger == nil { + logger = zap.NewNop() + } + return &Classifier{loader: loader, logger: logger} +} + +// Invalidate 使规则缓存立即失效,后台保存规则后调用。 +func (c *Classifier) Invalidate() { + if c == nil { + return + } + c.mu.Lock() + c.loaded = time.Time{} + c.mu.Unlock() +} + +// Classify 对某操作的上游返回信息分类;未命中任何启用规则时归为业务错误。 +func (c *Classifier) Classify(ctx context.Context, op, message string) Classification { + if c == nil { + return Classification{Category: constants.GatewayClassBusiness} + } + return MatchRules(c.current(ctx), op, message) +} + +// ClassifyWith 用给定规则列表分类,供测试接口在不写数据的前提下验证规则。 +func ClassifyWith(rows []model.GatewayErrorClassification, op, message string) Classification { + return MatchRules(compileRules(rows, zap.NewNop()), op, message) +} + +// MatchRules 按优先级顺序返回第一个命中的规则分类。 +func MatchRules(rules []compiledRule, op, message string) Classification { + for _, rule := range rules { + if len(rule.ops) > 0 && !rule.ops[op] { + continue + } + if !ruleMatches(rule, message) { + continue + } + return Classification{Category: rule.row.Category, RuleID: rule.row.ID, Keyword: rule.row.Keyword, MaybeAccepted: rule.row.MaybeAccepted} + } + return Classification{Category: constants.GatewayClassBusiness} +} + +func ruleMatches(rule compiledRule, message string) bool { + switch rule.row.MatchType { + case constants.GatewayMatchPrefix: + return strings.HasPrefix(message, rule.row.Keyword) + case constants.GatewayMatchRegex: + return rule.regex != nil && rule.regex.MatchString(message) + default: + return strings.Contains(message, rule.row.Keyword) + } +} + +func (c *Classifier) current(ctx context.Context) []compiledRule { + c.mu.Lock() + defer c.mu.Unlock() + if c.loader == nil || time.Since(c.loaded) < constants.GatewayRateLimitConfigTTL { + return c.rules + } + rows, err := c.loader.ListEnabled(ctx) + c.loaded = time.Now() + if err != nil { + c.logger.Warn("加载上游错误分类规则失败,沿用上一次规则", zap.Error(err)) + return c.rules + } + c.rules = compileRules(rows, c.logger) + return c.rules +} + +func compileRules(rows []model.GatewayErrorClassification, logger *zap.Logger) []compiledRule { + rules := make([]compiledRule, 0, len(rows)) + for _, row := range rows { + rule := compiledRule{row: row, ops: map[string]bool{}} + ops, err := DecodeOperations(row.Operations) + if err != nil { + logger.Warn("错误分类规则适用操作非法,已跳过", zap.Uint("rule_id", row.ID)) + continue + } + for _, op := range ops { + rule.ops[op] = true + } + if row.MatchType == constants.GatewayMatchRegex { + re, err := regexp.Compile(row.Keyword) + if err != nil { + logger.Warn("错误分类规则正则非法,已跳过", zap.Uint("rule_id", row.ID)) + continue + } + rule.regex = re + } + rules = append(rules, rule) + } + return rules +} diff --git a/internal/infrastructure/gatewaylimit/groups.go b/internal/infrastructure/gatewaylimit/groups.go new file mode 100644 index 00000000..2721aed7 --- /dev/null +++ b/internal/infrastructure/gatewaylimit/groups.go @@ -0,0 +1,130 @@ +package gatewaylimit + +import ( + "context" + "sync" + "time" + + "github.com/bytedance/sonic" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// defaultGroupCode 是未被任何启用限流组覆盖时使用的默认组标识(每个账户每个操作独立窗口)。 +const defaultGroupCode = "default" + +// GroupRule 是一个限流组对某个操作生效的规则快照。 +type GroupRule struct { + GroupCode string + MaxQPS int + ReservedQPS int + HighPriority bool // 该操作在组内是否为高优先级 +} + +// groupIndex 是启用限流组按"账户 → 操作"建立的索引。 +type groupIndex struct { + byCarrier map[uint]map[string]GroupRule + global map[string]GroupRule +} + +// GroupLoader 读取全部启用的限流组。 +type GroupLoader interface { + ListEnabled(ctx context.Context) ([]model.CarrierRateLimitGroup, error) +} + +// GroupProvider 在进程内缓存启用限流组,超过 TTL 重新加载,后台修改数秒内生效。 +type GroupProvider struct { + loader GroupLoader + logger *zap.Logger + mu sync.Mutex + index groupIndex + loaded time.Time +} + +// NewGroupProvider 创建限流组提供者;loader 为 nil 时所有操作使用默认 QPS。 +func NewGroupProvider(loader GroupLoader, logger *zap.Logger) *GroupProvider { + if logger == nil { + logger = zap.NewNop() + } + return &GroupProvider{loader: loader, logger: logger, index: groupIndex{byCarrier: map[uint]map[string]GroupRule{}, global: map[string]GroupRule{}}} +} + +// Invalidate 使缓存立即失效,后台保存限流组后调用。 +func (p *GroupProvider) Invalidate() { + if p == nil { + return + } + p.mu.Lock() + p.loaded = time.Time{} + p.mu.Unlock() +} + +// Resolve 返回某账户某操作的账户组规则(未覆盖时 ok=false)与全局组规则(未配置时 ok=false)。 +func (p *GroupProvider) Resolve(ctx context.Context, carrierID uint, op string) (account GroupRule, accountOK bool, global GroupRule, globalOK bool) { + index := p.current(ctx) + if rules, ok := index.byCarrier[carrierID]; ok { + account, accountOK = rules[op] + } + global, globalOK = index.global[op] + return +} + +func (p *GroupProvider) current(ctx context.Context) groupIndex { + if p == nil { + return groupIndex{} + } + p.mu.Lock() + defer p.mu.Unlock() + if p.loader == nil || time.Since(p.loaded) < constants.GatewayRateLimitConfigTTL { + return p.index + } + rows, err := p.loader.ListEnabled(ctx) + p.loaded = time.Now() + if err != nil { + p.logger.Warn("加载网关限流组失败,沿用上一次配置", zap.Error(err)) + return p.index + } + p.index = buildGroupIndex(rows, p.logger) + return p.index +} + +func buildGroupIndex(rows []model.CarrierRateLimitGroup, logger *zap.Logger) groupIndex { + index := groupIndex{byCarrier: map[uint]map[string]GroupRule{}, global: map[string]GroupRule{}} + for _, row := range rows { + ops, err := DecodeOperations(row.Operations) + if err != nil { + logger.Warn("限流组操作列表非法,已跳过该组", zap.Uint("group_id", row.ID), zap.Error(err)) + continue + } + highOps, _ := DecodeOperations(row.HighPriorityOperations) + high := map[string]bool{} + for _, op := range highOps { + high[op] = true + } + target := index.global + if row.CarrierID != nil { + if index.byCarrier[*row.CarrierID] == nil { + index.byCarrier[*row.CarrierID] = map[string]GroupRule{} + } + target = index.byCarrier[*row.CarrierID] + } + for _, op := range ops { + target[op] = GroupRule{GroupCode: row.GroupCode, MaxQPS: row.MaxQPS, ReservedQPS: row.ReservedQPS, HighPriority: high[op]} + } + } + return index +} + +// DecodeOperations 解析 JSON 字符串数组形式的操作列表。 +func DecodeOperations(raw []byte) ([]string, error) { + if len(raw) == 0 { + return nil, nil + } + var ops []string + if err := sonic.Unmarshal(raw, &ops); err != nil { + return nil, err + } + return ops, nil +} diff --git a/internal/infrastructure/gatewaylimit/guard.go b/internal/infrastructure/gatewaylimit/guard.go new file mode 100644 index 00000000..512e8bfd --- /dev/null +++ b/internal/infrastructure/gatewaylimit/guard.go @@ -0,0 +1,169 @@ +package gatewaylimit + +import ( + "context" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// 分钟计数 Hash 的字段名。 +const ( + statFieldWouldLimit = "would_limit" + statFieldDurationSum = "duration_ms_sum" + statFieldAttempts = "attempts" + statPrefixExempt = "exempt:" + statPrefixUpstream = "upstream:" + statPrefixDuration = "duration_le:" +) + +// durationBuckets 是耗时分桶上界(毫秒),用于近似计算 p95。 +var durationBuckets = []int64{100, 300, 500, 1000, 3000, 10000, 60000} + +// Guard 实现 gateway.CallGuard:每次卡网关 HTTP 尝试前按运营商账户限流,结束后写分钟计数与卡最近错误。 +type Guard struct { + limiter *Limiter + resolver *CarrierResolver + classifier *Classifier + redis *redis.Client + logger *zap.Logger +} + +// NewGuard 创建卡网关调用守卫。 +func NewGuard(limiter *Limiter, resolver *CarrierResolver, classifier *Classifier, redisClient *redis.Client, logger *zap.Logger) *Guard { + if logger == nil { + logger = zap.NewNop() + } + return &Guard{limiter: limiter, resolver: resolver, classifier: classifier, redis: redisClient, logger: logger} +} + +var _ gateway.CallGuard = (*Guard)(nil) + +// BeforeAttempt 取限流名额;豁免调用直接放行。拦截模式下拿不到名额返回限流错误,本次方法调用随即结束。 +func (g *Guard) BeforeAttempt(ctx context.Context, call gateway.CallInfo) error { + if g == nil || g.limiter == nil || call.ExemptSource != "" { + return nil + } + carrierID := g.carrierOf(ctx, call) + decision, err := g.limiter.Check(ctx, carrierID, call.Operation) + if err != nil { + // 限流器故障时失败开放,避免 Redis 抖动阻断全部网关调用。 + g.logger.Warn("网关限流判断失败,本次放行", zap.Uint("carrier_id", carrierID), zap.String("operation", call.Operation), zap.Error(err)) + return nil + } + if decision.Exceed { + g.incr(ctx, carrierID, call.Operation, statFieldWouldLimit) + } + if !decision.Allowed { + g.incr(ctx, carrierID, call.Operation, constants.GatewayErrorRateLimited) + return gateway.NewRateLimitedError() + } + return nil +} + +// AfterAttempt 写分钟计数(按结果类别与耗时分桶);查询失败时写卡该操作最近一次错误。 +func (g *Guard) AfterAttempt(ctx context.Context, call gateway.CallInfo, callErr error, duration time.Duration) { + if g == nil || g.redis == nil { + return + } + ctx = context.WithoutCancel(ctx) + carrierID := g.carrierOf(ctx, call) + field := constants.GatewayResultSuccess + var classification Classification + gwErr, isGatewayErr := gateway.AsGatewayError(callErr) + if callErr != nil { + field = constants.GatewayErrorNetwork + if isGatewayErr { + field = gwErr.Category + if gwErr.Category == constants.GatewayErrorUpstream { + classification = g.classifier.Classify(ctx, call.Operation, gwErr.ProviderMessage) + field = statPrefixUpstream + classification.Category + } + } + } + key := constants.RedisGatewayCallStatKey(carrierID, call.Operation, statMinute(time.Now())) + durationMS := duration.Milliseconds() + pipe := g.redis.Pipeline() + pipe.HIncrBy(ctx, key, field, 1) + pipe.HIncrBy(ctx, key, statFieldAttempts, 1) + pipe.HIncrBy(ctx, key, statFieldDurationSum, durationMS) + pipe.HIncrBy(ctx, key, statPrefixDuration+durationBucket(durationMS), 1) + if call.ExemptSource != "" { + pipe.HIncrBy(ctx, key, statPrefixExempt+call.ExemptSource, 1) + } + pipe.Expire(ctx, key, constants.GatewayCallStatTTL) + if callErr != nil && isQueryOperation(call.Operation) && call.CardNo != "" { + errKey := constants.RedisGatewayCardLastErrorKey(call.CardNo, call.Operation) + values := map[string]any{"category": field, "at": time.Now().Format(time.RFC3339)} + if isGatewayErr { + values["provider_code"] = strconv.Itoa(gwErr.ProviderCode) + values["provider_message"] = gwErr.ProviderMessage + } + pipe.HSet(ctx, errKey, values) + pipe.Expire(ctx, errKey, constants.GatewayCardLastErrorTTL) + } + if _, err := pipe.Exec(ctx); err != nil { + g.logger.Debug("写网关调用统计失败", zap.Error(err)) + } +} + +// Classify 暴露分类器供停复机记录等调用方对上游错误分类。 +func (g *Guard) Classify(ctx context.Context, op, message string) Classification { + if g == nil { + return Classification{Category: constants.GatewayClassBusiness} + } + return g.classifier.Classify(ctx, op, message) +} + +func (g *Guard) carrierOf(ctx context.Context, call gateway.CallInfo) uint { + if call.CarrierID != 0 || g.resolver == nil { + return call.CarrierID + } + carrierID, err := g.resolver.Resolve(ctx, call.CardNo) + if err != nil { + g.logger.Debug("解析卡所属运营商账户失败,按未知账户计量", zap.String("iccid", call.CardNo), zap.Error(err)) + } + return carrierID +} + +func (g *Guard) incr(ctx context.Context, carrierID uint, op, field string) { + if g.redis == nil { + return + } + key := constants.RedisGatewayCallStatKey(carrierID, op, statMinute(time.Now())) + pipe := g.redis.Pipeline() + pipe.HIncrBy(ctx, key, field, 1) + pipe.Expire(ctx, key, constants.GatewayCallStatTTL) + _, _ = pipe.Exec(context.WithoutCancel(ctx)) +} + +func isQueryOperation(op string) bool { + return op == constants.GatewayOpQueryFlow || op == constants.GatewayOpQueryStatus || op == constants.GatewayOpQueryRealname +} + +// statMinute 返回上海时区的分钟标识(yyyyMMddHHmm)。 +func statMinute(t time.Time) string { + return t.In(shanghai()).Format("200601021504") +} + +func durationBucket(ms int64) string { + for _, upper := range durationBuckets { + if ms <= upper { + return strconv.FormatInt(upper, 10) + } + } + return "inf" +} + +func shanghai() *time.Location { + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.FixedZone("CST", 8*3600) + } + return location +} diff --git a/internal/infrastructure/gatewaylimit/limiter.go b/internal/infrastructure/gatewaylimit/limiter.go new file mode 100644 index 00000000..85ad1931 --- /dev/null +++ b/internal/infrastructure/gatewaylimit/limiter.go @@ -0,0 +1,191 @@ +package gatewaylimit + +import ( + "context" + "crypto/rand" + "encoding/hex" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// slidingWindowScript 在一次原子执行内判断账户组窗口与可选的全局组窗口。 +// +// KEYS[1] 账户组窗口 ZSET;KEYS[2] 账户组高优先级等待计数;KEYS[3] 全局组窗口 ZSET(无全局组时传空串占位)。 +// ARGV:now_ms、window_ms、max_qps、reserved_qps、is_high(0/1)、global_max(0 表示无全局组)、member、dry_run(0/1)。 +// 返回 {allowed(1/0), wait_ms}:dry_run=1(只统计)时总是记录本次请求,allowed=0 表示"本应被限流"。 +const slidingWindowLua = ` +local now = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local maxQPS = tonumber(ARGV[3]) +local reserved = tonumber(ARGV[4]) +local isHigh = tonumber(ARGV[5]) +local globalMax = tonumber(ARGV[6]) +local member = ARGV[7] +local dryRun = tonumber(ARGV[8]) +local useGlobal = globalMax > 0 and KEYS[3] ~= '' + +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window) +if useGlobal then + redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', now - window) +end + +local limit = maxQPS +if isHigh == 0 then + limit = maxQPS - reserved + if reserved > 0 then + local waiting = tonumber(redis.call('GET', KEYS[2]) or '0') + if waiting > 0 then + limit = 0 + end + end +end + +local allowed = 1 +local wait = 0 +local count = redis.call('ZCARD', KEYS[1]) +if count >= limit then + allowed = 0 + local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES') + if oldest[2] then + wait = tonumber(oldest[2]) + window - now + else + wait = window + end +end +if allowed == 1 and useGlobal then + local gcount = redis.call('ZCARD', KEYS[3]) + if gcount >= globalMax then + allowed = 0 + local goldest = redis.call('ZRANGE', KEYS[3], 0, 0, 'WITHSCORES') + if goldest[2] then + wait = tonumber(goldest[2]) + window - now + else + wait = window + end + end +end + +if allowed == 1 or dryRun == 1 then + redis.call('ZADD', KEYS[1], now, member) + redis.call('PEXPIRE', KEYS[1], window * 2) + if useGlobal then + redis.call('ZADD', KEYS[3], now, member) + redis.call('PEXPIRE', KEYS[3], window * 2) + end +end +if wait < 1 and allowed == 0 then + wait = 1 +end +return {allowed, wait} +` + +var slidingWindowScript = redis.NewScript(slidingWindowLua) + +// Decision 是一次限流判断的结果。 +type Decision struct { + Allowed bool // 是否放行(只统计模式下恒为 true) + Exceed bool // 是否超过上限(只统计模式下表示"本应被限流") + Wait time.Duration // 拦截时建议的等待时长 +} + +// Limiter 是基于 Redis 滑动窗口的运营商账户限流器,跨全部进程共享。 +type Limiter struct { + redis *redis.Client + groups *GroupProvider + settings *SettingsProvider +} + +// NewLimiter 创建限流器。 +func NewLimiter(redisClient *redis.Client, groups *GroupProvider, settings *SettingsProvider) *Limiter { + return &Limiter{redis: redisClient, groups: groups, settings: settings} +} + +// Check 立即判断一次请求能否放行,不等待。拦截模式下超过上限返回 Allowed=false; +// 只统计模式下总是放行,Exceed 表示本应被限流。Redis 故障时放行(失败开放),避免限流故障阻断业务。 +func (l *Limiter) Check(ctx context.Context, carrierID uint, op string) (Decision, error) { + settings := l.settings.Current(ctx) + account, accountOK, global, globalOK := l.groups.Resolve(ctx, carrierID, op) + if !accountOK { + account = GroupRule{GroupCode: defaultGroupCode + ":" + op, MaxQPS: settings.DefaultQPS} + } + globalKey, globalMax := "", 0 + if globalOK { + globalKey, globalMax = constants.RedisGatewayGlobalQPSKey(global.GroupCode), global.MaxQPS + } + isHigh, dryRun := 0, 1 + if account.HighPriority { + isHigh = 1 + } + if settings.Enforce() { + dryRun = 0 + } + now := time.Now().UnixMilli() + result, err := slidingWindowScript.Run(ctx, l.redis, + []string{constants.RedisGatewayQPSKey(carrierID, account.GroupCode), constants.RedisGatewayQPSHighWaitingKey(carrierID, account.GroupCode), globalKey}, + now, constants.GatewayRateLimitWindow.Milliseconds(), account.MaxQPS, account.ReservedQPS, isHigh, globalMax, windowMember(now), dryRun, + ).Int64Slice() + if err != nil || len(result) != 2 { + return Decision{Allowed: true}, err + } + exceed := result[0] == 0 + decision := Decision{Allowed: !exceed || dryRun == 1, Exceed: exceed} + if exceed && dryRun == 0 { + decision.Wait = time.Duration(result[1]) * time.Millisecond + } + return decision, nil +} + +// Wait 在拦截模式下按建议时长等待名额,总等待不超过 maxWait;超时返回 false。 +// 高优先级操作等待期间登记等待计数,使同组普通操作拿不到新名额。本 Change 中 Asynq 任务不使用该方法。 +func (l *Limiter) Wait(ctx context.Context, carrierID uint, op string, maxWait time.Duration) (bool, error) { + deadline := time.Now().Add(maxWait) + registered := false + var waitingKey string + defer func() { + if registered { + l.redis.Decr(context.WithoutCancel(ctx), waitingKey) + } + }() + for { + decision, err := l.Check(ctx, carrierID, op) + if err != nil || decision.Allowed { + return true, err + } + if !registered { + account, accountOK, _, _ := l.groups.Resolve(ctx, carrierID, op) + if accountOK && account.HighPriority && account.ReservedQPS > 0 { + waitingKey = constants.RedisGatewayQPSHighWaitingKey(carrierID, account.GroupCode) + pipe := l.redis.TxPipeline() + pipe.Incr(ctx, waitingKey) + pipe.Expire(ctx, waitingKey, 10*time.Second) + if _, err := pipe.Exec(ctx); err == nil { + registered = true + } + } + } + sleep := decision.Wait + if remaining := time.Until(deadline); remaining <= 0 { + return false, nil + } else if sleep > remaining { + sleep = remaining + } + timer := time.NewTimer(sleep) + select { + case <-ctx.Done(): + timer.Stop() + return false, ctx.Err() + case <-timer.C: + } + } +} + +// windowMember 生成窗口内唯一成员,避免同毫秒请求互相覆盖。 +func windowMember(nowMS int64) string { + buf := make([]byte, 6) + _, _ = rand.Read(buf) + return strconv.FormatInt(nowMS, 10) + "-" + hex.EncodeToString(buf) +} diff --git a/internal/infrastructure/gatewaylimit/partition.go b/internal/infrastructure/gatewaylimit/partition.go new file mode 100644 index 00000000..ae6f80e1 --- /dev/null +++ b/internal/infrastructure/gatewaylimit/partition.go @@ -0,0 +1,61 @@ +package gatewaylimit + +import ( + "context" + stderrors "errors" + "time" + + "github.com/hibiken/asynq" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/store/postgres" +) + +// partitionDropLookbackMonths 是每次清理向前检查的过期月数,覆盖维护任务停摆一段时间后的补删。 +const partitionDropLookbackMonths = 12 + +// PartitionMaintainer 维护停复机记录按月分区:提前创建未来分区,按保留期整区删除过期分区。 +type PartitionMaintainer struct { + store *postgres.CardStopResumeRecordStore + settings *SettingsProvider + logger *zap.Logger +} + +// NewPartitionMaintainer 创建分区维护任务。 +func NewPartitionMaintainer(store *postgres.CardStopResumeRecordStore, settings *SettingsProvider, logger *zap.Logger) *PartitionMaintainer { + if logger == nil { + logger = zap.NewNop() + } + return &PartitionMaintainer{store: store, settings: settings, logger: logger} +} + +// Handle 是 Asynq 定时任务入口;任一分区操作失败时写错误告警并返回错误,由 Asynq 按既有规则重试。 +func (m *PartitionMaintainer) Handle(ctx context.Context, _ *asynq.Task) error { + if m == nil || m.store == nil { + return stderrors.New("停复机记录分区维护未配置") + } + settings := m.settings.Current(ctx) + monthStart := currentMonthStart(time.Now()) + var failed error + for i := 0; i <= settings.RecordPremakeMonth; i++ { + month := monthStart.AddDate(0, i, 0) + if err := m.store.EnsureMonthPartition(ctx, month); err != nil { + m.logger.Error("创建停复机记录分区失败,记录将落入默认分区", zap.String("month", month.Format("200601")), zap.Error(err)) + failed = err + } + } + for k := 0; k < partitionDropLookbackMonths; k++ { + month := monthStart.AddDate(0, -(settings.RecordRetention + k), 0) + if err := m.store.DropMonthPartition(ctx, month); err != nil { + m.logger.Error("删除过期停复机记录分区失败", zap.String("month", month.Format("200601")), zap.Error(err)) + failed = err + } + } + return failed +} + +// currentMonthStart 返回上海时区当月 1 日 0 点。 +func currentMonthStart(now time.Time) time.Time { + local := now.In(shanghai()) + return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, shanghai()) +} diff --git a/internal/infrastructure/gatewaylimit/resolver.go b/internal/infrastructure/gatewaylimit/resolver.go new file mode 100644 index 00000000..a2cc246a --- /dev/null +++ b/internal/infrastructure/gatewaylimit/resolver.go @@ -0,0 +1,53 @@ +package gatewaylimit + +import ( + "context" + "strconv" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// CarrierResolver 按 ICCID 解析卡所属的本系统运营商账户(tb_iot_card.carrier_id),结果缓存在 Redis。 +type CarrierResolver struct { + db *gorm.DB + redis *redis.Client +} + +// NewCarrierResolver 创建运营商账户解析器。 +func NewCarrierResolver(db *gorm.DB, redisClient *redis.Client) *CarrierResolver { + return &CarrierResolver{db: db, redis: redisClient} +} + +// Resolve 返回 ICCID 对应卡的运营商账户 ID;卡不存在时返回 0、nil,由调用方按未知账户处理。 +func (r *CarrierResolver) Resolve(ctx context.Context, iccid string) (uint, error) { + if r == nil || iccid == "" { + return 0, nil + } + cacheKey := constants.RedisGatewayCardCarrierKey(iccid) + if r.redis != nil { + if raw, err := r.redis.Get(ctx, cacheKey).Result(); err == nil { + if value, parseErr := strconv.ParseUint(raw, 10, 64); parseErr == nil { + return uint(value), nil + } + } + } + if r.db == nil { + return 0, nil + } + var carrierIDs []uint + err := r.db.WithContext(ctx).Table("tb_iot_card"). + Where("iccid = ? AND deleted_at IS NULL", iccid).Limit(1).Pluck("carrier_id", &carrierIDs).Error + if err != nil { + return 0, err + } + if len(carrierIDs) == 0 { + return 0, nil + } + if r.redis != nil { + _ = r.redis.Set(ctx, cacheKey, strconv.FormatUint(uint64(carrierIDs[0]), 10), constants.GatewayCardCarrierCacheTTL).Err() + } + return carrierIDs[0], nil +} diff --git a/internal/infrastructure/gatewaylimit/runtime.go b/internal/infrastructure/gatewaylimit/runtime.go new file mode 100644 index 00000000..dce45d62 --- /dev/null +++ b/internal/infrastructure/gatewaylimit/runtime.go @@ -0,0 +1,79 @@ +package gatewaylimit + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "time" + + "github.com/hibiken/asynq" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// Runtime 汇总一个进程内的网关限流组件,API 与 Worker 各自创建一份,限流窗口经 Redis 跨进程共享。 +type Runtime struct { + Settings *SettingsProvider + Groups *GroupProvider + Classifier *Classifier + Limiter *Limiter + Guard *Guard + Recorder *StopResumeRecorder + + redisClient *redis.Client + logger *zap.Logger +} + +// NewRuntime 创建网关限流运行组件;配置读取使用独立注册表,与后台系统配置共享同一份数据库与缓存数据。 +func NewRuntime(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Runtime { + if logger == nil { + logger = zap.NewNop() + } + registry := systemconfig.NewRegistry() + RegisterConfigDefinitions(registry, logger) + var cache systemconfig.Cache + if redisClient != nil { + cache = systemconfig.NewRedisCache(redisClient) + } + reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger)) + settings := NewSettingsProvider(reader, logger) + groups := NewGroupProvider(postgres.NewCarrierRateLimitGroupStore(db), logger) + classifier := NewClassifier(postgres.NewGatewayErrorClassificationStore(db), logger) + limiter := NewLimiter(redisClient, groups, settings) + guard := NewGuard(limiter, NewCarrierResolver(db, redisClient), classifier, redisClient, logger) + recorder := NewStopResumeRecorder(postgres.NewCardStopResumeRecordStore(db), classifier, logger) + return &Runtime{Settings: settings, Groups: groups, Classifier: classifier, Limiter: limiter, Guard: guard, Recorder: recorder, redisClient: redisClient, logger: logger} +} + +// TrackDeferral 记录 Asynq 任务因限流被推迟;同一任务首次被限流起累计推迟超过阈值时写一次错误告警,任务继续重试。 +// 任务以"类型 + 载荷摘要"标识,记录 1 天后自动过期。 +func (r *Runtime) TrackDeferral(task *asynq.Task) { + if r == nil || r.redisClient == nil || task == nil { + return + } + ctx := context.Background() + sum := sha1.Sum(task.Payload()) + taskKey := task.Type() + ":" + hex.EncodeToString(sum[:]) + key := constants.RedisRateLimitDeferStartKey(taskKey) + now := time.Now() + if ok, err := r.redisClient.SetNX(ctx, key, now.Unix(), 24*time.Hour).Result(); err != nil || ok { + return + } + startedUnix, err := r.redisClient.Get(ctx, key).Int64() + if err != nil { + return + } + deferred := now.Sub(time.Unix(startedUnix, 0)) + if deferred < r.Settings.Current(ctx).DeferAlert { + return + } + if alerted, _ := r.redisClient.SetNX(ctx, key+":alerted", 1, 24*time.Hour).Result(); alerted { + r.logger.Error("异步任务因运营商账户网关限流累计推迟超过告警阈值,任务继续重试", + zap.String("task_type", task.Type()), zap.String("task_key", taskKey), zap.Duration("deferred", deferred)) + } +} diff --git a/internal/infrastructure/gatewaylimit/settings.go b/internal/infrastructure/gatewaylimit/settings.go new file mode 100644 index 00000000..593047b8 --- /dev/null +++ b/internal/infrastructure/gatewaylimit/settings.go @@ -0,0 +1,136 @@ +// Package gatewaylimit 实现按运营商账户(本系统 tb_carrier)的卡网关调用限流、错误分类与调用统计。 +// +// 账户口径:限流单位是本系统的运营商账户(卡上的 carrier_id),与网关客户端自身的 appId 无关。 +// 调用方已知卡所属账户时用 gateway.WithCarrier 传入;否则守卫按 ICCID 查本系统卡表解析。 +package gatewaylimit + +import ( + "context" + stderrors "errors" + "strconv" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// RegisterConfigDefinitions 向受控系统配置注册表注册网关限流相关配置;已注册的 Key 跳过。 +func RegisterConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) { + if registry == nil { + return + } + minOne, minZero := int64(1), int64(0) + maxQPS, maxDelay, maxAlert, maxMonths := int64(10000), int64(3600), int64(86400*7), int64(120) + definitions := []systemconfig.Definition{ + {Key: constants.SystemConfigPollingRateLimitMode, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeString, DefaultValue: constants.RateLimitModeObserve, Description: "网关限流模式:observe 只统计不拦截,enforce 拦截", EnumValues: []string{constants.RateLimitModeObserve, constants.RateLimitModeEnforce}}, + {Key: constants.SystemConfigPollingGatewayDefaultQPS, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "10", Description: "未被启用限流组覆盖的卡网关操作的默认每秒请求上限", Min: &minOne, Max: &maxQPS}, + {Key: constants.SystemConfigPollingRateLimitedRetryDelay, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "5", Description: "Asynq 任务遇限流后的固定重试延迟(秒)", Min: &minOne, Max: &maxDelay}, + {Key: constants.SystemConfigPollingRateLimitDeferAlert, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "600", Description: "同一任务因限流累计推迟超过该时长(秒)时写告警", Min: &minOne, Max: &maxAlert}, + {Key: constants.SystemConfigPollingStopResumeRecordRetention, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "6", Description: "停复机记录保留月数,到期整区删除", Min: &minOne, Max: &maxMonths}, + {Key: constants.SystemConfigPollingStopResumeRecordPremake, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "3", Description: "停复机记录提前创建未来分区的月数", Min: &minZero, Max: &maxMonths}, + } + for _, definition := range definitions { + if _, exists := registry.Get(definition.Key); exists { + continue + } + if err := registry.Register(definition); err != nil && logger != nil { + logger.Error("注册网关限流系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err)) + } + } +} + +// ConfigReader 读取已注册的受控系统配置。 +type ConfigReader interface { + Get(ctx context.Context, key string) (string, error) +} + +// Settings 是网关限流运行参数快照。 +type Settings struct { + Mode string + DefaultQPS int + RetryDelay time.Duration + DeferAlert time.Duration + RecordRetention int + RecordPremakeMonth int +} + +// Enforce 判断当前是否为拦截模式。 +func (s Settings) Enforce() bool { + return s.Mode == constants.RateLimitModeEnforce +} + +// defaultSettings 是配置读取失败时的安全默认值:只统计、不拦截。 +func defaultSettings() Settings { + return Settings{Mode: constants.RateLimitModeObserve, DefaultQPS: 10, RetryDelay: 5 * time.Second, DeferAlert: 10 * time.Minute, RecordRetention: 6, RecordPremakeMonth: 3} +} + +// SettingsProvider 在进程内缓存限流参数,超过 TTL 重新读取,后台修改数秒内生效。 +type SettingsProvider struct { + reader ConfigReader + logger *zap.Logger + mu sync.Mutex + cached Settings + loaded time.Time +} + +// NewSettingsProvider 创建限流参数提供者;reader 为 nil 时始终返回安全默认值。 +func NewSettingsProvider(reader ConfigReader, logger *zap.Logger) *SettingsProvider { + if logger == nil { + logger = zap.NewNop() + } + return &SettingsProvider{reader: reader, logger: logger, cached: defaultSettings()} +} + +// Current 返回当前限流参数;读取失败时保留上一次成功值。 +func (p *SettingsProvider) Current(ctx context.Context) Settings { + if p == nil { + return defaultSettings() + } + p.mu.Lock() + defer p.mu.Unlock() + if p.reader == nil || time.Since(p.loaded) < constants.GatewayRateLimitConfigTTL { + return p.cached + } + next, err := p.load(ctx) + p.loaded = time.Now() + if err != nil { + p.logger.Warn("读取网关限流配置失败,沿用上一次配置", zap.Error(err)) + return p.cached + } + p.cached = next + return next +} + +func (p *SettingsProvider) load(ctx context.Context) (Settings, error) { + settings := defaultSettings() + mode, err := p.reader.Get(ctx, constants.SystemConfigPollingRateLimitMode) + if err != nil { + return settings, err + } + settings.Mode = mode + ints := []struct { + key string + set func(int) + }{ + {constants.SystemConfigPollingGatewayDefaultQPS, func(v int) { settings.DefaultQPS = v }}, + {constants.SystemConfigPollingRateLimitedRetryDelay, func(v int) { settings.RetryDelay = time.Duration(v) * time.Second }}, + {constants.SystemConfigPollingRateLimitDeferAlert, func(v int) { settings.DeferAlert = time.Duration(v) * time.Second }}, + {constants.SystemConfigPollingStopResumeRecordRetention, func(v int) { settings.RecordRetention = v }}, + {constants.SystemConfigPollingStopResumeRecordPremake, func(v int) { settings.RecordPremakeMonth = v }}, + } + for _, item := range ints { + raw, err := p.reader.Get(ctx, item.key) + if err != nil { + return settings, err + } + value, err := strconv.Atoi(raw) + if err != nil { + return settings, stderrors.New("网关限流整数配置非法") + } + item.set(value) + } + return settings, nil +} diff --git a/internal/infrastructure/gatewaylimit/stop_resume_record.go b/internal/infrastructure/gatewaylimit/stop_resume_record.go new file mode 100644 index 00000000..626db1f0 --- /dev/null +++ b/internal/infrastructure/gatewaylimit/stop_resume_record.go @@ -0,0 +1,86 @@ +package gatewaylimit + +import ( + "context" + "strconv" + "time" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// StopResumeRecordInput 描述一次实际调用上游停机或复机的结果。 +type StopResumeRecordInput struct { + CardID uint + ICCID string + CarrierID uint + Action string // constants.StopResumeActionStop / StopResumeActionStart + Source string // 来源:豁免来源或审计来源 + Scene string + Reason string // 停机原因 + CommandID string // 同一次停复机命令的多次尝试共用 + Attempt int + CallErr error + Duration time.Duration +} + +// StopResumeRecorder 把每次停复机上游调用写入停复机记录表;写入失败只记日志与计数,不影响业务回写。 +type StopResumeRecorder struct { + store *postgres.CardStopResumeRecordStore + classifier *Classifier + logger *zap.Logger +} + +// NewStopResumeRecorder 创建停复机记录写入器。 +func NewStopResumeRecorder(store *postgres.CardStopResumeRecordStore, classifier *Classifier, logger *zap.Logger) *StopResumeRecorder { + if logger == nil { + logger = zap.NewNop() + } + return &StopResumeRecorder{store: store, classifier: classifier, logger: logger} +} + +// Record 写入一条停复机记录;限流拒绝(请求未发出)不写。 +func (r *StopResumeRecorder) Record(ctx context.Context, input StopResumeRecordInput) { + if r == nil || r.store == nil || gateway.IsRateLimited(input.CallErr) { + return + } + row := &model.CardStopResumeRecord{ + CardID: input.CardID, ICCID: input.ICCID, CarrierID: input.CarrierID, Action: input.Action, + Source: input.Source, Scene: input.Scene, Reason: input.Reason, CommandID: input.CommandID, + Attempt: input.Attempt, Result: constants.StopResumeRecordSuccess, + DurationMS: int(input.Duration.Milliseconds()), CreatedAt: time.Now(), + } + if input.CallErr != nil { + row.Result = constants.StopResumeRecordFailed + if gwErr, ok := gateway.AsGatewayError(input.CallErr); ok { + row.ErrorCategory = gwErr.Category + switch gwErr.Category { + case constants.GatewayErrorTimeout, constants.GatewayErrorNetwork: + // 超时或连接失败时上游可能已受理,按结果未知记录。 + row.Result = constants.StopResumeRecordUnknown + case constants.GatewayErrorUpstream: + op := constants.GatewayOpStop + if input.Action == constants.StopResumeActionStart { + op = constants.GatewayOpStart + } + classification := r.classifier.Classify(ctx, op, gwErr.ProviderMessage) + row.ErrorCategory = classification.Category + if classification.Category == constants.GatewayClassAlreadyTarget { + row.Result = constants.StopResumeRecordAlreadyTarget + } + row.ProviderCode = strconv.Itoa(gwErr.ProviderCode) + row.ProviderMessage = gwErr.ProviderMessage + } + } + } + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancel() + if err := r.store.Create(writeCtx, row); err != nil { + r.logger.Warn("写停复机记录失败,不影响停复机结果回写", + zap.Uint("card_id", input.CardID), zap.String("action", input.Action), zap.String("metric", "stop_resume_record.write_failed"), zap.Error(err)) + } +} diff --git a/internal/model/dto/gateway_rate_limit_dto.go b/internal/model/dto/gateway_rate_limit_dto.go new file mode 100644 index 00000000..93cf166b --- /dev/null +++ b/internal/model/dto/gateway_rate_limit_dto.go @@ -0,0 +1,262 @@ +package dto + +import "time" + +// RateLimitGroupListRequest 网关限流组列表请求 +type RateLimitGroupListRequest 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" description:"运营商账户ID筛选"` + OnlyGlobal bool `json:"only_global" query:"only_global" description:"只看全局组"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:停用, 1:启用)"` +} + +// SaveRateLimitGroupRequest 新建或修改网关限流组请求 +type SaveRateLimitGroupRequest struct { + ID uint `json:"-" description:"限流组ID"` + CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商账户ID,为空表示全局组"` + GroupCode string `json:"group_code" validate:"required,max=50" required:"true" maxLength:"50" description:"组标识,同一账户内唯一"` + GroupName string `json:"group_name" validate:"required,max=100" required:"true" maxLength:"100" description:"组名称"` + MaxQPS int `json:"max_qps" validate:"required,min=1,max=10000" required:"true" minimum:"1" maximum:"10000" description:"每秒请求上限"` + Operations []string `json:"operations" validate:"required,min=1,dive,oneof=query_flow query_status query_realname stop start" required:"true" description:"组内操作 (query_flow:查流量, query_status:查状态, query_realname:查实名, stop:停机, start:复机)"` + HighPriorityOperations []string `json:"high_priority_operations" validate:"omitempty,dive,oneof=query_flow query_status query_realname stop start" description:"高优先级操作,必须属于组内操作"` + ReservedQPS int `json:"reserved_qps" validate:"min=0" minimum:"0" description:"预留给高优先级操作的额度,须小于每秒请求上限"` + Status *int `json:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:停用, 1:启用),新建默认启用"` +} + +// UpdateRateLimitGroupStatusRequest 启停网关限流组请求 +type UpdateRateLimitGroupStatusRequest struct { + ID uint `json:"-" path:"id" description:"限流组ID"` + Status *int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (0:停用, 1:启用);停用后组内操作回落为默认每秒请求上限"` +} + +// RateLimitGroupResponse 网关限流组响应 +type RateLimitGroupResponse struct { + ID uint `json:"id" description:"限流组ID"` + CarrierID *uint `json:"carrier_id" description:"运营商账户ID,为空表示全局组"` + CarrierName string `json:"carrier_name" description:"运营商账户名称,全局组为空"` + GroupCode string `json:"group_code" description:"组标识"` + GroupName string `json:"group_name" description:"组名称"` + MaxQPS int `json:"max_qps" description:"每秒请求上限"` + Operations []string `json:"operations" description:"组内操作 (query_flow:查流量, query_status:查状态, query_realname:查实名, stop:停机, start:复机)"` + HighPriorityOperations []string `json:"high_priority_operations" description:"高优先级操作"` + ReservedQPS int `json:"reserved_qps" description:"预留给高优先级操作的额度"` + Status int `json:"status" description:"状态 (0:停用, 1:启用)"` + StatusName string `json:"status_name" description:"状态名称"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +// RateLimitGroupPageResult 网关限流组分页结果 +type RateLimitGroupPageResult struct { + List []RateLimitGroupResponse `json:"items" description:"限流组列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` +} + +// GatewayErrorRuleListRequest 上游错误分类规则列表请求 +type GatewayErrorRuleListRequest 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:"每页数量"` + Category string `json:"category" query:"category" validate:"omitempty,oneof=rate_limited_upstream terminal already_target transient business account_config" description:"类别筛选 (rate_limited_upstream:上游频率限制, terminal:终态不可操作, already_target:已是目标状态, transient:临时故障, business:业务错误, account_config:账户配置错误)"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:停用, 1:启用)"` + Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字模糊搜索"` +} + +// SaveGatewayErrorRuleRequest 新建或修改上游错误分类规则请求 +type SaveGatewayErrorRuleRequest struct { + ID uint `json:"-" description:"规则ID"` + Keyword string `json:"keyword" validate:"required,max=200" required:"true" maxLength:"200" description:"匹配关键字或正则"` + MatchType string `json:"match_type" validate:"required,oneof=contains prefix regex" required:"true" description:"匹配方式 (contains:包含, prefix:前缀, regex:正则)"` + Category string `json:"category" validate:"required,oneof=rate_limited_upstream terminal already_target transient business account_config" required:"true" description:"类别 (rate_limited_upstream:上游频率限制, terminal:终态不可操作, already_target:已是目标状态, transient:临时故障, business:业务错误, account_config:账户配置错误)"` + Operations []string `json:"operations" validate:"omitempty,dive,oneof=query_flow query_status query_realname stop start" description:"适用操作,为空表示全部"` + Priority int `json:"priority" validate:"min=1,max=10000" minimum:"1" maximum:"10000" description:"匹配优先级,越小越先匹配"` + MaybeAccepted bool `json:"maybe_accepted" description:"上游可能已受理(如返回含调用成功、操作进行中、请求被合并)"` + Status *int `json:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:停用, 1:启用),新建默认启用"` + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"` +} + +// UpdateGatewayErrorRuleStatusRequest 启停上游错误分类规则请求 +type UpdateGatewayErrorRuleStatusRequest struct { + ID uint `json:"-" path:"id" description:"规则ID"` + Status *int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (0:停用, 1:启用)"` +} + +// GatewayErrorRuleResponse 上游错误分类规则响应 +type GatewayErrorRuleResponse struct { + ID uint `json:"id" description:"规则ID"` + Keyword string `json:"keyword" description:"匹配关键字或正则"` + MatchType string `json:"match_type" description:"匹配方式 (contains:包含, prefix:前缀, regex:正则)"` + Category string `json:"category" description:"类别"` + CategoryName string `json:"category_name" description:"类别名称"` + Operations []string `json:"operations" description:"适用操作,为空表示全部"` + Priority int `json:"priority" description:"匹配优先级"` + MaybeAccepted bool `json:"maybe_accepted" description:"上游可能已受理"` + Status int `json:"status" description:"状态 (0:停用, 1:启用)"` + Remark string `json:"remark" description:"备注"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +// GatewayErrorRulePageResult 上游错误分类规则分页结果 +type GatewayErrorRulePageResult struct { + List []GatewayErrorRuleResponse `json:"items" description:"规则列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` +} + +// TestGatewayErrorRuleRequest 测试上游错误分类请求 +type TestGatewayErrorRuleRequest struct { + Operation string `json:"operation" validate:"required,oneof=query_flow query_status query_realname stop start" required:"true" description:"操作 (query_flow:查流量, query_status:查状态, query_realname:查实名, stop:停机, start:复机)"` + Message string `json:"message" validate:"required,max=1000" required:"true" maxLength:"1000" description:"上游返回信息"` + Draft *SaveGatewayErrorRuleRequest `json:"draft" description:"未保存的规则草稿,传入时与已启用规则一起参与匹配(草稿按自身优先级排序)"` +} + +// TestGatewayErrorRuleResponse 测试上游错误分类响应 +type TestGatewayErrorRuleResponse struct { + Category string `json:"category" description:"命中类别"` + CategoryName string `json:"category_name" description:"命中类别名称"` + RuleID uint `json:"rule_id" description:"命中规则ID,未命中或命中草稿为 0"` + Keyword string `json:"keyword" description:"命中关键字,未命中为空"` + MaybeAccepted bool `json:"maybe_accepted" description:"上游可能已受理"` + Matched bool `json:"matched" description:"是否命中规则;未命中时按业务错误处理"` +} + +// StopResumeRecordListRequest 停复机记录列表请求 +type StopResumeRecordListRequest 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:"每页数量"` + ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=30" maxLength:"30" description:"ICCID"` + CarrierID *uint `json:"carrier_id" query:"carrier_id" validate:"omitempty" description:"运营商账户ID"` + Action string `json:"action" query:"action" validate:"omitempty,oneof=stop start" description:"动作 (stop:停机, start:复机)"` + Source string `json:"source" query:"source" validate:"omitempty,max=50" maxLength:"50" description:"来源"` + Result string `json:"result" query:"result" validate:"omitempty,oneof=success failed unknown already_target skipped" description:"结果 (success:成功, failed:失败, unknown:结果未知, already_target:已是目标状态, skipped:跳过)"` + ErrorCategory string `json:"error_category" query:"error_category" validate:"omitempty,max=30" maxLength:"30" description:"错误类别"` + StartTime time.Time `json:"start_time" query:"start_time" validate:"required" required:"true" description:"开始时间(必填,RFC3339)"` + EndTime time.Time `json:"end_time" query:"end_time" validate:"required" required:"true" description:"结束时间(必填,RFC3339,跨度不超过 31 天)"` +} + +// CardStopResumeRecordListRequest 单卡停复机历史请求 +type CardStopResumeRecordListRequest struct { + ID uint `json:"-" path:"id" description:"IoT卡ID"` + 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:"每页数量"` + StartTime time.Time `json:"start_time" query:"start_time" description:"开始时间(RFC3339),为空取最近 31 天"` + EndTime time.Time `json:"end_time" query:"end_time" description:"结束时间(RFC3339),为空取当前"` +} + +// StopResumeRecordResponse 停复机记录响应 +type StopResumeRecordResponse struct { + ID uint `json:"id" description:"记录ID"` + CardID uint `json:"card_id" description:"IoT卡ID"` + ICCID string `json:"iccid" description:"ICCID"` + CarrierID uint `json:"carrier_id" description:"运营商账户ID"` + Action string `json:"action" description:"动作 (stop:停机, start:复机)"` + ActionName string `json:"action_name" description:"动作名称"` + Source string `json:"source" description:"来源"` + Scene string `json:"scene" description:"场景"` + Reason string `json:"reason" description:"停机原因"` + CommandID string `json:"command_id" description:"停复机命令标识,同一命令的多次尝试相同"` + Attempt int `json:"attempt" description:"尝试序号"` + Result string `json:"result" description:"结果 (success:成功, failed:失败, unknown:结果未知, already_target:已是目标状态, skipped:跳过)"` + ErrorCategory string `json:"error_category" description:"错误类别"` + ErrorCategoryName string `json:"error_category_name" description:"错误类别名称"` + ProviderCode string `json:"provider_code" description:"上游返回码"` + ProviderMessage string `json:"provider_message" description:"上游返回信息(已截断)"` + DurationMS int `json:"duration_ms" description:"耗时(毫秒)"` + CreatedAt time.Time `json:"created_at" description:"记录时间"` +} + +// StopResumeRecordPageResult 停复机记录分页结果 +type StopResumeRecordPageResult struct { + List []StopResumeRecordResponse `json:"items" description:"记录列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` +} + +// GatewayCallStatsRequest 按运营商账户的网关调用统计请求 +type GatewayCallStatsRequest struct { + Minutes int `json:"minutes" query:"minutes" validate:"omitempty,min=1,max=60" minimum:"1" maximum:"60" description:"统计最近多少分钟,默认 15,最大 60"` +} + +// GatewayCallTrendRequest 单个运营商账户的网关调用趋势请求 +type GatewayCallTrendRequest struct { + CarrierID uint `json:"-" path:"carrier_id" description:"运营商账户ID(0 表示无法解析账户的卡)"` + Minutes int `json:"minutes" query:"minutes" validate:"omitempty,min=1,max=10080" minimum:"1" maximum:"10080" description:"统计最近多少分钟,默认 60,最大 10080(7 天)"` + BucketMinutes int `json:"bucket_minutes" query:"bucket_minutes" validate:"omitempty,min=1,max=1440" minimum:"1" maximum:"1440" description:"聚合粒度(分钟),默认 1"` +} + +// GatewayOperationStat 某操作在统计窗口内的调用汇总 +type GatewayOperationStat struct { + Operation string `json:"operation" description:"操作 (query_flow:查流量, query_status:查状态, query_realname:查实名, stop:停机, start:复机)"` + OperationName string `json:"operation_name" description:"操作名称"` + Attempts int64 `json:"attempts" description:"实际发出的请求数"` + Success int64 `json:"success" description:"成功数"` + SuccessRate float64 `json:"success_rate" description:"成功率(0~1)"` + AvgQPS float64 `json:"avg_qps" description:"平均每秒请求数"` + PeakMinuteQPS float64 `json:"peak_minute_qps" description:"峰值分钟的平均每秒请求数"` + WouldLimit int64 `json:"would_limit" description:"超过限流组上限的次数(只统计模式下为本应被限流次数)"` + RateLimited int64 `json:"rate_limited" description:"被限流拒绝的次数(拦截模式)"` + Failures map[string]int64 `json:"failures" description:"按错误类别的失败数(network/timeout/http/invalid_response/upstream:<类别>)"` + Exempt map[string]int64 `json:"exempt" description:"按豁免来源的调用量 (manual_click/channel_threshold/auto_renewal/manual_refresh)"` + AvgDurationMS float64 `json:"avg_duration_ms" description:"平均耗时(毫秒)"` + P95DurationMS int64 `json:"p95_duration_ms" description:"p95 耗时上界(毫秒,按分桶近似),-1 表示超过最大分桶"` +} + +// GatewayCarrierStat 某运营商账户的网关调用汇总 +type GatewayCarrierStat struct { + CarrierID uint `json:"carrier_id" description:"运营商账户ID(0 表示无法解析账户的卡)"` + CarrierName string `json:"carrier_name" description:"运营商账户名称"` + Operations []GatewayOperationStat `json:"operations" description:"各操作汇总"` +} + +// GatewayCallStatsResponse 按运营商账户的网关调用统计响应 +type GatewayCallStatsResponse struct { + Mode string `json:"mode" description:"当前限流模式 (observe:只统计, enforce:拦截)"` + Minutes int `json:"minutes" description:"统计窗口(分钟)"` + Carriers []GatewayCarrierStat `json:"carriers" description:"有调用的运营商账户汇总"` +} + +// GatewayCallTrendPoint 趋势中的一个时间桶 +type GatewayCallTrendPoint struct { + Time string `json:"time" description:"时间桶起点(上海时区,yyyy-MM-dd HH:mm)"` + Operations []GatewayOperationStat `json:"operations" description:"各操作在该时间桶的汇总"` +} + +// GatewayCallTrendResponse 单个运营商账户的网关调用趋势响应 +type GatewayCallTrendResponse struct { + CarrierID uint `json:"carrier_id" description:"运营商账户ID"` + Points []GatewayCallTrendPoint `json:"points" description:"时间桶列表"` +} + +// PollingOptionItem 枚举选项 +type PollingOptionItem struct { + Value string `json:"value" description:"取值"` + Label string `json:"label" description:"中文名称"` +} + +// PollingOptionsResponse 轮询与网关限流相关枚举 +type PollingOptionsResponse struct { + GatewayOperations []PollingOptionItem `json:"gateway_operations" description:"卡网关操作"` + ErrorCategories []PollingOptionItem `json:"error_categories" description:"上游错误分类类别"` + MatchTypes []PollingOptionItem `json:"match_types" description:"错误分类匹配方式"` + RateLimitModes []PollingOptionItem `json:"rate_limit_modes" description:"限流模式"` + ExemptSources []PollingOptionItem `json:"exempt_sources" description:"限流豁免来源"` + StopResumeResults []PollingOptionItem `json:"stop_resume_results" description:"停复机记录结果"` + StopResumeActions []PollingOptionItem `json:"stop_resume_actions" description:"停复机动作"` +} + +// UpdateRateLimitGroupParams 修改网关限流组参数(路径 + 请求体,供接口文档使用) +type UpdateRateLimitGroupParams struct { + IDReq + SaveRateLimitGroupRequest +} + +// UpdateGatewayErrorRuleParams 修改上游错误分类规则参数(路径 + 请求体,供接口文档使用) +type UpdateGatewayErrorRuleParams struct { + IDReq + SaveGatewayErrorRuleRequest +} diff --git a/internal/model/dto/system_config_dto.go b/internal/model/dto/system_config_dto.go index 45780630..a1bda173 100644 --- a/internal/model/dto/system_config_dto.go +++ b/internal/model/dto/system_config_dto.go @@ -4,7 +4,7 @@ import "time" // SystemConfigListRequest 是系统配置分页查询参数。 type SystemConfigListRequest struct { - Module string `json:"module" query:"module" validate:"omitempty,oneof=carrier_callback c2b.payment" enum:"carrier_callback,c2b.payment" description:"模块筛选 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置)"` + Module string `json:"module" query:"module" validate:"omitempty,oneof=carrier_callback c2b.payment polling" enum:"carrier_callback,c2b.payment,polling" description:"模块筛选 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置, polling:轮询与网关限流配置)"` 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:"每页数量"` } @@ -14,7 +14,7 @@ type SystemConfigItem struct { ConfigKey string `json:"config_key" description:"稳定配置 Key"` Value string `json:"value" description:"配置值;敏感值按注册策略脱敏"` ValueType string `json:"value_type" description:"值类型 (string:字符串, int:整数, bool:布尔, json:JSON)"` - Module string `json:"module" enum:"carrier_callback,c2b.payment" description:"所属模块 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置)"` + Module string `json:"module" enum:"carrier_callback,c2b.payment,polling" description:"所属模块 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置, polling:轮询与网关限流配置)"` Description string `json:"description" description:"中文说明"` Readonly bool `json:"readonly" description:"是否只读"` Sensitive bool `json:"sensitive" description:"是否敏感"` diff --git a/internal/model/gateway_rate_limit.go b/internal/model/gateway_rate_limit.go new file mode 100644 index 00000000..2e2cb559 --- /dev/null +++ b/internal/model/gateway_rate_limit.go @@ -0,0 +1,72 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// CarrierRateLimitGroup 运营商账户网关限流组。 +// CarrierID 为 nil 表示全局组;Operations、HighPriorityOperations 为卡网关操作字符串数组(JSON)。 +type CarrierRateLimitGroup struct { + gorm.Model + BaseModel `gorm:"embedded"` + CarrierID *uint `gorm:"column:carrier_id;comment:运营商账户ID,为空表示全局组" json:"carrier_id"` + GroupCode string `gorm:"column:group_code;type:varchar(50);not null;comment:组标识" json:"group_code"` + GroupName string `gorm:"column:group_name;type:varchar(100);not null;comment:组名称" json:"group_name"` + MaxQPS int `gorm:"column:max_qps;not null;comment:每秒请求上限" json:"max_qps"` + Operations datatypes.JSON `gorm:"column:operations;type:jsonb;not null;comment:组内操作" json:"operations"` + HighPriorityOperations datatypes.JSON `gorm:"column:high_priority_operations;type:jsonb;not null;comment:高优先级操作" json:"high_priority_operations"` + ReservedQPS int `gorm:"column:reserved_qps;not null;default:0;comment:预留给高优先级操作的额度" json:"reserved_qps"` + Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0-停用 1-启用" json:"status"` +} + +// TableName 返回限流组表名。 +func (CarrierRateLimitGroup) TableName() string { + return "tb_carrier_rate_limit_group" +} + +// GatewayErrorClassification 上游错误分类规则。 +type GatewayErrorClassification struct { + gorm.Model + BaseModel `gorm:"embedded"` + Keyword string `gorm:"column:keyword;type:varchar(200);not null;comment:匹配关键字或正则" json:"keyword"` + MatchType string `gorm:"column:match_type;type:varchar(20);not null;default:contains;comment:匹配方式" json:"match_type"` + Category string `gorm:"column:category;type:varchar(30);not null;comment:类别" json:"category"` + Operations datatypes.JSON `gorm:"column:operations;type:jsonb;not null;comment:适用操作,空数组表示全部" json:"operations"` + Priority int `gorm:"column:priority;not null;default:100;comment:匹配优先级" json:"priority"` + MaybeAccepted bool `gorm:"column:maybe_accepted;not null;default:false;comment:上游可能已受理" json:"maybe_accepted"` + Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0-停用 1-启用" json:"status"` + Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:备注" json:"remark"` +} + +// TableName 返回错误分类规则表名。 +func (GatewayErrorClassification) TableName() string { + return "tb_gateway_error_classification" +} + +// CardStopResumeRecord 停复机调用记录(按月分区表),每次实际调用上游停机或复机一条。 +type CardStopResumeRecord struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CardID uint `gorm:"column:card_id;not null" json:"card_id"` + ICCID string `gorm:"column:iccid;type:varchar(30);not null;default:''" json:"iccid"` + CarrierID uint `gorm:"column:carrier_id;not null;default:0" json:"carrier_id"` + Action string `gorm:"column:action;type:varchar(10);not null" json:"action"` + Source string `gorm:"column:source;type:varchar(50);not null;default:''" json:"source"` + Scene string `gorm:"column:scene;type:varchar(50);not null;default:''" json:"scene"` + Reason string `gorm:"column:reason;type:varchar(50);not null;default:''" json:"reason"` + CommandID string `gorm:"column:command_id;type:varchar(64);not null;default:''" json:"command_id"` + Attempt int `gorm:"column:attempt;not null;default:1" json:"attempt"` + Result string `gorm:"column:result;type:varchar(20);not null" json:"result"` + ErrorCategory string `gorm:"column:error_category;type:varchar(30);not null;default:''" json:"error_category"` + ProviderCode string `gorm:"column:provider_code;type:varchar(30);not null;default:''" json:"provider_code"` + ProviderMessage string `gorm:"column:provider_message;type:varchar(500);not null;default:''" json:"provider_message"` + DurationMS int `gorm:"column:duration_ms;not null;default:0" json:"duration_ms"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;primaryKey;not null" json:"created_at"` +} + +// TableName 返回停复机记录表名。 +func (CardStopResumeRecord) TableName() string { + return "tb_card_stop_resume_record" +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index dce782df..9c999627 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -133,6 +133,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.PollingConfig != nil { registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath) } + if handlers.GatewayRateLimit != nil { + registerGatewayRateLimitRoutes(authGroup, handlers.GatewayRateLimit, doc, basePath) + } if handlers.PollingConcurrency != nil { registerPollingConcurrencyRoutes(authGroup, handlers.PollingConcurrency, doc, basePath) } diff --git a/internal/routes/gateway_rate_limit.go b/internal/routes/gateway_rate_limit.go new file mode 100644 index 00000000..a22f8172 --- /dev/null +++ b/internal/routes/gateway_rate_limit.go @@ -0,0 +1,72 @@ +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" +) + +// registerGatewayRateLimitRoutes 注册运营商账户网关限流、错误分类、停复机记录与调用统计路由。 +func registerGatewayRateLimitRoutes(router fiber.Router, handler *admin.GatewayRateLimitHandler, doc *openapi.Generator, basePath string) { + tags := []string{"网关限流"} + + groups := router.Group("/polling-rate-limit-groups") + groupsPath := basePath + "/polling-rate-limit-groups" + Register(groups, doc, groupsPath, "GET", "", handler.ListGroups, RouteSpec{ + Summary: "查询网关限流组列表", Tags: tags, Input: new(dto.RateLimitGroupListRequest), Output: new(dto.RateLimitGroupPageResult), Auth: true, + }) + Register(groups, doc, groupsPath, "POST", "", handler.CreateGroup, RouteSpec{ + Summary: "新建网关限流组(仅超级管理员)", Tags: tags, Input: new(dto.SaveRateLimitGroupRequest), Output: new(dto.RateLimitGroupResponse), Auth: true, + }) + Register(groups, doc, groupsPath, "GET", "/:id", handler.GetGroup, RouteSpec{ + Summary: "查询网关限流组详情", Tags: tags, Input: new(dto.IDReq), Output: new(dto.RateLimitGroupResponse), Auth: true, + }) + Register(groups, doc, groupsPath, "PUT", "/:id", handler.UpdateGroup, RouteSpec{ + Summary: "修改网关限流组(仅超级管理员)", Tags: tags, Input: new(dto.UpdateRateLimitGroupParams), Output: new(dto.RateLimitGroupResponse), Auth: true, + }) + Register(groups, doc, groupsPath, "PUT", "/:id/status", handler.UpdateGroupStatus, RouteSpec{ + Summary: "启停网关限流组(仅超级管理员)", Tags: tags, Input: new(dto.UpdateRateLimitGroupStatusRequest), Output: nil, Auth: true, + }) + Register(groups, doc, groupsPath, "DELETE", "/:id", handler.DeleteGroup, RouteSpec{ + Summary: "删除网关限流组(仅超级管理员)", Tags: tags, Input: new(dto.IDReq), Output: nil, Auth: true, + }) + + rules := router.Group("/gateway-error-classifications") + rulesPath := basePath + "/gateway-error-classifications" + Register(rules, doc, rulesPath, "GET", "", handler.ListRules, RouteSpec{ + Summary: "查询上游错误分类规则列表", Tags: tags, Input: new(dto.GatewayErrorRuleListRequest), Output: new(dto.GatewayErrorRulePageResult), Auth: true, + }) + Register(rules, doc, rulesPath, "POST", "", handler.CreateRule, RouteSpec{ + Summary: "新建上游错误分类规则(仅超级管理员)", Tags: tags, Input: new(dto.SaveGatewayErrorRuleRequest), Output: new(dto.GatewayErrorRuleResponse), Auth: true, + }) + Register(rules, doc, rulesPath, "POST", "/test", handler.TestRule, RouteSpec{ + Summary: "测试上游错误分类(不写数据)", Tags: tags, Input: new(dto.TestGatewayErrorRuleRequest), Output: new(dto.TestGatewayErrorRuleResponse), Auth: true, + }) + Register(rules, doc, rulesPath, "PUT", "/:id", handler.UpdateRule, RouteSpec{ + Summary: "修改上游错误分类规则(仅超级管理员)", Tags: tags, Input: new(dto.UpdateGatewayErrorRuleParams), Output: new(dto.GatewayErrorRuleResponse), Auth: true, + }) + Register(rules, doc, rulesPath, "PUT", "/:id/status", handler.UpdateRuleStatus, RouteSpec{ + Summary: "启停上游错误分类规则(仅超级管理员)", Tags: tags, Input: new(dto.UpdateGatewayErrorRuleStatusRequest), Output: nil, Auth: true, + }) + Register(rules, doc, rulesPath, "DELETE", "/:id", handler.DeleteRule, RouteSpec{ + Summary: "删除上游错误分类规则(仅超级管理员)", Tags: tags, Input: new(dto.IDReq), Output: nil, Auth: true, + }) + + Register(router, doc, basePath, "GET", "/card-stop-resume-records", handler.ListStopResumeRecords, RouteSpec{ + Summary: "查询停复机记录(时间范围必填,跨度不超过 31 天)", Tags: tags, Input: new(dto.StopResumeRecordListRequest), Output: new(dto.StopResumeRecordPageResult), Auth: true, + }) + Register(router, doc, basePath, "GET", "/iot-cards/:id/stop-resume-records", handler.ListCardStopResumeRecords, RouteSpec{ + Summary: "查询单卡停复机历史", Tags: tags, Input: new(dto.CardStopResumeRecordListRequest), Output: new(dto.StopResumeRecordPageResult), Auth: true, + }) + Register(router, doc, basePath, "GET", "/polling-stats/carriers", handler.CarrierCallStats, RouteSpec{ + Summary: "按运营商账户汇总网关调用", Tags: tags, Input: new(dto.GatewayCallStatsRequest), Output: new(dto.GatewayCallStatsResponse), Auth: true, + }) + Register(router, doc, basePath, "GET", "/polling-stats/carriers/:carrier_id/trend", handler.CarrierCallTrend, RouteSpec{ + Summary: "查询单个运营商账户的网关调用趋势", Tags: tags, Input: new(dto.GatewayCallTrendRequest), Output: new(dto.GatewayCallTrendResponse), Auth: true, + }) + Register(router, doc, basePath, "GET", "/polling-options", handler.PollingOptions, RouteSpec{ + Summary: "查询轮询与网关限流枚举", Tags: tags, Input: nil, Output: new(dto.PollingOptionsResponse), Auth: true, + }) +} diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 889c0332..2cf3c507 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -653,6 +653,8 @@ func flexIntPtr(fi gateway.FlexInt) *int { // Refresh 刷新资产数据(调网关同步) func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) { + // 手动刷新是用户在页面等待结果的操作,豁免运营商账户网关限流(限流只约束自动触发的调用)。 + ctx = gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptManualRefresh) switch assetType { case "card": card, err := s.iotCardStore.GetByID(ctx, id) diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 2cf0b37b..ac21f15b 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -15,6 +15,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/gateway" auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" @@ -28,6 +29,7 @@ import ( ) type Service struct { + stopResumeRecorder *gatewaylimit.StopResumeRecorder db *gorm.DB redis *redis.Client deviceStore *postgres.DeviceStore @@ -1440,7 +1442,9 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus log.Info("调用网关停机(设备)", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID)) - gwErr := s.gatewayClient.StopCard(gateway.WithAttemptObserver(ctx, observer), &gateway.CardOperationReq{CardNo: card.ICCID}) + callStartedAt := time.Now() + gwErr := s.gatewayClient.StopCard(gateway.WithCarrier(gateway.WithAttemptObserver(ctx, observer), card.CarrierID), &gateway.CardOperationReq{CardNo: card.ICCID}) + s.recordCardCommand(ctx, card, constants.StopResumeActionStop, constants.StopReasonManual, gwErr, time.Since(callStartedAt)) integrationID = observer.integration if gwErr != nil { log.Error("设备停机-调网关停机失败", @@ -1601,7 +1605,9 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { log.Info("调用网关复机(设备)", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID)) - gwErr := s.gatewayClient.StartCard(gateway.WithAttemptObserver(ctx, observer), &gateway.CardOperationReq{CardNo: card.ICCID}) + callStartedAt := time.Now() + gwErr := s.gatewayClient.StartCard(gateway.WithCarrier(gateway.WithAttemptObserver(ctx, observer), card.CarrierID), &gateway.CardOperationReq{CardNo: card.ICCID}) + s.recordCardCommand(ctx, card, constants.StopResumeActionStart, "", gwErr, time.Since(callStartedAt)) integrationID = observer.integration if gwErr != nil { log.Error("设备复机-调网关复机失败", @@ -1757,3 +1763,24 @@ func (s *Service) updateCardAndAppendNetworkSeries( }) }) } + +// SetStopResumeRecorder 注入停复机记录写入器;设备停复机每次上游调用写一条记录。 +func (s *Service) SetStopResumeRecorder(recorder *gatewaylimit.StopResumeRecorder) { + s.stopResumeRecorder = recorder +} + +// recordCardCommand 写设备下单卡的停复机记录;停复机只发一次请求,尝试序号恒为 1。 +func (s *Service) recordCardCommand(ctx context.Context, card *model.IotCard, action, reason string, callErr error, duration time.Duration) { + if s.stopResumeRecorder == nil || card == nil { + return + } + source := gateway.RateLimitExemptSource(ctx) + if source == "" { + source = constants.AuditSourceWorker + } + s.stopResumeRecorder.Record(ctx, gatewaylimit.StopResumeRecordInput{ + CardID: card.ID, ICCID: card.ICCID, CarrierID: card.CarrierID, Action: action, + Source: source, Scene: "device_command", Reason: reason, CommandID: deviceCommandSeriesKey(ctx), + Attempt: 1, CallErr: callErr, Duration: duration, + }) +} diff --git a/internal/service/iot_card/stop_resume_audit.go b/internal/service/iot_card/stop_resume_audit.go index 9f7d532a..fe9f0a81 100644 --- a/internal/service/iot_card/stop_resume_audit.go +++ b/internal/service/iot_card/stop_resume_audit.go @@ -8,7 +8,9 @@ import ( "github.com/google/uuid" "gorm.io/gorm" + "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/model" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" @@ -25,6 +27,9 @@ func (s *StopResumeService) appendCardCommandAudit( beforeData, afterData map[string]any, businessErr error, ) error { + if !commandAuditRequired(ctx, actionCode, result) { + return nil + } if s.auditWriter == nil || card == nil || card.ID == 0 { return errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置或资源不完整") } @@ -62,6 +67,9 @@ func (s *StopResumeService) recordCardCommandAudit( beforeData, afterData map[string]any, businessErr error, ) { + if !commandAuditRequired(ctx, actionCode, result) { + return + } if s.db == nil || s.auditWriter == nil || card == nil || card.ID == 0 { recordCardAuditSecondaryFailure(ctx, actionCode, cardID(card), businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置")) @@ -74,6 +82,49 @@ func (s *StopResumeService) recordCardCommandAudit( } } +// legacyCommandLogging 判断本次停复机是否沿用统一审计与集成日志: +// 带限流豁免标记的入口(后台人工点击、通道阈值、自动续费)与账户、个人客户、OpenAPI 发起的操作沿用; +// 系统自动停复机(轮询、Outbox、激活、重置、退款等系统任务)只写停复机记录(add-carrier-gateway-rate-limit)。 +// 通道阈值与自动续费的恢复扫描依赖集成日志标识,因此必须沿用。 +func legacyCommandLogging(ctx context.Context) bool { + if gateway.RateLimitExemptSource(ctx) != "" { + return true + } + switch auditcontext.From(ctx).ActorKind { + case constants.AuditActorAccount, constants.AuditActorPersonalCustomer, constants.AuditActorOpenAPI: + return true + default: + return false + } +} + +// commandAuditRequired 判断停复机命令结果是否写统一审计:系统自动停复机的成功、失败与结果未知改由停复机记录承载; +// 被拒绝(未调用上游)与非停复机命令的审计保持不变。 +func commandAuditRequired(ctx context.Context, actionCode, result string) bool { + if legacyCommandLogging(ctx) || result == constants.AuditResultDenied { + return true + } + switch actionCode { + case constants.AuditActionIotCardAutoStopped, constants.AuditActionIotCardAutoStarted, + constants.AuditActionIotCardManualStopped, constants.AuditActionIotCardManualStarted, + constants.AuditActionIotCardOpenAPIStarted: + return false + default: + return true + } +} + +// commandSource 返回停复机记录的来源:豁免来源优先,其次审计来源。 +func commandSource(ctx context.Context) string { + if source := gateway.RateLimitExemptSource(ctx); source != "" { + return source + } + if source := auditcontext.From(ctx).Source; source != "" { + return source + } + return constants.AuditSourceWorker +} + func cardID(card *model.IotCard) uint { if card == nil { return 0 @@ -191,6 +242,10 @@ type cardCommandAttemptObserver struct { operation string scene string seriesKey string + action string // constants.StopResumeActionStop / StopResumeActionStart + reason string // 停机原因(复机为空) + legacy bool // 是否沿用集成日志(见 legacyCommandLogging) + startedAt time.Time nextAttempt int current *gatewayAttempt successful *gatewayAttempt @@ -203,6 +258,13 @@ type cardCommandAttemptObserver struct { func (o *cardCommandAttemptObserver) BeforeAttempt(ctx context.Context, _ int) error { o.nextAttempt++ o.lastCallErr = nil + o.startedAt = time.Now() + if !o.legacy { + // 系统自动停复机不再写集成日志,只在尝试结束后写停复机记录。 + o.current = nil + o.lastIntegrationID = "" + return nil + } attempt, err := o.service.startCardCommandAttempt(ctx, o.card, o.operation, o.scene, o.seriesKey, o.nextAttempt) if err != nil { o.recordingErr = err @@ -218,6 +280,10 @@ func (o *cardCommandAttemptObserver) AfterAttempt(ctx context.Context, _ int, ca if isGatewayTimeout(callErr) { o.unknown = true } + o.service.recordStopResumeAttempt(ctx, o, callErr) + if !o.legacy { + return nil + } if callErr == nil { o.successful = o.current o.current = nil @@ -237,3 +303,15 @@ func (o *cardCommandAttemptObserver) auditResult(lastErr error) string { } return cardCommandAuditResult(lastErr) } + +// recordStopResumeAttempt 每次实际调用上游停机或复机后写一条停复机记录,写入失败不影响业务回写。 +func (s *StopResumeService) recordStopResumeAttempt(ctx context.Context, o *cardCommandAttemptObserver, callErr error) { + if s.stopResumeRecorder == nil || o == nil || o.card == nil { + return + } + s.stopResumeRecorder.Record(ctx, gatewaylimit.StopResumeRecordInput{ + CardID: o.card.ID, ICCID: o.card.ICCID, CarrierID: o.card.CarrierID, Action: o.action, + Source: commandSource(ctx), Scene: o.scene, Reason: o.reason, CommandID: o.seriesKey, + Attempt: o.nextAttempt, CallErr: callErr, Duration: time.Since(o.startedAt), + }) +} diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index a46415f7..28313b8d 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -16,6 +16,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/model" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -61,6 +62,13 @@ type StopResumeService struct { retryInterval time.Duration // channelThresholdGuard 是可选的通道阈值持锁判定能力;未注入时不做持锁拒绝。 channelThresholdGuard ChannelThresholdLockGuard + // stopResumeRecorder 写每次上游停复机调用的停复机记录;未注入时不写。 + stopResumeRecorder *gatewaylimit.StopResumeRecorder +} + +// SetStopResumeRecorder 注入停复机记录写入器。 +func (s *StopResumeService) SetStopResumeRecorder(recorder *gatewaylimit.StopResumeRecorder) { + s.stopResumeRecorder = recorder } // SetObservationSeriesEventWriter 注入停复机成功观测序列 Outbox Writer。 @@ -717,8 +725,9 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I attemptObserver := &cardCommandAttemptObserver{ service: s, card: card, operation: constants.IntegrationOperationGatewayStopCard, scene: constants.CardObservationSceneBusinessStop, seriesKey: seriesKey, + action: constants.StopResumeActionStop, reason: stopReason, legacy: legacyCommandLogging(ctx), } - gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver) + gatewayCtx := gateway.WithCarrier(gateway.WithAttemptObserver(ctx, attemptObserver), card.CarrierID) var lastErr error lastIntegrationID := "" for i := 0; i < s.maxRetries; i++ { @@ -730,6 +739,11 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I } callErr := s.gatewayClient.StopCard(gatewayCtx, &gateway.CardOperationReq{CardNo: card.ICCID}) + if gateway.IsRateLimited(callErr) { + // 被运营商账户限流拒绝时请求并未发出:不重试、不写失败审计,由调用方的既有重试或兜底路径稍后处理。 + s.logger.Warn("停机被运营商账户网关限流,本次不调用上游", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) + return carrierthresholddomain.CommandOutcome{Result: constants.AuditResultFailed}, callErr + } lastIntegrationID = attemptObserver.lastIntegrationID if attemptObserver.recordingErr != nil { s.logger.Error("记录停机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr)) @@ -818,8 +832,9 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model attemptObserver := &cardCommandAttemptObserver{ service: s, card: card, operation: constants.IntegrationOperationGatewayStartCard, scene: constants.CardObservationSceneBusinessResume, seriesKey: seriesKey, + action: constants.StopResumeActionStart, legacy: legacyCommandLogging(ctx), } - gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver) + gatewayCtx := gateway.WithCarrier(gateway.WithAttemptObserver(ctx, attemptObserver), card.CarrierID) var lastErr error lastIntegrationID := "" for i := 0; i < s.maxRetries; i++ { @@ -835,6 +850,11 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model req.Extend = constants.GatewayCardStartExtendMachineSeparated } callErr := s.gatewayClient.StartCard(gatewayCtx, req) + if gateway.IsRateLimited(callErr) { + // 被运营商账户限流拒绝时请求并未发出:不重试、不写失败审计,由调用方的既有重试或兜底路径稍后处理。 + s.logger.Warn("复机被运营商账户网关限流,本次不调用上游", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) + return nil, "", callErr + } lastIntegrationID = attemptObserver.lastIntegrationID if attemptObserver.recordingErr != nil { s.logger.Error("记录复机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr)) diff --git a/internal/service/polling/gateway_call_query.go b/internal/service/polling/gateway_call_query.go new file mode 100644 index 00000000..8e5d0dd5 --- /dev/null +++ b/internal/service/polling/gateway_call_query.go @@ -0,0 +1,366 @@ +package polling + +import ( + "context" + "sort" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// 分钟计数 Hash 字段(与 gatewaylimit.Guard 写入口径一致)。 +const ( + statAttempts = "attempts" + statDurationSum = "duration_ms_sum" + statWouldLimit = "would_limit" + statPrefixDur = "duration_le:" + statPrefixExem = "exempt:" + statPrefixUp = "upstream:" +) + +// statDurationBuckets 与写入端分桶上界一致(毫秒)。 +var statDurationBuckets = []string{"100", "300", "500", "1000", "3000", "10000", "60000", "inf"} + +// ListStopResumeRecords 按条件查询停复机记录;时间范围必填且跨度不超过 31 天。 +func (s *GatewayRateLimitService) ListStopResumeRecords(ctx context.Context, req *dto.StopResumeRecordListRequest) (*dto.StopResumeRecordPageResult, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + if req.StartTime.IsZero() || req.EndTime.IsZero() { + return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:开始时间与结束时间必填") + } + if !req.EndTime.After(req.StartTime) || req.EndTime.Sub(req.StartTime) > time.Duration(constants.StopResumeRecordQueryMaxDays)*24*time.Hour { + return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:时间跨度须大于 0 且不超过 31 天") + } + page, size := normalizePage(req.Page, req.PageSize) + return s.listRecords(ctx, postgres.StopResumeRecordFilter{ + ICCID: strings.TrimSpace(req.ICCID), CarrierID: req.CarrierID, Action: req.Action, Source: req.Source, + Result: req.Result, ErrorCategory: req.ErrorCategory, StartTime: req.StartTime, EndTime: req.EndTime, + Page: page, PageSize: size, + }) +} + +// ListCardStopResumeRecords 查询单卡停复机历史;未传时间默认最近 31 天。 +func (s *GatewayRateLimitService) ListCardStopResumeRecords(ctx context.Context, req *dto.CardStopResumeRecordListRequest) (*dto.StopResumeRecordPageResult, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + var count int64 + if err := s.db.WithContext(ctx).Model(&model.IotCard{}).Where("id = ?", req.ID).Count(&count).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询 IoT 卡失败") + } + if count == 0 { + return nil, errors.New(errors.CodeNotFound, "IoT 卡不存在") + } + end := req.EndTime + if end.IsZero() { + end = time.Now() + } + start := req.StartTime + if start.IsZero() { + start = end.AddDate(0, 0, -constants.StopResumeRecordQueryMaxDays) + } + if !end.After(start) || end.Sub(start) > time.Duration(constants.StopResumeRecordQueryMaxDays)*24*time.Hour { + return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:时间跨度须大于 0 且不超过 31 天") + } + page, size := normalizePage(req.Page, req.PageSize) + cardID := req.ID + return s.listRecords(ctx, postgres.StopResumeRecordFilter{CardID: &cardID, StartTime: start, EndTime: end, Page: page, PageSize: size}) +} + +func (s *GatewayRateLimitService) listRecords(ctx context.Context, filter postgres.StopResumeRecordFilter) (*dto.StopResumeRecordPageResult, error) { + rows, total, err := postgres.NewCardStopResumeRecordStore(s.db).List(ctx, filter) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询停复机记录失败") + } + items := make([]dto.StopResumeRecordResponse, 0, len(rows)) + for _, row := range rows { + items = append(items, dto.StopResumeRecordResponse{ + ID: row.ID, CardID: row.CardID, ICCID: row.ICCID, CarrierID: row.CarrierID, Action: row.Action, + ActionName: stopResumeActionName(row.Action), Source: row.Source, Scene: row.Scene, Reason: row.Reason, + CommandID: row.CommandID, Attempt: row.Attempt, Result: row.Result, ErrorCategory: row.ErrorCategory, + ErrorCategoryName: errorCategoryName(row.ErrorCategory), ProviderCode: row.ProviderCode, + ProviderMessage: row.ProviderMessage, DurationMS: row.DurationMS, CreatedAt: row.CreatedAt, + }) + } + return &dto.StopResumeRecordPageResult{List: items, Total: total, Page: filter.Page, PageSize: filter.PageSize}, nil +} + +// CarrierCallStats 汇总最近若干分钟各运营商账户的网关调用(只返回有调用的账户)。 +func (s *GatewayRateLimitService) CarrierCallStats(ctx context.Context, redisClient *redis.Client, req *dto.GatewayCallStatsRequest) (*dto.GatewayCallStatsResponse, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + minutes := req.Minutes + if minutes <= 0 { + minutes = 15 + } + names, err := s.carrierNames(ctx) + if err != nil { + return nil, err + } + carrierIDs := []uint{0} + for id := range names { + carrierIDs = append(carrierIDs, id) + } + sort.Slice(carrierIDs, func(i, j int) bool { return carrierIDs[i] < carrierIDs[j] }) + stamps := minuteStamps(time.Now(), minutes) + data, err := loadStatHashes(ctx, redisClient, carrierIDs, stamps) + if err != nil { + return nil, err + } + resp := &dto.GatewayCallStatsResponse{Mode: s.currentMode(ctx), Minutes: minutes, Carriers: []dto.GatewayCarrierStat{}} + for _, carrierID := range carrierIDs { + var ops []dto.GatewayOperationStat + for _, op := range constants.GatewayCardOperations { + stat := aggregateOperation(op, data[statKey{carrierID, op}], len(stamps)) + if stat.Attempts > 0 || stat.RateLimited > 0 || stat.WouldLimit > 0 { + ops = append(ops, stat) + } + } + if len(ops) == 0 { + continue + } + name := names[carrierID] + if carrierID == 0 { + name = "未知账户" + } + resp.Carriers = append(resp.Carriers, dto.GatewayCarrierStat{CarrierID: carrierID, CarrierName: name, Operations: ops}) + } + return resp, nil +} + +// CarrierCallTrend 返回单个运营商账户按时间桶聚合的网关调用趋势。 +func (s *GatewayRateLimitService) CarrierCallTrend(ctx context.Context, redisClient *redis.Client, req *dto.GatewayCallTrendRequest) (*dto.GatewayCallTrendResponse, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + minutes, bucket := req.Minutes, req.BucketMinutes + if minutes <= 0 { + minutes = 60 + } + if bucket <= 0 { + bucket = 1 + } + stamps := minuteStamps(time.Now(), minutes) + data, err := loadStatHashes(ctx, redisClient, []uint{req.CarrierID}, stamps) + if err != nil { + return nil, err + } + resp := &dto.GatewayCallTrendResponse{CarrierID: req.CarrierID, Points: []dto.GatewayCallTrendPoint{}} + for start := 0; start < len(stamps); start += bucket { + end := start + bucket + if end > len(stamps) { + end = len(stamps) + } + point := dto.GatewayCallTrendPoint{Time: formatStamp(stamps[start]), Operations: []dto.GatewayOperationStat{}} + for _, op := range constants.GatewayCardOperations { + series := data[statKey{req.CarrierID, op}] + if series == nil { + continue + } + stat := aggregateOperation(op, series[start:end], end-start) + if stat.Attempts > 0 || stat.RateLimited > 0 || stat.WouldLimit > 0 { + point.Operations = append(point.Operations, stat) + } + } + resp.Points = append(resp.Points, point) + } + return resp, nil +} + +func (s *GatewayRateLimitService) currentMode(ctx context.Context) string { + if s.runtime == nil { + return constants.RateLimitModeObserve + } + return s.runtime.Settings.Current(ctx).Mode +} + +type statKey struct { + carrierID uint + op string +} + +// minuteStamps 返回从最早到最近的分钟标识(上海时区 yyyyMMddHHmm)。 +func minuteStamps(now time.Time, minutes int) []string { + location := shanghaiLocation() + current := now.In(location).Truncate(time.Minute) + stamps := make([]string, minutes) + for i := 0; i < minutes; i++ { + stamps[minutes-1-i] = current.Add(-time.Duration(i) * time.Minute).Format("200601021504") + } + return stamps +} + +func formatStamp(stamp string) string { + t, err := time.ParseInLocation("200601021504", stamp, shanghaiLocation()) + if err != nil { + return stamp + } + return t.Format("2006-01-02 15:04") +} + +// loadStatHashes 用管道批量读取分钟计数;返回每个账户 × 操作按分钟顺序的 Hash 列表。 +func loadStatHashes(ctx context.Context, redisClient *redis.Client, carrierIDs []uint, stamps []string) (map[statKey][]map[string]string, error) { + result := map[statKey][]map[string]string{} + if redisClient == nil { + return result, nil + } + type pending struct { + key statKey + idx int + cmd *redis.MapStringStringCmd + } + pipe := redisClient.Pipeline() + cmds := make([]pending, 0, len(carrierIDs)*len(constants.GatewayCardOperations)*len(stamps)) + for _, carrierID := range carrierIDs { + for _, op := range constants.GatewayCardOperations { + key := statKey{carrierID, op} + result[key] = make([]map[string]string, len(stamps)) + for idx, stamp := range stamps { + cmds = append(cmds, pending{key: key, idx: idx, cmd: pipe.HGetAll(ctx, constants.RedisGatewayCallStatKey(carrierID, op, stamp))}) + } + } + } + if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil { + return nil, errors.Wrap(errors.CodeRedisError, err, "读取网关调用统计失败") + } + for _, item := range cmds { + values, err := item.cmd.Result() + if err == nil && len(values) > 0 { + result[item.key][item.idx] = values + } + } + return result, nil +} + +// aggregateOperation 汇总一个操作在若干分钟内的计数。 +func aggregateOperation(op string, minutes []map[string]string, minuteCount int) dto.GatewayOperationStat { + stat := dto.GatewayOperationStat{Operation: op, OperationName: constants.GatewayOperationName(op), Failures: map[string]int64{}, Exempt: map[string]int64{}} + buckets := map[string]int64{} + var durationSum int64 + for _, values := range minutes { + var minuteAttempts int64 + for field, raw := range values { + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + continue + } + switch { + case field == statAttempts: + stat.Attempts += value + minuteAttempts = value + case field == statDurationSum: + durationSum += value + case field == statWouldLimit: + stat.WouldLimit += value + case field == constants.GatewayErrorRateLimited: + stat.RateLimited += value + case field == constants.GatewayResultSuccess: + stat.Success += value + case strings.HasPrefix(field, statPrefixDur): + buckets[strings.TrimPrefix(field, statPrefixDur)] += value + case strings.HasPrefix(field, statPrefixExem): + stat.Exempt[strings.TrimPrefix(field, statPrefixExem)] += value + default: + stat.Failures[field] += value + } + } + if qps := float64(minuteAttempts) / 60; qps > stat.PeakMinuteQPS { + stat.PeakMinuteQPS = qps + } + } + if minuteCount > 0 { + stat.AvgQPS = float64(stat.Attempts) / float64(minuteCount*60) + } + if stat.Attempts > 0 { + stat.SuccessRate = float64(stat.Success) / float64(stat.Attempts) + stat.AvgDurationMS = float64(durationSum) / float64(stat.Attempts) + stat.P95DurationMS = percentileBucket(buckets, stat.Attempts, 0.95) + } + return stat +} + +// percentileBucket 返回累计占比达到 p 的分桶上界;落在最后一个分桶时返回 -1。 +func percentileBucket(buckets map[string]int64, total int64, p float64) int64 { + threshold := int64(float64(total)*p + 0.5) + var cumulative int64 + for _, upper := range statDurationBuckets { + cumulative += buckets[upper] + if cumulative >= threshold { + if upper == "inf" { + return -1 + } + value, _ := strconv.ParseInt(upper, 10, 64) + return value + } + } + return -1 +} + +func shanghaiLocation() *time.Location { + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.FixedZone("CST", 8*3600) + } + return location +} + +func stopResumeActionName(action string) string { + if action == constants.StopResumeActionStart { + return "复机" + } + return "停机" +} + +func errorCategoryName(category string) string { + switch category { + case "": + return "" + case constants.GatewayErrorNetwork: + return "网络错误" + case constants.GatewayErrorTimeout: + return "请求超时" + case constants.GatewayErrorHTTP: + return "HTTP 异常" + case constants.GatewayErrorInvalidResponse: + return "响应无效" + default: + return constants.GatewayErrorClassName(category) + } +} + +// PollingOptions 返回轮询与网关限流相关的前端枚举,前端不写死取值。 +func (s *GatewayRateLimitService) PollingOptions(ctx context.Context) (*dto.PollingOptionsResponse, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + resp := &dto.PollingOptionsResponse{} + for _, op := range constants.GatewayCardOperations { + resp.GatewayOperations = append(resp.GatewayOperations, dto.PollingOptionItem{Value: op, Label: constants.GatewayOperationName(op)}) + } + for _, class := range constants.GatewayErrorClasses { + resp.ErrorCategories = append(resp.ErrorCategories, dto.PollingOptionItem{Value: class, Label: constants.GatewayErrorClassName(class)}) + } + resp.MatchTypes = []dto.PollingOptionItem{{Value: constants.GatewayMatchContains, Label: "包含"}, {Value: constants.GatewayMatchPrefix, Label: "前缀"}, {Value: constants.GatewayMatchRegex, Label: "正则"}} + resp.RateLimitModes = []dto.PollingOptionItem{{Value: constants.RateLimitModeObserve, Label: "只统计不拦截"}, {Value: constants.RateLimitModeEnforce, Label: "拦截"}} + resp.ExemptSources = []dto.PollingOptionItem{ + {Value: constants.RateLimitExemptManualClick, Label: "后台人工点击"}, {Value: constants.RateLimitExemptChannelThreshold, Label: "通道阈值"}, + {Value: constants.RateLimitExemptAutoRenewal, Label: "自动续费"}, {Value: constants.RateLimitExemptManualRefresh, Label: "手动刷新"}, + } + resp.StopResumeResults = []dto.PollingOptionItem{ + {Value: constants.StopResumeRecordSuccess, Label: "成功"}, {Value: constants.StopResumeRecordFailed, Label: "失败"}, + {Value: constants.StopResumeRecordUnknown, Label: "结果未知"}, {Value: constants.StopResumeRecordAlreadyTarget, Label: "已是目标状态"}, + {Value: constants.StopResumeRecordSkipped, Label: "跳过"}, + } + resp.StopResumeActions = []dto.PollingOptionItem{{Value: constants.StopResumeActionStop, Label: "停机"}, {Value: constants.StopResumeActionStart, Label: "复机"}} + return resp, nil +} diff --git a/internal/service/polling/gateway_rate_limit_service.go b/internal/service/polling/gateway_rate_limit_service.go new file mode 100644 index 00000000..b001c728 --- /dev/null +++ b/internal/service/polling/gateway_rate_limit_service.go @@ -0,0 +1,638 @@ +package polling + +import ( + "context" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/bytedance/sonic" + "gorm.io/datatypes" + "gorm.io/gorm" + + auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// GatewayRateLimitService 维护运营商账户网关限流组与上游错误分类规则(后台配置,简单写事务脚本)。 +// 读取仅超级管理员与平台用户可用;修改仅超级管理员可用,并在同一事务写统一审计。 +type GatewayRateLimitService struct { + db *gorm.DB + auditWriter *auditinfra.Writer + groups *postgres.CarrierRateLimitGroupStore + rules *postgres.GatewayErrorClassificationStore + runtime *gatewaylimit.Runtime +} + +// NewGatewayRateLimitService 创建网关限流配置服务。 +func NewGatewayRateLimitService(db *gorm.DB, auditWriter *auditinfra.Writer, runtime *gatewaylimit.Runtime) *GatewayRateLimitService { + return &GatewayRateLimitService{ + db: db, auditWriter: auditWriter, runtime: runtime, + groups: postgres.NewCarrierRateLimitGroupStore(db), rules: postgres.NewGatewayErrorClassificationStore(db), + } +} + +// requirePlatformReader 校验读取权限:超级管理员或平台用户。 +func requirePlatformReader(ctx context.Context) error { + switch middleware.GetUserTypeFromContext(ctx) { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform: + return nil + default: + return errors.New(errors.CodeForbidden, "仅平台账号可查看网关限流配置") + } +} + +// requireSuperAdminWriter 校验修改权限:仅超级管理员,返回操作者 ID。 +func requireSuperAdminWriter(ctx context.Context) (uint, error) { + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return 0, errors.New(errors.CodeForbidden, "仅超级管理员可修改网关限流配置") + } + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return 0, errors.New(errors.CodeUnauthorized, "未授权访问") + } + return operatorID, nil +} + +func normalizePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size +} + +// ListGroups 分页查询限流组。 +func (s *GatewayRateLimitService) ListGroups(ctx context.Context, req *dto.RateLimitGroupListRequest) (*dto.RateLimitGroupPageResult, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + page, size := normalizePage(req.Page, req.PageSize) + rows, total, err := s.groups.List(ctx, postgres.RateLimitGroupFilter{CarrierID: req.CarrierID, OnlyGlobal: req.OnlyGlobal, Status: req.Status, Page: page, PageSize: size}) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询网关限流组失败") + } + names, err := s.carrierNames(ctx) + if err != nil { + return nil, err + } + items := make([]dto.RateLimitGroupResponse, 0, len(rows)) + for i := range rows { + items = append(items, toRateLimitGroupResponse(&rows[i], names)) + } + return &dto.RateLimitGroupPageResult{List: items, Total: total, Page: page, PageSize: size}, nil +} + +// GetGroup 查询限流组详情。 +func (s *GatewayRateLimitService) GetGroup(ctx context.Context, id uint) (*dto.RateLimitGroupResponse, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + row, err := s.loadGroup(ctx, id) + if err != nil { + return nil, err + } + names, err := s.carrierNames(ctx) + if err != nil { + return nil, err + } + resp := toRateLimitGroupResponse(row, names) + return &resp, nil +} + +// CreateGroup 新建限流组。 +func (s *GatewayRateLimitService) CreateGroup(ctx context.Context, req *dto.SaveRateLimitGroupRequest) (*dto.RateLimitGroupResponse, error) { + operatorID, err := requireSuperAdminWriter(ctx) + if err != nil { + return nil, err + } + if err := s.validateGroup(ctx, req, 0); err != nil { + return nil, err + } + row := &model.CarrierRateLimitGroup{CarrierID: req.CarrierID, Status: constants.RateLimitGroupStatusEnabled} + applyGroupRequest(row, req) + row.Creator, row.Updater = operatorID, operatorID + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Create(row).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "新建网关限流组失败") + } + return writePollingAudit(ctx, tx, s.auditWriter, groupAudit(constants.AuditActionRateLimitGroupCreated, "创建网关限流组", row, operatorID, nil)) + }) + if err != nil { + return nil, err + } + s.invalidateGroups() + return s.GetGroup(ctx, row.ID) +} + +// UpdateGroup 修改限流组。 +func (s *GatewayRateLimitService) UpdateGroup(ctx context.Context, req *dto.SaveRateLimitGroupRequest) (*dto.RateLimitGroupResponse, error) { + operatorID, err := requireSuperAdminWriter(ctx) + if err != nil { + return nil, err + } + row, err := s.loadGroup(ctx, req.ID) + if err != nil { + return nil, err + } + if !sameCarrier(row.CarrierID, req.CarrierID) { + return nil, errors.New(errors.CodeInvalidParam, "限流组所属运营商账户不可修改,请删除后重新创建") + } + if err := s.validateGroup(ctx, req, row.ID); err != nil { + return nil, err + } + before := groupState(row) + applyGroupRequest(row, req) + 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, groupAudit(constants.AuditActionRateLimitGroupUpdated, "更新网关限流组", row, operatorID, before)) + }) + if err != nil { + return nil, err + } + s.invalidateGroups() + return s.GetGroup(ctx, row.ID) +} + +// UpdateGroupStatus 启停限流组;停用后组内操作回落为默认每秒请求上限。 +func (s *GatewayRateLimitService) UpdateGroupStatus(ctx context.Context, id uint, status int) error { + operatorID, err := requireSuperAdminWriter(ctx) + if err != nil { + return err + } + row, err := s.loadGroup(ctx, id) + if err != nil { + return err + } + if status == constants.RateLimitGroupStatusEnabled { + ops, _ := gatewaylimit.DecodeOperations(row.Operations) + if err := s.checkOperationsFree(ctx, row.CarrierID, ops, row.ID); err != nil { + return err + } + } + before := groupState(row) + row.Status, row.Updater = status, operatorID + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Model(&model.CarrierRateLimitGroup{}).Where("id = ?", id). + Updates(map[string]any{"status": status, "updater": operatorID}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新网关限流组状态失败") + } + return writePollingAudit(ctx, tx, s.auditWriter, groupAudit(constants.AuditActionRateLimitGroupStatusUpdated, "更新网关限流组状态", row, operatorID, before)) + }) + if err != nil { + return err + } + s.invalidateGroups() + return nil +} + +// DeleteGroup 删除限流组;组内操作回落为默认每秒请求上限。 +func (s *GatewayRateLimitService) DeleteGroup(ctx context.Context, id uint) error { + operatorID, err := requireSuperAdminWriter(ctx) + if err != nil { + return err + } + row, err := s.loadGroup(ctx, id) + if err != nil { + return err + } + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Delete(&model.CarrierRateLimitGroup{}, id).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "删除网关限流组失败") + } + return writePollingAudit(ctx, tx, s.auditWriter, groupAudit(constants.AuditActionRateLimitGroupDeleted, "删除网关限流组", row, operatorID, groupState(row))) + }) + if err != nil { + return err + } + s.invalidateGroups() + return nil +} + +func (s *GatewayRateLimitService) loadGroup(ctx context.Context, id uint) (*model.CarrierRateLimitGroup, error) { + row, err := s.groups.Get(ctx, id) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询网关限流组失败") + } + if row == nil { + return nil, errors.New(errors.CodeNotFound, "网关限流组不存在") + } + return row, nil +} + +// validateGroup 校验预留额度、高优先级操作归属、同账户同操作只属于一个启用组、组标识唯一。 +func (s *GatewayRateLimitService) validateGroup(ctx context.Context, req *dto.SaveRateLimitGroupRequest, selfID uint) error { + if req.ReservedQPS >= req.MaxQPS { + return errors.New(errors.CodeInvalidParam, "预留额度必须小于每秒请求上限") + } + ops := uniqueStrings(req.Operations) + opSet := map[string]bool{} + for _, op := range ops { + opSet[op] = true + } + for _, op := range req.HighPriorityOperations { + if !opSet[op] { + return errors.New(errors.CodeInvalidParam, "高优先级操作必须属于组内操作:"+constants.GatewayOperationName(op)) + } + } + if req.ReservedQPS > 0 && len(req.HighPriorityOperations) == 0 { + return errors.New(errors.CodeInvalidParam, "设置预留额度时必须指定高优先级操作") + } + if req.CarrierID != nil { + var count int64 + if err := s.db.WithContext(ctx).Model(&model.Carrier{}).Where("id = ?", *req.CarrierID).Count(&count).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户失败") + } + if count == 0 { + return errors.New(errors.CodeInvalidParam, "运营商账户不存在") + } + } + existing, err := s.groups.ListByCarrier(ctx, req.CarrierID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询网关限流组失败") + } + for _, row := range existing { + if row.ID != selfID && row.GroupCode == req.GroupCode { + return errors.New(errors.CodeInvalidParam, "同一运营商账户内组标识已存在") + } + } + if req.Status == nil || *req.Status == constants.RateLimitGroupStatusEnabled { + return s.checkOperationsFree(ctx, req.CarrierID, ops, selfID) + } + return nil +} + +// checkOperationsFree 校验操作未被同账户其他启用组占用。 +func (s *GatewayRateLimitService) checkOperationsFree(ctx context.Context, carrierID *uint, ops []string, selfID uint) error { + existing, err := s.groups.ListByCarrier(ctx, carrierID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询网关限流组失败") + } + for _, row := range existing { + if row.ID == selfID || row.Status != constants.RateLimitGroupStatusEnabled { + continue + } + used, _ := gatewaylimit.DecodeOperations(row.Operations) + for _, op := range used { + for _, want := range ops { + if op == want { + return errors.New(errors.CodeInvalidParam, "操作「"+constants.GatewayOperationName(op)+"」已属于限流组「"+row.GroupName+"」,同一账户下每个操作只能属于一个启用的组") + } + } + } + } + return nil +} + +func applyGroupRequest(row *model.CarrierRateLimitGroup, req *dto.SaveRateLimitGroupRequest) { + row.GroupCode = strings.TrimSpace(req.GroupCode) + row.GroupName = strings.TrimSpace(req.GroupName) + row.MaxQPS = req.MaxQPS + row.ReservedQPS = req.ReservedQPS + row.Operations = encodeStrings(uniqueStrings(req.Operations)) + row.HighPriorityOperations = encodeStrings(uniqueStrings(req.HighPriorityOperations)) + if req.Status != nil { + row.Status = *req.Status + } +} + +func groupState(row *model.CarrierRateLimitGroup) map[string]any { + ops, _ := gatewaylimit.DecodeOperations(row.Operations) + high, _ := gatewaylimit.DecodeOperations(row.HighPriorityOperations) + return map[string]any{"group_code": row.GroupCode, "group_name": row.GroupName, "max_qps": row.MaxQPS, + "reserved_qps": row.ReservedQPS, "operations": ops, "high_priority_operations": high, "status": row.Status} +} + +func groupAudit(action, summary string, row *model.CarrierRateLimitGroup, operatorID uint, before map[string]any) auditinfra.PollingInput { + var carrierID any + if row.CarrierID != nil { + carrierID = *row.CarrierID + } + return auditinfra.PollingInput{ + ActionCode: action, Summary: summary, ResourceType: constants.AuditResourceRateLimitGroup, + ResourceID: row.ID, ResourceKey: "rate_limit_group:" + uintString(row.ID), DisplayName: row.GroupName, + OperatorID: operatorID, + IdentitySnapshot: map[string]any{"id": row.ID, "carrier_id": carrierID, "group_code": row.GroupCode, + "group_name": row.GroupName, "max_qps": row.MaxQPS, "reserved_qps": row.ReservedQPS, "status": row.Status}, + BeforeData: before, AfterData: groupState(row), + } +} + +func toRateLimitGroupResponse(row *model.CarrierRateLimitGroup, names map[uint]string) dto.RateLimitGroupResponse { + ops, _ := gatewaylimit.DecodeOperations(row.Operations) + high, _ := gatewaylimit.DecodeOperations(row.HighPriorityOperations) + resp := dto.RateLimitGroupResponse{ + ID: row.ID, CarrierID: row.CarrierID, GroupCode: row.GroupCode, GroupName: row.GroupName, MaxQPS: row.MaxQPS, + Operations: nonNil(ops), HighPriorityOperations: nonNil(high), ReservedQPS: row.ReservedQPS, + Status: row.Status, StatusName: enabledName(row.Status), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + } + if row.CarrierID != nil { + resp.CarrierName = names[*row.CarrierID] + } + return resp +} + +// ListRules 分页查询上游错误分类规则。 +func (s *GatewayRateLimitService) ListRules(ctx context.Context, req *dto.GatewayErrorRuleListRequest) (*dto.GatewayErrorRulePageResult, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + page, size := normalizePage(req.Page, req.PageSize) + rows, total, err := s.rules.List(ctx, postgres.ErrorClassificationFilter{Category: req.Category, Status: req.Status, Keyword: strings.TrimSpace(req.Keyword), Page: page, PageSize: size}) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上游错误分类规则失败") + } + items := make([]dto.GatewayErrorRuleResponse, 0, len(rows)) + for i := range rows { + items = append(items, toRuleResponse(&rows[i])) + } + return &dto.GatewayErrorRulePageResult{List: items, Total: total, Page: page, PageSize: size}, nil +} + +// CreateRule 新建上游错误分类规则。 +func (s *GatewayRateLimitService) CreateRule(ctx context.Context, req *dto.SaveGatewayErrorRuleRequest) (*dto.GatewayErrorRuleResponse, error) { + operatorID, err := requireSuperAdminWriter(ctx) + if err != nil { + return nil, err + } + if err := validateRule(req); err != nil { + return nil, err + } + row := &model.GatewayErrorClassification{Status: constants.RateLimitGroupStatusEnabled} + applyRuleRequest(row, req) + row.Creator, row.Updater = operatorID, operatorID + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Create(row).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "新建上游错误分类规则失败") + } + return writePollingAudit(ctx, tx, s.auditWriter, ruleAudit(constants.AuditActionGatewayErrorRuleCreated, "创建上游错误分类规则", row, operatorID, nil)) + }) + if err != nil { + return nil, err + } + s.invalidateRules() + resp := toRuleResponse(row) + return &resp, nil +} + +// UpdateRule 修改上游错误分类规则。 +func (s *GatewayRateLimitService) UpdateRule(ctx context.Context, req *dto.SaveGatewayErrorRuleRequest) (*dto.GatewayErrorRuleResponse, error) { + operatorID, err := requireSuperAdminWriter(ctx) + if err != nil { + return nil, err + } + if err := validateRule(req); err != nil { + return nil, err + } + row, err := s.loadRule(ctx, req.ID) + if err != nil { + return nil, err + } + before := ruleState(row) + applyRuleRequest(row, req) + 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, ruleAudit(constants.AuditActionGatewayErrorRuleUpdated, "更新上游错误分类规则", row, operatorID, before)) + }) + if err != nil { + return nil, err + } + s.invalidateRules() + resp := toRuleResponse(row) + return &resp, nil +} + +// UpdateRuleStatus 启停上游错误分类规则。 +func (s *GatewayRateLimitService) UpdateRuleStatus(ctx context.Context, id uint, status int) error { + operatorID, err := requireSuperAdminWriter(ctx) + if err != nil { + return err + } + row, err := s.loadRule(ctx, id) + if err != nil { + return err + } + before := ruleState(row) + row.Status, row.Updater = status, operatorID + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Model(&model.GatewayErrorClassification{}).Where("id = ?", id). + Updates(map[string]any{"status": status, "updater": operatorID}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新上游错误分类规则状态失败") + } + return writePollingAudit(ctx, tx, s.auditWriter, ruleAudit(constants.AuditActionGatewayErrorRuleStatusUpdated, "更新上游错误分类规则状态", row, operatorID, before)) + }) + if err != nil { + return err + } + s.invalidateRules() + return nil +} + +// DeleteRule 删除上游错误分类规则。 +func (s *GatewayRateLimitService) DeleteRule(ctx context.Context, id uint) error { + operatorID, err := requireSuperAdminWriter(ctx) + if err != nil { + return err + } + row, err := s.loadRule(ctx, id) + if err != nil { + return err + } + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Delete(&model.GatewayErrorClassification{}, id).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "删除上游错误分类规则失败") + } + return writePollingAudit(ctx, tx, s.auditWriter, ruleAudit(constants.AuditActionGatewayErrorRuleDeleted, "删除上游错误分类规则", row, operatorID, ruleState(row))) + }) + if err != nil { + return err + } + s.invalidateRules() + return nil +} + +// TestRule 用已启用规则(可附带一条未保存的草稿)对上游返回信息分类,不写任何数据。 +func (s *GatewayRateLimitService) TestRule(ctx context.Context, req *dto.TestGatewayErrorRuleRequest) (*dto.TestGatewayErrorRuleResponse, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + rows, err := s.rules.ListEnabled(ctx) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上游错误分类规则失败") + } + if req.Draft != nil { + if err := validateRule(req.Draft); err != nil { + return nil, err + } + draft := model.GatewayErrorClassification{} + applyRuleRequest(&draft, req.Draft) + rows = append(rows, draft) + sort.SliceStable(rows, func(i, j int) bool { return rows[i].Priority < rows[j].Priority }) + } + result := gatewaylimit.ClassifyWith(rows, req.Operation, req.Message) + matched := result.Keyword != "" + return &dto.TestGatewayErrorRuleResponse{ + Category: result.Category, CategoryName: constants.GatewayErrorClassName(result.Category), + RuleID: result.RuleID, Keyword: result.Keyword, MaybeAccepted: result.MaybeAccepted, Matched: matched, + }, nil +} + +func (s *GatewayRateLimitService) loadRule(ctx context.Context, id uint) (*model.GatewayErrorClassification, error) { + row, err := s.rules.Get(ctx, id) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上游错误分类规则失败") + } + if row == nil { + return nil, errors.New(errors.CodeNotFound, "上游错误分类规则不存在") + } + return row, nil +} + +func validateRule(req *dto.SaveGatewayErrorRuleRequest) error { + if strings.TrimSpace(req.Keyword) == "" { + return errors.New(errors.CodeInvalidParam, "匹配关键字不能为空") + } + if req.MatchType == constants.GatewayMatchRegex { + if _, err := regexp.Compile(req.Keyword); err != nil { + return errors.New(errors.CodeInvalidParam, "正则表达式不合法") + } + } + return nil +} + +func applyRuleRequest(row *model.GatewayErrorClassification, req *dto.SaveGatewayErrorRuleRequest) { + row.Keyword = strings.TrimSpace(req.Keyword) + row.MatchType = req.MatchType + row.Category = req.Category + row.Operations = encodeStrings(uniqueStrings(req.Operations)) + row.Priority = req.Priority + if row.Priority == 0 { + row.Priority = 100 + } + row.MaybeAccepted = req.MaybeAccepted + row.Remark = strings.TrimSpace(req.Remark) + if req.Status != nil { + row.Status = *req.Status + } +} + +func ruleState(row *model.GatewayErrorClassification) map[string]any { + ops, _ := gatewaylimit.DecodeOperations(row.Operations) + return map[string]any{"keyword": row.Keyword, "match_type": row.MatchType, "category": row.Category, + "operations": ops, "priority": row.Priority, "maybe_accepted": row.MaybeAccepted, "status": row.Status} +} + +func ruleAudit(action, summary string, row *model.GatewayErrorClassification, operatorID uint, before map[string]any) auditinfra.PollingInput { + return auditinfra.PollingInput{ + ActionCode: action, Summary: summary, ResourceType: constants.AuditResourceGatewayErrorRule, + ResourceID: row.ID, ResourceKey: "gateway_error_rule:" + uintString(row.ID), DisplayName: row.Keyword, + OperatorID: operatorID, + IdentitySnapshot: map[string]any{"id": row.ID, "keyword": row.Keyword, "match_type": row.MatchType, + "category": row.Category, "priority": row.Priority, "status": row.Status}, + BeforeData: before, AfterData: ruleState(row), + } +} + +func toRuleResponse(row *model.GatewayErrorClassification) dto.GatewayErrorRuleResponse { + ops, _ := gatewaylimit.DecodeOperations(row.Operations) + return dto.GatewayErrorRuleResponse{ + ID: row.ID, Keyword: row.Keyword, MatchType: row.MatchType, Category: row.Category, + CategoryName: constants.GatewayErrorClassName(row.Category), Operations: nonNil(ops), Priority: row.Priority, + MaybeAccepted: row.MaybeAccepted, Status: row.Status, Remark: row.Remark, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + } +} + +// carrierNames 返回运营商账户 ID → 名称映射(含已删除账户,便于展示历史配置)。 +func (s *GatewayRateLimitService) carrierNames(ctx context.Context) (map[uint]string, error) { + var carriers []model.Carrier + if err := s.db.WithContext(ctx).Unscoped().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 +} + +func sameCarrier(a, b *uint) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func uniqueStrings(values []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func encodeStrings(values []string) datatypes.JSON { + if values == nil { + values = []string{} + } + raw, err := sonic.Marshal(values) + if err != nil { + return datatypes.JSON("[]") + } + return datatypes.JSON(raw) +} + +func nonNil(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +func enabledName(status int) string { + if status == constants.RateLimitGroupStatusEnabled { + return "启用" + } + return "停用" +} + +func uintString(value uint) string { + return strconv.FormatUint(uint64(value), 10) +} + +// invalidateGroups 让本进程限流组缓存立即失效;其他进程在缓存时长(数秒)内自动刷新。 +func (s *GatewayRateLimitService) invalidateGroups() { + if s.runtime != nil { + s.invalidateGroups() + } +} + +// invalidateRules 让本进程错误分类规则缓存立即失效。 +func (s *GatewayRateLimitService) invalidateRules() { + if s.runtime != nil { + s.invalidateRules() + } +} diff --git a/internal/store/postgres/gateway_rate_limit_store.go b/internal/store/postgres/gateway_rate_limit_store.go new file mode 100644 index 00000000..1edd1974 --- /dev/null +++ b/internal/store/postgres/gateway_rate_limit_store.go @@ -0,0 +1,258 @@ +package postgres + +import ( + "context" + stderrors "errors" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// CarrierRateLimitGroupStore 运营商账户网关限流组存储。 +type CarrierRateLimitGroupStore struct { + db *gorm.DB +} + +// NewCarrierRateLimitGroupStore 创建限流组存储。 +func NewCarrierRateLimitGroupStore(db *gorm.DB) *CarrierRateLimitGroupStore { + return &CarrierRateLimitGroupStore{db: db} +} + +// RateLimitGroupFilter 是限流组列表筛选条件;CarrierID 为 nil 不限,OnlyGlobal 只返回全局组。 +type RateLimitGroupFilter struct { + CarrierID *uint + OnlyGlobal bool + Status *int + Page int + PageSize int +} + +// List 分页查询限流组。 +func (s *CarrierRateLimitGroupStore) List(ctx context.Context, filter RateLimitGroupFilter) ([]model.CarrierRateLimitGroup, int64, error) { + query := s.db.WithContext(ctx).Model(&model.CarrierRateLimitGroup{}) + if filter.OnlyGlobal { + query = query.Where("carrier_id IS NULL") + } else if filter.CarrierID != nil { + query = query.Where("carrier_id = ?", *filter.CarrierID) + } + if filter.Status != nil { + query = query.Where("status = ?", *filter.Status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []model.CarrierRateLimitGroup + err := query.Order("carrier_id NULLS FIRST, id").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error + return rows, total, err +} + +// ListEnabled 返回全部启用的限流组,供限流器加载。 +func (s *CarrierRateLimitGroupStore) ListEnabled(ctx context.Context) ([]model.CarrierRateLimitGroup, error) { + var rows []model.CarrierRateLimitGroup + err := s.db.WithContext(ctx).Where("status = ?", 1).Order("id").Find(&rows).Error + return rows, err +} + +// ListByCarrier 返回同一账户(nil 为全局组)下的全部未删除组,供保存时校验操作归属。 +func (s *CarrierRateLimitGroupStore) ListByCarrier(ctx context.Context, carrierID *uint) ([]model.CarrierRateLimitGroup, error) { + query := s.db.WithContext(ctx) + if carrierID == nil { + query = query.Where("carrier_id IS NULL") + } else { + query = query.Where("carrier_id = ?", *carrierID) + } + var rows []model.CarrierRateLimitGroup + err := query.Find(&rows).Error + return rows, err +} + +// Get 按 ID 查询限流组;不存在返回 nil、nil。 +func (s *CarrierRateLimitGroupStore) Get(ctx context.Context, id uint) (*model.CarrierRateLimitGroup, error) { + var row model.CarrierRateLimitGroup + err := s.db.WithContext(ctx).First(&row, id).Error + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +// Create 新建限流组。 +func (s *CarrierRateLimitGroupStore) Create(ctx context.Context, row *model.CarrierRateLimitGroup) error { + return s.db.WithContext(ctx).Create(row).Error +} + +// Save 保存限流组全部字段。 +func (s *CarrierRateLimitGroupStore) Save(ctx context.Context, row *model.CarrierRateLimitGroup) error { + return s.db.WithContext(ctx).Save(row).Error +} + +// Delete 软删除限流组。 +func (s *CarrierRateLimitGroupStore) Delete(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&model.CarrierRateLimitGroup{}, id).Error +} + +// GatewayErrorClassificationStore 上游错误分类规则存储。 +type GatewayErrorClassificationStore struct { + db *gorm.DB +} + +// NewGatewayErrorClassificationStore 创建错误分类规则存储。 +func NewGatewayErrorClassificationStore(db *gorm.DB) *GatewayErrorClassificationStore { + return &GatewayErrorClassificationStore{db: db} +} + +// ErrorClassificationFilter 是错误分类规则列表筛选条件。 +type ErrorClassificationFilter struct { + Category string + Status *int + Keyword string + Page int + PageSize int +} + +// List 分页查询错误分类规则。 +func (s *GatewayErrorClassificationStore) List(ctx context.Context, filter ErrorClassificationFilter) ([]model.GatewayErrorClassification, int64, error) { + query := s.db.WithContext(ctx).Model(&model.GatewayErrorClassification{}) + if filter.Category != "" { + query = query.Where("category = ?", filter.Category) + } + if filter.Status != nil { + query = query.Where("status = ?", *filter.Status) + } + if filter.Keyword != "" { + query = query.Where("keyword LIKE ?", "%"+filter.Keyword+"%") + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []model.GatewayErrorClassification + err := query.Order("priority, id").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error + return rows, total, err +} + +// ListEnabled 返回全部启用规则(按优先级),供分类器加载。 +func (s *GatewayErrorClassificationStore) ListEnabled(ctx context.Context) ([]model.GatewayErrorClassification, error) { + var rows []model.GatewayErrorClassification + err := s.db.WithContext(ctx).Where("status = ?", 1).Order("priority, id").Find(&rows).Error + return rows, err +} + +// Get 按 ID 查询规则;不存在返回 nil、nil。 +func (s *GatewayErrorClassificationStore) Get(ctx context.Context, id uint) (*model.GatewayErrorClassification, error) { + var row model.GatewayErrorClassification + err := s.db.WithContext(ctx).First(&row, id).Error + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +// Create 新建规则。 +func (s *GatewayErrorClassificationStore) Create(ctx context.Context, row *model.GatewayErrorClassification) error { + return s.db.WithContext(ctx).Create(row).Error +} + +// Save 保存规则全部字段。 +func (s *GatewayErrorClassificationStore) Save(ctx context.Context, row *model.GatewayErrorClassification) error { + return s.db.WithContext(ctx).Save(row).Error +} + +// Delete 软删除规则。 +func (s *GatewayErrorClassificationStore) Delete(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&model.GatewayErrorClassification{}, id).Error +} + +// CardStopResumeRecordStore 停复机调用记录存储(分区表)。 +type CardStopResumeRecordStore struct { + db *gorm.DB +} + +// NewCardStopResumeRecordStore 创建停复机记录存储。 +func NewCardStopResumeRecordStore(db *gorm.DB) *CardStopResumeRecordStore { + return &CardStopResumeRecordStore{db: db} +} + +// StopResumeRecordFilter 是停复机记录列表筛选条件;时间范围由调用方保证必填且有上限。 +type StopResumeRecordFilter struct { + CardID *uint + ICCID string + CarrierID *uint + Action string + Source string + Result string + ErrorCategory string + StartTime time.Time + EndTime time.Time + Page int + PageSize int +} + +// Create 写入一条停复机记录。 +func (s *CardStopResumeRecordStore) Create(ctx context.Context, row *model.CardStopResumeRecord) error { + return s.db.WithContext(ctx).Create(row).Error +} + +// List 按条件分页查询停复机记录,按时间倒序。 +func (s *CardStopResumeRecordStore) List(ctx context.Context, filter StopResumeRecordFilter) ([]model.CardStopResumeRecord, int64, error) { + query := s.db.WithContext(ctx).Model(&model.CardStopResumeRecord{}). + Where("created_at >= ? AND created_at < ?", filter.StartTime, filter.EndTime) + if filter.CardID != nil { + query = query.Where("card_id = ?", *filter.CardID) + } + if filter.ICCID != "" { + query = query.Where("iccid = ?", filter.ICCID) + } + if filter.CarrierID != nil { + query = query.Where("carrier_id = ?", *filter.CarrierID) + } + if filter.Action != "" { + query = query.Where("action = ?", filter.Action) + } + if filter.Source != "" { + query = query.Where("source = ?", filter.Source) + } + if filter.Result != "" { + query = query.Where("result = ?", filter.Result) + } + if filter.ErrorCategory != "" { + query = query.Where("error_category = ?", filter.ErrorCategory) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []model.CardStopResumeRecord + err := query.Order("created_at DESC, id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error + return rows, total, err +} + +// stopResumeRecordPartitionName 返回某月分区表名(上海时区月份)。 +func stopResumeRecordPartitionName(month time.Time) string { + return "tb_card_stop_resume_record_" + month.Format("200601") +} + +// EnsureMonthPartition 创建 month 所在月的分区(已存在则跳过);month 须为上海时区月初。 +func (s *CardStopResumeRecordStore) EnsureMonthPartition(ctx context.Context, month time.Time) error { + next := month.AddDate(0, 1, 0) + sql := fmt.Sprintf( + "CREATE TABLE IF NOT EXISTS %s PARTITION OF tb_card_stop_resume_record FOR VALUES FROM ('%s') TO ('%s')", + stopResumeRecordPartitionName(month), month.Format(time.RFC3339), next.Format(time.RFC3339)) + return s.db.WithContext(ctx).Exec(sql).Error +} + +// DropMonthPartition 删除 month 所在月的分区(不存在则跳过),用于按保留期清理。 +func (s *CardStopResumeRecordStore) DropMonthPartition(ctx context.Context, month time.Time) error { + sql := fmt.Sprintf("DROP TABLE IF EXISTS %s", stopResumeRecordPartitionName(month)) + return s.db.WithContext(ctx).Exec(sql).Error +} diff --git a/internal/task/polling_base.go b/internal/task/polling_base.go index fcb8f7f5..ba58dd86 100644 --- a/internal/task/polling_base.go +++ b/internal/task/polling_base.go @@ -70,6 +70,24 @@ type PollingBase struct { priorityStore *postgres.PollingPriorityItemStore priorityAudit *audit.Writer scopeChecker *polling.PollingLifecycleService + rateLimitedDelay func() time.Duration // 卡网关调用被运营商账户限流后的固定重入队延迟 +} + +// SetRateLimitedDelay 设置被运营商账户限流后的重入队延迟来源(受控系统配置,即时生效)。 +func (b *PollingBase) SetRateLimitedDelay(delay func() time.Duration) { + b.rateLimitedDelay = delay +} + +// requeueRateLimited 卡网关调用被运营商账户限流拒绝时请求并未发出:不计失败、不写失败统计与失败审计, +// 按固定短延迟重入队,不在任务内等待名额(避免占满与其他业务共用的 Asynq 协程)。 +func (b *PollingBase) requeueRateLimited(cardID uint, taskType string) error { + delay := 5 * time.Second + if b.rateLimitedDelay != nil { + if configured := b.rateLimitedDelay(); configured > 0 { + delay = configured + } + } + return b.requeueCardAt(cardID, taskType, time.Now().Add(delay)) } // NewPollingBase 创建轮询共享基类 diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go index 174bff67..354452b6 100644 --- a/internal/task/polling_carddata_handler.go +++ b/internal/task/polling_carddata_handler.go @@ -82,7 +82,11 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e } priority.MarkExecution(ctx) attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling) - result, err := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID}) + result, err := h.gateway.QueryFlow(gateway.WithCarrier(ctx, card.CarrierID), &gateway.FlowQueryReq{CardNo: card.ICCID}) + if gateway.IsRateLimited(err) { + priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试") + return h.base.requeueRateLimited(cardID, constants.TaskTypePollingCarddata) + } if err != nil { _ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败", priority) priority.FailRetryable(ctx, "查询流量失败") diff --git a/internal/task/polling_cardstatus_handler.go b/internal/task/polling_cardstatus_handler.go index 913dd04a..5613bc36 100644 --- a/internal/task/polling_cardstatus_handler.go +++ b/internal/task/polling_cardstatus_handler.go @@ -82,7 +82,11 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task) } priority.MarkExecution(ctx) attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling) - result, err := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) + result, err := h.gateway.QueryCardStatus(gateway.WithCarrier(ctx, card.CarrierID), &gateway.CardStatusReq{CardNo: card.ICCID}) + if gateway.IsRateLimited(err) { + priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试") + return h.base.requeueRateLimited(cardID, constants.TaskTypePollingCardStatus) + } if err != nil { _ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询卡状态失败", priority) priority.FailRetryable(ctx, "查询卡状态失败") diff --git a/internal/task/polling_integration_log.go b/internal/task/polling_integration_log.go index 08b6515b..7ed48624 100644 --- a/internal/task/polling_integration_log.go +++ b/internal/task/polling_integration_log.go @@ -39,9 +39,9 @@ func recordGatewayAttempt(ctx context.Context, repository *integrationlog.Reposi return err } -// recordPriorityGatewayAttempt 记录外部交互日志,并把真正落库的日志标识回写优先项。 -// 成功且无变化的轮询按既有语义不落库,因此只有落库成功才回写标识,避免悬挂引用; -// 其余调用方(无优先项在途)继续使用 recordGatewayAttempt。 +// recordPriorityGatewayAttempt 只在本轮同时是一次优先执行时写外部交互日志,并把日志标识回写优先项。 +// 普通轮询查询(无优先项在途)不再写集成日志:失败原因与调用量改由网关调用分钟计数与卡最近错误保留 +// (add-carrier-gateway-rate-limit);优先执行仍按优先轮询可追溯要求保留日志引用。 func recordPriorityGatewayAttempt( ctx context.Context, repository *integrationlog.Repository, @@ -51,6 +51,9 @@ func recordPriorityGatewayAttempt( summary string, round *pollingPriorityRound, ) error { + if round == nil { + return nil + } if err := recordGatewayAttempt(ctx, repository, attempt, result, changed, summary); err != nil { return err } diff --git a/internal/task/polling_realname_handler.go b/internal/task/polling_realname_handler.go index b9a85bf4..d43e8bf0 100644 --- a/internal/task/polling_realname_handler.go +++ b/internal/task/polling_realname_handler.go @@ -73,7 +73,11 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e } priority.MarkExecution(ctx) attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling) - result, err := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) + result, err := h.gateway.QueryRealnameStatus(gateway.WithCarrier(ctx, card.CarrierID), &gateway.CardStatusReq{CardNo: card.ICCID}) + if gateway.IsRateLimited(err) { + priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试") + return h.base.requeueRateLimited(cardID, constants.TaskTypePollingRealname) + } if err != nil { _ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询实名状态失败", priority) priority.FailRetryable(ctx, "查询实名状态失败") diff --git a/migrations/000243_add_carrier_rate_limit_group.down.sql b/migrations/000243_add_carrier_rate_limit_group.down.sql new file mode 100644 index 00000000..9b65236e --- /dev/null +++ b/migrations/000243_add_carrier_rate_limit_group.down.sql @@ -0,0 +1,6 @@ +-- 回滚运营商账户网关限流组:删除 tb_carrier_rate_limit_group,与 up 严格倒序成对。 +-- 删除后限流器对全部账户回落默认 QPS(只统计模式下无行为影响)。 + +DROP INDEX IF EXISTS idx_carrier_rate_limit_group_carrier; +DROP INDEX IF EXISTS uk_carrier_rate_limit_group_code; +DROP TABLE IF EXISTS tb_carrier_rate_limit_group; diff --git a/migrations/000243_add_carrier_rate_limit_group.up.sql b/migrations/000243_add_carrier_rate_limit_group.up.sql new file mode 100644 index 00000000..427ea72e --- /dev/null +++ b/migrations/000243_add_carrier_rate_limit_group.up.sql @@ -0,0 +1,44 @@ +-- 运营商账户网关限流组(add-carrier-gateway-rate-limit)。 +-- 背景:上游按运营商账户(tb_carrier 一行即一个上游账户)限频,现有轮询并发计数不以账户为单位且数值无依据。 +-- 本表按账户配置卡的 5 个网关操作(查流量 / 查状态 / 查实名 / 停机 / 复机)的每秒请求上限。 +-- +-- 设计选择: +-- 1. carrier_id 为空表示全局组(可选,用于聚合网关总限流);同一账户下同一操作只能属于一个组,由应用层保存时校验。 +-- 2. operations / high_priority_operations 为 JSON 字符串数组,取值见 pkg/constants 网关操作常量。 +-- 3. 停用(status=0)的组不参与判断,组内操作回落为默认 QPS,不存在不限流的自动调用。 +-- 4. 不使用数据库外键(ENG-MODEL-001);软删除,后台维护。 + +CREATE TABLE tb_carrier_rate_limit_group ( + id BIGSERIAL PRIMARY KEY, + carrier_id BIGINT, + group_code VARCHAR(50) NOT NULL, + group_name VARCHAR(100) NOT NULL, + max_qps INTEGER NOT NULL, + operations JSONB NOT NULL DEFAULT '[]'::jsonb, + high_priority_operations JSONB NOT NULL DEFAULT '[]'::jsonb, + reserved_qps INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT ck_carrier_rate_limit_group_qps CHECK (max_qps > 0 AND reserved_qps >= 0 AND reserved_qps < max_qps), + CONSTRAINT ck_carrier_rate_limit_group_status CHECK (status IN (0, 1)) +); + +CREATE UNIQUE INDEX uk_carrier_rate_limit_group_code + ON tb_carrier_rate_limit_group (COALESCE(carrier_id, 0), group_code) WHERE deleted_at IS NULL; +CREATE INDEX idx_carrier_rate_limit_group_carrier ON tb_carrier_rate_limit_group (carrier_id) WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_carrier_rate_limit_group IS '运营商账户网关限流组:按账户配置卡网关操作的每秒请求上限'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.carrier_id IS '运营商账户ID(tb_carrier.id),为空表示全局组'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.group_code IS '组标识,同一账户内唯一'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.group_name IS '组名称'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.max_qps IS '该组每秒请求上限'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.operations IS '组内操作:query_flow/query_status/query_realname/stop/start'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.high_priority_operations IS '组内高优先级操作,可用满 max_qps'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.reserved_qps IS '预留给高优先级操作的额度,普通操作最多使用 max_qps - reserved_qps'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.status IS '状态 0-停用 1-启用;停用后组内操作回落为默认 QPS'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.creator IS '创建人ID'; +COMMENT ON COLUMN tb_carrier_rate_limit_group.updater IS '更新人ID'; diff --git a/migrations/000244_add_gateway_error_classification.down.sql b/migrations/000244_add_gateway_error_classification.down.sql new file mode 100644 index 00000000..cadc2cda --- /dev/null +++ b/migrations/000244_add_gateway_error_classification.down.sql @@ -0,0 +1,5 @@ +-- 回滚上游错误分类规则:删除 tb_gateway_error_classification(含初始规则),与 up 严格倒序成对。 +-- 删除后分类器回落为"未命中规则即业务错误",后台新增的规则一并丢失。 + +DROP INDEX IF EXISTS idx_gateway_error_classification_active; +DROP TABLE IF EXISTS tb_gateway_error_classification; diff --git a/migrations/000244_add_gateway_error_classification.up.sql b/migrations/000244_add_gateway_error_classification.up.sql new file mode 100644 index 00000000..f8fbb0d3 --- /dev/null +++ b/migrations/000244_add_gateway_error_classification.up.sql @@ -0,0 +1,61 @@ +-- 上游错误分类规则(add-carrier-gateway-rate-limit)。 +-- 背景:网关对各类上游错误统一返回 code=400,失败原因只拼在错误字符串里,无法区分频率限制、终态、临时故障与业务错误。 +-- 本表把上游返回信息按关键字归入类别,供停复机与轮询的失败处理、告警与统计使用;后台可逐条增补。 +-- +-- 设计选择: +-- 1. match_type:contains(包含)/ prefix(前缀)/ regex(正则);operations 为空数组表示适用全部操作。 +-- 2. priority 越小越先匹配;未命中任何规则的上游业务错误归为业务错误(business)。 +-- 3. maybe_accepted 标记"上游可能已受理"(如"调用成功-5分钟内最多重复调用3次"),停复机下次执行前先查卡状态确认。 +-- 4. 只有明确表示卡已处于目标状态的返回才可归入 already_target;老系统结束停机任务的关键字("已存在""密码错误"等) +-- 不代表停机成功,初始数据不据此归类为 already_target。 +-- 5. 初始规则来自《轮询按运营商账户限流技术方案》第三部分 6.2 节(生产 2026-10-22 实测返回)。 + +CREATE TABLE tb_gateway_error_classification ( + id BIGSERIAL PRIMARY KEY, + keyword VARCHAR(200) NOT NULL, + match_type VARCHAR(20) NOT NULL DEFAULT 'contains', + category VARCHAR(30) NOT NULL, + operations JSONB NOT NULL DEFAULT '[]'::jsonb, + priority INTEGER NOT NULL DEFAULT 100, + maybe_accepted BOOLEAN NOT NULL DEFAULT FALSE, + status SMALLINT NOT NULL DEFAULT 1, + remark VARCHAR(500) NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT ck_gateway_error_classification_match CHECK (match_type IN ('contains', 'prefix', 'regex')), + CONSTRAINT ck_gateway_error_classification_category CHECK (category IN ('rate_limited_upstream', 'terminal', 'already_target', 'transient', 'business', 'account_config')), + CONSTRAINT ck_gateway_error_classification_status CHECK (status IN (0, 1)) +); + +CREATE INDEX idx_gateway_error_classification_active ON tb_gateway_error_classification (status, priority) WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_gateway_error_classification IS '上游错误分类规则:按关键字把网关返回信息归入失败类别'; +COMMENT ON COLUMN tb_gateway_error_classification.keyword IS '匹配关键字或正则'; +COMMENT ON COLUMN tb_gateway_error_classification.match_type IS '匹配方式 contains-包含 prefix-前缀 regex-正则'; +COMMENT ON COLUMN tb_gateway_error_classification.category IS '类别 rate_limited_upstream-上游频率限制 terminal-终态不可操作 already_target-已是目标状态 transient-临时故障 business-业务错误 account_config-账户配置错误'; +COMMENT ON COLUMN tb_gateway_error_classification.operations IS '适用操作,空数组表示全部:query_flow/query_status/query_realname/stop/start'; +COMMENT ON COLUMN tb_gateway_error_classification.priority IS '匹配优先级,越小越先匹配'; +COMMENT ON COLUMN tb_gateway_error_classification.maybe_accepted IS '上游可能已受理(如返回含调用成功、操作进行中、请求被合并)'; +COMMENT ON COLUMN tb_gateway_error_classification.status IS '状态 0-停用 1-启用'; +COMMENT ON COLUMN tb_gateway_error_classification.remark IS '备注'; + +INSERT INTO tb_gateway_error_classification (keyword, match_type, category, priority, maybe_accepted, remark) VALUES + ('仅允许一次', 'contains', 'rate_limited_upstream', 10, TRUE, '3分钟内同一张卡相同操作仅允许一次'), + ('最多重复调用', 'contains', 'rate_limited_upstream', 10, TRUE, '调用成功-5分钟内最多重复调用3次'), + ('只允许调用', 'contains', 'rate_limited_upstream', 10, TRUE, '调用成功-卡状态变更每十分钟只允许调用1次'), + ('请求被合并', 'contains', 'rate_limited_upstream', 10, TRUE, '请求被合并,以最新一次为准'), + ('操作进行中', 'contains', 'rate_limited_upstream', 10, TRUE, '操作进行中'), + ('超频访问', 'contains', 'rate_limited_upstream', 10, FALSE, '超频访问'), + ('已销户', 'contains', 'terminal', 20, FALSE, '当前用户已销户,无法操作'), + ('仅支持风险复机', 'contains', 'terminal', 20, FALSE, '当前卡为风险停机状态,仅支持风险复机'), + ('号码封存', 'contains', 'terminal', 20, FALSE, '用户状态是物联网号码封存,禁止办理物联网停机'), + ('密码错误', 'contains', 'account_config', 30, FALSE, '账户凭证问题,影响该账户全部卡'), + ('参数缺失', 'contains', 'account_config', 30, FALSE, '账户接入参数问题'), + ('token 为空', 'contains', 'transient', 40, FALSE, '湖北省网获取到的 token 为空'), + ('token为空', 'contains', 'transient', 40, FALSE, '获取到的 token 为空'), + ('当前用户不允许办理', 'contains', 'business', 50, FALSE, '不视为已是目标状态'), + ('防范实名', 'contains', 'business', 50, FALSE, '不视为已是目标状态'), + ('请求失败', 'contains', 'transient', 90, FALSE, '上游通用失败'); diff --git a/migrations/000245_add_card_stop_resume_record.down.sql b/migrations/000245_add_card_stop_resume_record.down.sql new file mode 100644 index 00000000..cb14af13 --- /dev/null +++ b/migrations/000245_add_card_stop_resume_record.down.sql @@ -0,0 +1,7 @@ +-- 回滚停复机调用记录:删除分区表 tb_card_stop_resume_record(含全部分区),与 up 严格倒序成对。 +-- 不可逆说明(ENG-MIG-001 例外条件):记录删除后无法重建;只在代码已退回写统一审计、且无需追溯停复机调用时执行。 + +DROP INDEX IF EXISTS idx_card_stop_resume_record_result; +DROP INDEX IF EXISTS idx_card_stop_resume_record_carrier; +DROP INDEX IF EXISTS idx_card_stop_resume_record_card; +DROP TABLE IF EXISTS tb_card_stop_resume_record CASCADE; diff --git a/migrations/000245_add_card_stop_resume_record.up.sql b/migrations/000245_add_card_stop_resume_record.up.sql new file mode 100644 index 00000000..32b9a93f --- /dev/null +++ b/migrations/000245_add_card_stop_resume_record.up.sql @@ -0,0 +1,71 @@ +-- 停复机调用记录(add-carrier-gateway-rate-limit)。 +-- 背景:系统自动停复机的上游调用此前写统一审计 tb_audit_event 与集成日志 tb_integration_log(生产约 1,500 万行、积压严重), +-- 且错误原因只在审计 error_summary 中可见。改为每次实际调用上游停机或复机写一条本表记录。 +-- +-- 设计选择: +-- 1. 按 created_at 月分区(声明式 RANGE 分区),主键 (id, created_at);建 DEFAULT 分区兜底,任何月份插入都不因分区缺失失败。 +-- 2. 本迁移创建当前月起 4 个月的分区;之后由 Worker 定时任务提前创建未来分区,并按保留期整区删除过期分区。 +-- 3. 记录写入与卡状态回写不在同一事务,写入失败只记日志与计数。 +-- 4. provider_message 为脱敏截断后的上游信息;不使用数据库外键(ENG-MODEL-001)。 + +CREATE TABLE tb_card_stop_resume_record ( + id BIGSERIAL, + card_id BIGINT NOT NULL, + iccid VARCHAR(30) NOT NULL DEFAULT '', + carrier_id BIGINT NOT NULL DEFAULT 0, + action VARCHAR(10) NOT NULL, + source VARCHAR(50) NOT NULL DEFAULT '', + scene VARCHAR(50) NOT NULL DEFAULT '', + reason VARCHAR(50) NOT NULL DEFAULT '', + command_id VARCHAR(64) NOT NULL DEFAULT '', + attempt INTEGER NOT NULL DEFAULT 1, + result VARCHAR(20) NOT NULL, + error_category VARCHAR(30) NOT NULL DEFAULT '', + provider_code VARCHAR(30) NOT NULL DEFAULT '', + provider_message VARCHAR(500) NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (id, created_at), + CONSTRAINT ck_card_stop_resume_record_action CHECK (action IN ('stop', 'start')), + CONSTRAINT ck_card_stop_resume_record_result CHECK (result IN ('success', 'failed', 'unknown', 'already_target', 'skipped')) +) PARTITION BY RANGE (created_at); + +CREATE TABLE tb_card_stop_resume_record_default PARTITION OF tb_card_stop_resume_record DEFAULT; + +DO $$ +DECLARE + month_start DATE := date_trunc('month', NOW() AT TIME ZONE 'Asia/Shanghai')::date; + i INTEGER; + from_date DATE; + to_date DATE; +BEGIN + FOR i IN 0..3 LOOP + from_date := (month_start + make_interval(months => i))::date; + to_date := (month_start + make_interval(months => i + 1))::date; + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF tb_card_stop_resume_record FOR VALUES FROM (%L) TO (%L)', + 'tb_card_stop_resume_record_' || to_char(from_date, 'YYYYMM'), + (from_date::timestamp AT TIME ZONE 'Asia/Shanghai'), + (to_date::timestamp AT TIME ZONE 'Asia/Shanghai')); + END LOOP; +END $$; + +CREATE INDEX idx_card_stop_resume_record_card ON tb_card_stop_resume_record (card_id, created_at); +CREATE INDEX idx_card_stop_resume_record_carrier ON tb_card_stop_resume_record (carrier_id, created_at); +CREATE INDEX idx_card_stop_resume_record_result ON tb_card_stop_resume_record (result, created_at); + +COMMENT ON TABLE tb_card_stop_resume_record IS '停复机调用记录:每次实际调用上游停机或复机一条,按月分区'; +COMMENT ON COLUMN tb_card_stop_resume_record.card_id IS 'IoT卡ID'; +COMMENT ON COLUMN tb_card_stop_resume_record.iccid IS 'ICCID'; +COMMENT ON COLUMN tb_card_stop_resume_record.carrier_id IS '卡所属运营商账户ID'; +COMMENT ON COLUMN tb_card_stop_resume_record.action IS '动作 stop-停机 start-复机'; +COMMENT ON COLUMN tb_card_stop_resume_record.source IS '来源:轮询、业务事件、退款、激活、重置、订单、后台人工点击、通道阈值等'; +COMMENT ON COLUMN tb_card_stop_resume_record.scene IS '场景'; +COMMENT ON COLUMN tb_card_stop_resume_record.reason IS '停机原因'; +COMMENT ON COLUMN tb_card_stop_resume_record.command_id IS '停复机指令标识,同一指令的多次尝试归为一组'; +COMMENT ON COLUMN tb_card_stop_resume_record.attempt IS '尝试序号'; +COMMENT ON COLUMN tb_card_stop_resume_record.result IS '结果 success-成功 failed-失败 unknown-结果未知 already_target-已是目标状态 skipped-跳过'; +COMMENT ON COLUMN tb_card_stop_resume_record.error_category IS '错误类别'; +COMMENT ON COLUMN tb_card_stop_resume_record.provider_code IS '上游返回码'; +COMMENT ON COLUMN tb_card_stop_resume_record.provider_message IS '上游返回信息(脱敏截断)'; +COMMENT ON COLUMN tb_card_stop_resume_record.duration_ms IS '耗时(毫秒)'; diff --git a/migrations/000246_add_polling_rate_limit_system_config.down.sql b/migrations/000246_add_polling_rate_limit_system_config.down.sql new file mode 100644 index 00000000..af81e472 --- /dev/null +++ b/migrations/000246_add_polling_rate_limit_system_config.down.sql @@ -0,0 +1,11 @@ +-- 回滚网关限流相关的受控系统配置:删除 up 插入的 6 个 Key,与 up 成对。 +-- 删除后代码按注册表默认值运行(限流模式回到 observe)。 + +DELETE FROM tb_system_config WHERE config_key IN ( + 'polling.rate_limit_mode', + 'polling.gateway_default_qps', + 'polling.rate_limited_retry_delay_seconds', + 'polling.rate_limit_defer_alert_seconds', + 'polling.stop_resume_record_retention_months', + 'polling.stop_resume_record_premake_months' +); diff --git a/migrations/000246_add_polling_rate_limit_system_config.up.sql b/migrations/000246_add_polling_rate_limit_system_config.up.sql new file mode 100644 index 00000000..9c83675c --- /dev/null +++ b/migrations/000246_add_polling_rate_limit_system_config.up.sql @@ -0,0 +1,18 @@ +-- 网关限流相关的受控系统配置(add-carrier-gateway-rate-limit),module = polling。 +-- 配置的权威定义(类型、取值范围、默认值)在代码注册表中;本迁移只落库初始值,便于后台列表展示与审计。 +-- 已存在同名 Key 时不覆盖(ON CONFLICT DO NOTHING),避免覆盖运营已修改的值。 +-- +-- 1. polling.rate_limit_mode:observe 只统计不拦截(默认)/ enforce 拦截;全局生效,不按账户覆盖。 +-- 2. polling.gateway_default_qps:未被任何启用限流组覆盖的操作使用的默认每秒请求上限(暂定,上线前由维护者按 observe 数据确认)。 +-- 3. polling.rate_limited_retry_delay_seconds:Asynq 任务遇限流后的固定短延迟。 +-- 4. polling.rate_limit_defer_alert_seconds:同一任务因限流累计推迟超过该时长时写告警。 +-- 5. polling.stop_resume_record_retention_months / polling.stop_resume_record_premake_months:停复机记录保留月数与提前建分区月数。 + +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.rate_limit_mode', 'observe', 'string', 'polling', '网关限流模式:observe 只统计不拦截,enforce 拦截', FALSE, FALSE, 0, 0, NOW(), NOW()), + ('polling.gateway_default_qps', '10', 'int', 'polling', '未被启用限流组覆盖的卡网关操作的默认每秒请求上限', FALSE, FALSE, 0, 0, NOW(), NOW()), + ('polling.rate_limited_retry_delay_seconds', '5', 'int', 'polling', 'Asynq 任务遇限流后的固定重试延迟(秒)', FALSE, FALSE, 0, 0, NOW(), NOW()), + ('polling.rate_limit_defer_alert_seconds', '600', 'int', 'polling', '同一任务因限流累计推迟超过该时长(秒)时写告警', FALSE, FALSE, 0, 0, NOW(), NOW()), + ('polling.stop_resume_record_retention_months', '6', 'int', 'polling', '停复机记录保留月数,到期整区删除', FALSE, FALSE, 0, 0, NOW(), NOW()), + ('polling.stop_resume_record_premake_months', '3', 'int', 'polling', '停复机记录提前创建未来分区的月数', FALSE, FALSE, 0, 0, NOW(), NOW()) +ON CONFLICT (config_key) DO NOTHING; diff --git a/openspec/changes/add-carrier-gateway-rate-limit/.openspec.yaml b/openspec/changes/add-carrier-gateway-rate-limit/.openspec.yaml new file mode 100644 index 00000000..1f5d2804 --- /dev/null +++ b/openspec/changes/add-carrier-gateway-rate-limit/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/add-carrier-gateway-rate-limit/design.md b/openspec/changes/add-carrier-gateway-rate-limit/design.md new file mode 100644 index 00000000..cdf533ef --- /dev/null +++ b/openspec/changes/add-carrier-gateway-rate-limit/design.md @@ -0,0 +1,73 @@ +## Context + +完整推导、生产数据与评审记录见 `docs/engineering/轮询按运营商账户限流技术方案.md`(第三部分第 4、6.2、7.1、7.4 节,第五部分第 3 节第 1 步)。本文只记录本 Change 的实现决策。 + +现状: + +- `internal/gateway/client.go:141` `executeWithRetry`:一次方法调用最多 `maxRetries + 1` 次 HTTP 请求(默认 2 次重试),仅对超时、连接失败重试;每次尝试前后调用 `AttemptObserver`(`stop_resume_audit.go`、`gateway_integration.go` 已用于审计与集成日志)。 +- `internal/gateway/flow_card.go`:`StopCard` / `StartCard` 用默认重试;`SetCardSpeedTier` 已设 `maxRetries = 0`。 +- 业务错误只拼在错误字符串(`client.go:274`),集成日志 `provider_code`、`provider_message` 为空。 +- 轮询并发:`internal/task/polling_base.go` 的全局 + 任务类型计数(本 Change 不动,第 5 个 Change 下线)。 +- Asynq:`pkg/queue/server.go` 显式使用 `asynq.DefaultRetryDelayFunc`,未设 `IsFailure`;`outbox:deliver` 入队未设 `MaxRetry`(默认 25 次)。`queue.concurrency` 默认 10,轮询与 Outbox、导出、佣金共用。 +- Redis 单节点;自动化测试为 N/A,验证在维护者指定的测试环境进行。 + +## Goals / Non-Goals + +**Goals:** + +- 所有自动触发的卡网关调用受运营商账户限流约束,口径为每次实际 HTTP 请求。 +- 上线不造成整体变慢:先只统计,配好后整体拦截。 +- 限流不导致"流量用完停机"被推迟数小时或丢失。 +- 失败原因与调用量可见,停复机逐次可查。 + +**Non-Goals:** + +- 不改轮询调度、分片、并发计数(第 5 个 Change)。 +- 不改停复机业务规则与触发路径(第 4、6 个 Change);现有 `stopCardWithRetry` 的业务层 sleep 重试保留到第 6 个 Change。 +- 不做批量查询;不对后台人工点击、通道阈值、自动续费、手动刷新限流。 + +## Decisions + +1. **限流在每次 HTTP 尝试前执行**,而不是方法入口。方法入口只能扣一次,内部重试会漏计。实现为网关客户端的调用守卫 `gateway.CallGuard`(`BeforeAttempt` / `AfterAttempt`),在 `executeWithRetry` 每次尝试前、现有 `AttemptObserver` 之前调用;卡操作类型由 5 个卡方法写入 ctx。没有复用 ctx 里的 `AttemptObserver`,因为 ctx 只能放一个观察器,会与停复机、设备的审计观察器互相覆盖。限流拒绝发生在审计观察器之前,因此被拒绝的尝试不写集成日志。 +2. **滑动窗口 Lua(ZSET 毫秒时间戳)**,key `gateway:qps:{cid}:{group}`;一次脚本同时校验账户组与全局组、普通操作与预留额度、`high_waiting`。备选令牌桶:突发更平滑但"本秒剩余"口径与后续分发器领取不一致,放弃。 +3. **默认限流、显式豁免**:ctx 中只有豁免标记(带来源),没有"受限流"标记。备选"带标记才限流":新入口忘记打标记就漏网,与"不存在不限流的自动调用"冲突,放弃。 +4. **两种模式全局开关**:`polling.rate_limit_mode` 放受控系统配置(`tb_system_config`,`module = polling`),不按账户覆盖(不灰度,见方案第三部分第 9 节)。API 与 Worker 各自用独立注册表读取同一份配置数据,进程内缓存 5 秒,后台修改数秒内生效。 + - 限流组与错误分类规则同样在进程内缓存 5 秒(`GatewayRateLimitConfigTTL`),后台保存后本进程立即失效、其他进程 5 秒内刷新;未另建 Redis 配置缓存。 + - 运营商账户口径为本系统 `tb_carrier`(卡上的 `carrier_id`),与网关客户端自身的 appId 无关:已知卡的调用方用 `gateway.WithCarrier` 传入,否则守卫按 ICCID 查本系统卡表并缓存 10 分钟;查不到时按账户 0(未知账户)单独计量。 + - Redis 故障时限流失败开放(放行并记日志),避免限流组件故障阻断全部网关调用。 +5. **Asynq 内不等待**:限流器提供"立即返回"与"等待至超时"两种模式,本 Change 只用立即返回。等待会占住共用的 10 个协程、饿死 Outbox 等业务。等待模式留给第 5 个 Change 的 NSQ 消费者。 +6. **限流错误不计失败**:在 `pkg/queue/server.go` 设置 `IsFailure`(`errors.Is(err, ErrRateLimited)` 时返回 false)与 `RetryDelayFunc`(限流错误返回 `rate_limited_retry_delay`,其余回落 `DefaultRetryDelayFunc`)。只改服务配置与错误类型,各消费者业务代码不改。轮询 handler 另外自行短延迟 Requeue(它们是 `MaxRetry(0)`)。累计推迟告警:任务载荷或 Redis 记首次限流时间,超过上限写告警。 +7. **停复机 `maxRetries = 0`**:与 `SetCardSpeedTier` 同一做法(复制 Client 并置 0)。超时返回 `CodeGatewayTimeout`,现有调用方按失败或结果未知处理。 +8. **结构化错误**:新增 `GatewayError{Category, ProviderCode, ProviderMessage, Duration}` 实现 `error` 并保留现有错误码(`CodeGatewayError` 等),调用方用 `errors.As` 取结构,既有 `errors.Is`/错误码判断不受影响。 +9. **错误分类**:表 `tb_gateway_error_classification` + Redis 缓存;分类器按"适用操作 + 启用 + 优先级"匹配关键字(包含 / 前缀 / 正则)。初始数据按方案第 6.2 节表格写入迁移。 +10. **停复机记录表**:PostgreSQL 声明式分区(`PARTITION BY RANGE (created_at)`),主键 `(id, created_at)`,建 DEFAULT 分区;Worker 定时任务提前创建未来 N 个月分区(N 为系统参数)并按保留期删除过期分区。记录写入在业务事务之外,失败只记日志与计数。 +11. **审计替换**:以"是否带限流豁免标记或由账户、个人客户、OpenAPI 发起"区分(`legacyCommandLogging`): + - 系统自动停复机(系统任务发起、无豁免标记)只写停复机记录,不写 `tb_audit_event` 与集成日志;未调用上游的拒绝类审计保留。 + - 后台人工点击、通道阈值、自动续费,以及账户、个人客户、OpenAPI 发起的停复机保持原审计与集成日志并额外写记录。通道阈值与自动续费的可靠任务用集成日志标识做结果未知恢复,不能去掉。 + - 设备停复机(后台点击)在设备服务内同样写记录。 + - 普通轮询查询不再写集成日志(含有变化时的成功记录);同时是优先执行的轮次仍写,以满足优先轮询"返回集成交互日志标识"的现行要求。 +12. **分钟计数**:key `gateway:stat:{cid}:{op}:{yyyyMMddHHmm}` Hash(按结果类别计数、耗时分桶),TTL 8 天,覆盖 `observe` 一周的评估需要。 + +13. **观测序列遇限流**:观测序列任务为 `MaxRetry(0)`,现有逻辑在限流时记一次"被限流"并结束本次尝试(不归档、不报错),后续两次尝试与普通轮询兜底;本 Change 不做顺延重入队(需改序列幂等认领),观测序列在第 5 个 Change 下线。 +14. **业务层停复机重试**:`stopCardWithRetry` / `resumeCardWithRetry` 遇限流立即返回,不再 sleep 重试、不写失败审计,由调用方既有重试或兜底路径处理。 + +## Risks / Trade-offs + +- [默认 QPS 设得不合理] → 先 `observe` 至少一周,按 `/polling-stats/carriers` 数据为全部账户配置限流组后再整体 `enforce`;`enforce` 后异常可即时改回 `observe`。 +- [停复机不再内部重试,人工点击网络抖动直接失败] → 已接受;页面提示重试。 +- [移除系统自动停复机审计后,原审计查询看不到] → 停复机记录提供同等字段与查询;后台人工点击仍有统一审计。 +- [`IsFailure` 对全部 Asynq 任务生效] → 只对 `ErrRateLimited` 返回 false,其余错误行为不变;限流错误只由限流器产生。 +- [分区表维护任务失败] → DEFAULT 分区兜底插入不失败;监控分区创建结果。 +- [Redis 计数 key 量] → 账户 37 × 操作 5 × 分钟,8 天约 210 万个小 Hash;上线前按生产 Redis 余量评估,必要时改为 5 分钟粒度。 + +## Migration Plan + +1. 测试环境:执行迁移(三张新表、`tb_system_config` 配置项与错误分类初始数据),部署 API 与 Worker,默认 `observe`。 +2. 按 tasks 第 9 节核对清单逐项验证,包括 `enforce` 下 Outbox 限流不计失败。 +3. 生产:维护者按生产运行说明手工执行迁移与发布,保持 `observe` 至少一周。 +4. 运营按统计为全部账户配置限流组,确认后改 `enforce`。 +5. 回退:改回 `observe` 即停止拦截;代码回退时新表保留不删。 + +## Open Questions + +- 默认 QPS 初值:在测试环境与 `observe` 数据确认前,暂定为系统参数默认值,由维护者上线前填写。 diff --git a/openspec/changes/add-carrier-gateway-rate-limit/proposal.md b/openspec/changes/add-carrier-gateway-rate-limit/proposal.md new file mode 100644 index 00000000..90e94f05 --- /dev/null +++ b/openspec/changes/add-carrier-gateway-rate-limit/proposal.md @@ -0,0 +1,37 @@ +## Why + +上游按运营商账户限频,本系统却只有"全局 + 任务类型"的并发计数,数值没有依据,且自动停复机、业务事件观测完全不受约束。生产 2026-10-22:退款触发的停机 24 小时失败约 26.8 万次、单卡每 1~2 秒调用一次;轮询失败约 37 万次但失败原因全部为空,无法区分限频、卡错误与超时。本 Change 是《轮询按运营商账户限流技术方案》(`docs/engineering/轮询按运营商账户限流技术方案.md`)第五部分第 3 节的第 1 步:不动现有调度,先给网关调用加上按运营商账户的限流,并让失败原因与调用量可见,作为后续各 Change 和各账户 QPS 配置的依据。 + +## What Changes + +- 新增网关限流器:按"运营商账户 → 限流组 → 操作"对卡的 5 个网关操作(查流量、查状态、查实名、停机、复机)做每秒请求数限制,滑动窗口,跨 Worker 共享;**按每次实际 HTTP 尝试计量**(挂在 `AttemptObserver.BeforeAttempt`),网关内部重试同样计入。 +- 默认限流、显式豁免:只有后台人工点击停复机、通道阈值、自动续费、手动刷新 4 个入口打豁免标记,其余卡操作一律受限。 +- 限流组配置(新表 `tb_carrier_rate_limit_group`):组内操作、`max_qps`、高优先级操作与预留额度、可选全局组;未覆盖或组已停用的操作按默认 QPS,不存在不限流的自动调用。 +- 两种模式:受控系统配置 `polling.rate_limit_mode`,`observe`(只统计、不拦截,默认)与 `enforce`(拦截),全局生效、不按账户覆盖。上线先 `observe` 采集至少一周,配好全部账户限流组后整体切到 `enforce`。 +- 拦截时在 Asynq 任务内**不等待**:拿不到名额立即返回"限流"错误;轮询任务短延迟重入队且不计失败;Asynq 服务配置 `IsFailure` 对"限流"错误返回 false、`RetryDelayFunc` 返回固定短延迟,Outbox 消费者与观测序列因此不会因限流消耗重试次数、推迟数小时或归档。 +- **BREAKING** `StopCard` / `StartCard` 不再使用网关内部重试(`maxRetries = 0`),对全部停复机入口生效(含后台人工点击):避免超时后几百毫秒内对同一张卡连发 2~3 次撞上游单卡频率限制。 +- 网关返回结构化错误(错误类别、网关业务码与信息、耗时),不再只拼在错误字符串里。 +- 上游错误分类:新表 `tb_gateway_error_classification`(关键字 → 类别:频率限制 / 终态不可操作 / 已是目标状态 / 临时故障 / 业务错误 / 账户配置错误),后台可增补,提供测试接口。只有明确返回已是目标状态才视为成功。 +- 停复机记录:新表 `tb_card_stop_resume_record`(按月分区 + DEFAULT 分区,提前建未来分区),每次实际调用上游停机或复机写一条;**BREAKING** 系统自动停复机不再写统一审计 `tb_audit_event` 与集成日志 `tb_integration_log`(后台人工点击仍写统一审计);轮询查询不再写集成日志。 +- 查询类调用不逐条落库:Redis 按"运营商账户 × 操作 × 结果类别"做分钟级计数与耗时分布。 +- 后台接口:限流组增删改查、错误分类增删改查与测试、停复机记录查询、按账户调用量统计、`polling` 模块系统配置注册(含 JSON 结构校验)、枚举选项。 + +## Capabilities + +### New Capabilities + +- `carrier-gateway-rate-limit`:按运营商账户的网关限流、计量口径、豁免、只统计与拦截模式、限流组配置、Asynq 任务遇限流的重试语义、停复机不做网关内部重试。 +- `gateway-call-observability`:网关结构化错误、上游错误分类、停复机记录表、查询调用分钟计数、按账户调用量与失败查询接口。 + +### Modified Capabilities + +- `polling-load-control`:"业务变化与轮询异常仍可追溯"改为:系统自动停复机写停复机记录表而非统一审计与集成日志;查询失败以分钟计数与卡上最近错误保留可调查信息,不逐条写集成日志。 + +## Impact + +- 依赖:无前置 Change;不改现有轮询调度、并发计数与停复机业务规则。 +- 数据库:新增 `tb_carrier_rate_limit_group`、`tb_gateway_error_classification`、`tb_card_stop_resume_record`(分区表);`tb_system_config` 插入 `module = polling` 配置项。均为新的成对迁移。 +- 代码:`internal/gateway/client.go`、`internal/gateway/flow_card.go`;新增限流器与错误分类器;`pkg/queue/server.go`(`IsFailure`、`RetryDelayFunc`);`internal/task/polling_*_handler.go`(限流重入队);`internal/service/iot_card/stop_resume_service.go` 与 `stop_resume_audit.go`(记录表替代审计);`internal/infrastructure/systemconfig` 注册;新增 Handler / 路由 / DTO 并同步 `cmd/api/docs.go`、`cmd/gendocs/main.go`。 +- 接口:新增 `/polling-rate-limit-groups`、`/gateway-error-classifications`、`/card-stop-resume-records`、`/iot-cards/:id/stop-resume-records`、`/polling-stats/carriers`、`/polling-options`;`/system-configs` 的 `module` 枚举增加 `polling`。前端需新增限流组、错误分类、停复机记录页面,系统配置页增加 `polling` 模块。 +- 行为:后台人工点击停复机遇网络超时不再自动重试,页面直接显示失败;系统自动停复机的审计查询改到停复机记录。 +- 运营:`observe` 期间按调用量统计为全部运营商账户配置限流组,确认后切 `enforce`。 diff --git a/openspec/changes/add-carrier-gateway-rate-limit/specs/carrier-gateway-rate-limit/spec.md b/openspec/changes/add-carrier-gateway-rate-limit/specs/carrier-gateway-rate-limit/spec.md new file mode 100644 index 00000000..562ad1ec --- /dev/null +++ b/openspec/changes/add-carrier-gateway-rate-limit/specs/carrier-gateway-rate-limit/spec.md @@ -0,0 +1,108 @@ +## ADDED Requirements + +### Requirement: 卡网关操作按运营商账户限流 + +系统 SHALL 对卡的 5 个网关操作(查流量、查状态、查实名、停机、复机)按目标卡所属运营商账户与操作类型找到所属限流组,并在每秒请求数达到该组 `max_qps` 时拒绝或延后本次请求。限流窗口 MUST 跨全部 API 与 Worker 进程共享。设备停复机拆分到卡后,每张卡 MUST 各自按其所属运营商账户查找限流组。未被任何启用的限流组覆盖的操作(含所在组已停用)MUST 按默认 QPS 限流,不得出现不限流的自动调用。配置了全局组时,请求 MUST 同时满足账户组与全局组才放行。 + +#### Scenario: 账户组 QPS 用满 +- **GIVEN** 运营商账户 A 的查询组 `max_qps = 20`,当前 1 秒窗口内已放行 20 次查询 +- **WHEN** 第 21 次查询 A 账户下卡的流量 +- **THEN** 系统在拦截模式下不发出该 HTTP 请求,返回"限流"结果 + +#### Scenario: 不同账户互不影响 +- **GIVEN** 账户 A 的查询组本秒已用满 +- **WHEN** 查询账户 B 下卡的状态 +- **THEN** 系统按账户 B 的限流组判断,不受账户 A 影响 + +#### Scenario: 限流组停用 +- **WHEN** 账户 A 的停复机组被停用后发起 A 账户下卡的停机 +- **THEN** 系统按默认 QPS 限流该次停机,不得不限流放行 + +#### Scenario: 全局组 +- **GIVEN** 配置了覆盖查询操作的全局组且其本秒已用满,账户 A 查询组仍有余量 +- **WHEN** 查询账户 A 下卡的流量 +- **THEN** 系统按全局组拒绝本次请求 + +### Requirement: 按每次实际 HTTP 请求计量 + +系统 SHALL 在每次向网关发出 HTTP 请求之前各取一次限流名额,网关客户端一次方法调用内部的重试请求 MUST 各自计量。拿不到名额时,本次方法调用 MUST 结束且不再发起后续尝试。 + +#### Scenario: 查询内部重试 +- **GIVEN** 查流量第一次请求超时,网关客户端准备进行第二次尝试 +- **WHEN** 第二次尝试发出前 +- **THEN** 系统为第二次尝试再取一次名额,名额不足时不发出第二次请求并返回"限流"结果 + +### Requirement: 默认限流与显式豁免 + +系统 SHALL 对卡的 5 个网关操作默认限流;只有后台人工点击的停复机、通道阈值、自动续费、手动刷新 4 个入口 MAY 携带豁免标记绕过限流,豁免调用 MUST 按豁免来源计数以便监控。未携带豁免标记的调用(含今后新增的入口)MUST 受限流约束。 + +#### Scenario: 新增自动入口未处理标记 +- **WHEN** 任一未声明豁免的入口调用停机 +- **THEN** 该调用受所属限流组约束 + +#### Scenario: 后台人工点击停机 +- **WHEN** 运营在后台对单卡点击停机 +- **THEN** 该调用不受限流约束,并计入"人工点击"豁免调用量 + +### Requirement: 高优先级操作预留额度 + +当同一限流组内包含停机、复机与其他操作且配置了高优先级操作与 `reserved_qps` 时,系统 SHALL 让普通操作最多使用 `max_qps − reserved_qps`,高优先级操作可用满 `max_qps`;高优先级请求等待名额期间,普通操作 MUST 拿不到新名额。保存限流组时,系统 MUST 拒绝 `reserved_qps ≥ max_qps`、高优先级操作不属于本组操作、同一运营商账户下同一操作属于多个组的配置。 + +#### Scenario: 共享组内普通操作受预留约束 +- **GIVEN** 广电账户一个组含全部 5 个操作,`max_qps = 10`,停机与复机为高优先级,`reserved_qps = 3` +- **WHEN** 本秒已放行 7 次查询后再发起查询 +- **THEN** 系统拒绝该查询;同一秒内的停机仍可放行至第 10 次 + +#### Scenario: 同一操作重复归组 +- **WHEN** 维护者为账户 A 新建组并包含已属于另一组的"查流量" +- **THEN** 系统拒绝保存并提示该操作已属于其他组 + +### Requirement: 只统计与拦截两种模式 + +系统 SHALL 按受控系统配置 `polling.rate_limit_mode` 全局决定限流模式:`observe` 时照常计数(实际 QPS、本应被拦截的次数)但不拦截、不等待;`enforce` 时按限流组拦截。默认值 MUST 为 `observe`。模式修改 MUST 即时生效,不需要重新部署。模式不得按运营商账户覆盖。 + +#### Scenario: 只统计模式下超出上限 +- **GIVEN** 模式为 `observe`,账户 A 查询组本秒已达上限 +- **WHEN** 再查询 A 账户下一张卡 +- **THEN** 系统照常发出请求,并把该次计入"本应被限流"次数 + +#### Scenario: 切换为拦截 +- **WHEN** 超级管理员把 `polling.rate_limit_mode` 改为 `enforce` +- **THEN** 下一次请求起按限流组拦截,无需重启进程 + +### Requirement: Asynq 任务内遇限流不等待且不计失败 + +在拦截模式下,系统 SHALL 让 Asynq 任务内发起的受限调用拿不到名额时立即返回"限流"错误,不在任务内等待名额。轮询任务遇限流 MUST 按 `rate_limited_retry_delay` 短延迟重新入队且不计为失败。其余 Asynq 任务(含 Outbox 消费者、观测序列)返回"限流"错误时,Asynq MUST 不将其计为失败、不增加重试计数,并按固定短延迟重试;其他错误的重试行为 MUST 不变。同一任务因限流累计推迟超过上限时,系统 MUST 写告警且任务继续重试。 + +#### Scenario: 流量用完停机遇限流 +- **GIVEN** 流量入账后的 Outbox 消费者判断需要停机,停复机组本秒已用满 +- **WHEN** 消费者调用停机拿不到名额 +- **THEN** 任务以"限流"错误结束,Asynq 不增加重试计数,几秒后重试,不会因连续限流推迟数小时或被归档 + +#### Scenario: 非限流错误 +- **WHEN** Outbox 消费者因数据库错误失败 +- **THEN** Asynq 按原有重试次数与递增间隔处理 + +### Requirement: 停复机不使用网关内部重试 + +系统 SHALL 对停机、复机网关调用只发出一次 HTTP 请求,不使用网关客户端的内部重试,对全部停复机入口生效(含后台人工点击、通道阈值、自动续费)。超时或连接失败 MUST 按结果未知返回给调用方。 + +#### Scenario: 停机超时 +- **WHEN** 停机请求超时 +- **THEN** 系统不在同一次调用内再发停机请求,向调用方返回结果未知 + +#### Scenario: 后台人工点击停机网络抖动 +- **WHEN** 运营点击停机时连接失败 +- **THEN** 页面返回失败,由运营重新操作,系统不自动重试 + +### Requirement: 限流组与限流参数可后台维护 + +系统 SHALL 提供限流组的列表(按运营商账户筛选,含全局组)、新建、详情、修改、启停、删除接口;修改类接口仅超级管理员可用并写统一审计。保存后 MUST 同步到共享缓存,下一次请求起生效。限流模式、限流短延迟、限流累计推迟告警上限等标量参数 SHALL 通过受控系统配置 `module = polling` 维护,列表 MUST 支持按 `polling` 模块筛选。 + +#### Scenario: 修改 QPS 即时生效 +- **WHEN** 超级管理员把账户 A 查询组 `max_qps` 从 20 改为 30 +- **THEN** 下一次请求起按 30 判断,无需重启 + +#### Scenario: 非超级管理员修改 +- **WHEN** 非超级管理员提交限流组修改 +- **THEN** 系统拒绝并保留原配置 diff --git a/openspec/changes/add-carrier-gateway-rate-limit/specs/gateway-call-observability/spec.md b/openspec/changes/add-carrier-gateway-rate-limit/specs/gateway-call-observability/spec.md new file mode 100644 index 00000000..b445408a --- /dev/null +++ b/openspec/changes/add-carrier-gateway-rate-limit/specs/gateway-call-observability/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: 网关结构化错误 + +系统 SHALL 让网关客户端在失败时返回结构化错误,包含错误类别(网络、超时、HTTP 异常、响应无效、上游业务错误、限流)、网关业务码与信息、耗时。上游信息 MUST 脱敏截断后才可落库或展示。 + +#### Scenario: 上游业务错误 +- **WHEN** 网关返回业务码 400 与信息"当前用户已销户,无法操作" +- **THEN** 调用方拿到类别"上游业务错误"、业务码 400、该信息与本次耗时 + +### Requirement: 上游错误分类 + +系统 SHALL 按后台维护的关键字规则把上游错误归入频率限制、终态不可操作、已是目标状态、临时故障、业务错误、账户配置错误之一;规则包含关键字、匹配方式、类别、适用操作、启用状态。只有明确表示卡已处于目标状态的返回 MAY 归入"已是目标状态";"密码错误""参数缺失"MUST 归入账户配置错误;"当前用户不允许办理""防范实名"MUST NOT 归入已是目标状态。未命中任何规则的上游业务错误 MUST 归入业务错误。系统 SHALL 提供测试接口:输入一段上游返回信息与操作,返回命中的规则与类别。 + +#### Scenario: 已停机返回 +- **WHEN** 停机返回信息命中"已是目标状态"规则 +- **THEN** 该次结果归为已是目标状态 + +#### Scenario: 密码错误 +- **WHEN** 停机返回"密码错误" +- **THEN** 该次结果归为账户配置错误,不视为停机成功 + +#### Scenario: 测试规则 +- **WHEN** 运营在新增规则前用测试接口输入"3分钟内同一张卡相同操作仅允许一次" +- **THEN** 系统返回命中的规则与"频率限制"类别,不写任何业务数据 + +### Requirement: 停复机调用逐次记录 + +系统 SHALL 在每次实际调用上游停机或复机后写一条停复机记录:卡、ICCID、运营商账户、动作、来源与场景、停机原因、尝试序号、结果(成功、失败、结果未知、已是目标状态)、错误类别、上游返回码与信息(脱敏截断)、耗时、记录时间。系统自动停复机(轮询、Outbox、激活、重置、退款等系统任务发起,且未带限流豁免标记)MUST 不再写统一审计与集成日志;后台人工点击、通道阈值、自动续费(带豁免标记),以及账户、个人客户、OpenAPI 发起的停复机 MUST 仍写统一审计与集成日志并同时写停复机记录(通道阈值与自动续费的结果未知恢复依赖集成日志标识)。未调用上游的拒绝类审计(如通道阈值锁拒绝复机)保持不变。被本系统限流拒绝、未发出请求的停复机 MUST NOT 写记录。记录写入失败 MUST 只记日志与计数,不影响卡状态与停机原因回写。记录按月分区,系统 MUST 提前创建未来若干个月的分区并保留默认分区,使任何月份的插入都不因分区缺失失败;保留期可配置,到期整区删除。 + +#### Scenario: 流量用完自动停机 +- **WHEN** 轮询判定流量用完并成功停机 +- **THEN** 系统写一条结果为成功的停复机记录,不写统一审计事件与集成日志 + +#### Scenario: 后台人工点击复机失败 +- **WHEN** 运营点击复机且上游返回业务错误 +- **THEN** 系统写统一审计,并写一条结果为失败、带错误类别与上游信息的停复机记录 + +#### Scenario: 未来月份分区缺失 +- **WHEN** 某月分区尚未创建时写入记录 +- **THEN** 记录写入默认分区,插入成功 + +### Requirement: 查询调用不逐条落库 + +系统 SHALL 不为普通轮询查询(查流量、查状态、查实名)逐条写集成日志,包括"数据有变化"的成功调用;本轮同时是一次优先执行时,按优先轮询可追溯要求仍写集成日志并回写优先项;改为在共享缓存中按"运营商账户 × 操作 × 结果类别"做分钟级计数与耗时分布,并在卡上保留各检查类型最近一次错误(类别、码、信息、时间)。业务事实变化(网络、实名、套餐、有效流量)MUST 仍按现有规则持久化业务记录。 + +#### Scenario: 查流量失败 +- **WHEN** 轮询查流量超时 +- **THEN** 系统不写集成日志,对应分钟计数的"超时"加一,卡上流量类型的最近错误更新为超时 + +### Requirement: 按账户调用量与停复机记录可查询 + +系统 SHALL 提供:按运营商账户的调用量统计(各操作实际 QPS、成功率、按类别的失败数、p95 耗时、本应被限流次数、豁免调用量,分钟级趋势);停复机记录列表(按 ICCID、运营商账户、动作、来源、结果、错误类别、时间范围筛选,时间范围必填且有上限)与单卡停复机历史;前端下拉所需的操作类型、错误类别等枚举。查询接口 MUST 需要登录并遵守现有数据权限。 + +#### Scenario: 无时间范围查询记录 +- **WHEN** 请求停复机记录列表未带时间范围 +- **THEN** 系统拒绝请求并提示时间范围必填 + +#### Scenario: 只统计期间评估 QPS +- **WHEN** 运营在 `observe` 模式下查看账户 A 近一周统计 +- **THEN** 系统返回各操作实际 QPS 峰值与本应被限流次数,作为配置限流组的依据 diff --git a/openspec/changes/add-carrier-gateway-rate-limit/specs/polling-load-control/spec.md b/openspec/changes/add-carrier-gateway-rate-limit/specs/polling-load-control/spec.md new file mode 100644 index 00000000..0233b9dd --- /dev/null +++ b/openspec/changes/add-carrier-gateway-rate-limit/specs/polling-load-control/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: 业务变化与轮询异常仍可追溯 +系统 SHALL 在轮询产生网络、实名、套餐或有效流量等业务事实变化时持久化必要的业务事实与审计记录。外部调用失败、响应无效或结果未知时,系统 MUST 保留可调查的失败或恢复信息,且不得把失败静默降级为正常无变化观测。系统自动停复机的每次上游调用 MUST 以停复机记录保留结果与失败原因,不再写统一审计与集成日志;查询类调用失败 MUST 以按运营商账户的分钟计数与卡上各检查类型最近一次错误保留可调查信息,普通轮询不逐条写集成日志;同时是优先执行的轮次仍写集成日志供优先项追溯。 + +#### Scenario: 轮询产生网络状态变化 +- **GIVEN** 渠道返回的网络状态与当前业务事实不同 +- **WHEN** 系统成功应用该状态变化 +- **THEN** 系统持久化业务状态变化及相应审计事实 + +#### Scenario: 轮询渠道调用失败 +- **WHEN** 轮询调用渠道超时、失败或返回无效响应 +- **THEN** 系统更新对应分钟计数与该卡该检查类型的最近错误,并按既有策略安排后续处理 + +#### Scenario: 自动停机失败 +- **WHEN** 系统自动停机时上游返回业务错误 +- **THEN** 系统写一条带错误类别与上游信息的停复机记录,不写统一审计与集成日志 diff --git a/openspec/changes/add-carrier-gateway-rate-limit/tasks.md b/openspec/changes/add-carrier-gateway-rate-limit/tasks.md new file mode 100644 index 00000000..9fc18efd --- /dev/null +++ b/openspec/changes/add-carrier-gateway-rate-limit/tasks.md @@ -0,0 +1,66 @@ +## 1. 迁移与模型 + +- [x] 1.1 新增成对迁移:建 `tb_carrier_rate_limit_group`(`carrier_id` 可空表示全局组、`group_code`、`group_name`、`max_qps`、`operations`、`high_priority_operations`、`reserved_qps`、`status`、审计字段);down 删表。 +- [x] 1.2 新增成对迁移:建 `tb_gateway_error_classification`(关键字、匹配方式、类别、适用操作、优先级、启用状态),写入方案第 6.2 节的初始规则;down 删表。 +- [x] 1.3 新增成对迁移:建分区表 `tb_card_stop_resume_record`(按 `created_at` 月分区,主键 `(id, created_at)`,DEFAULT 分区,当前月起若干个月分区,索引 `(card_id, created_at)`、`(carrier_id, created_at)`、`(result, created_at)`);down 删表。 +- [x] 1.4 新增成对迁移:`tb_system_config` 插入 `module = polling` 配置项(`polling.rate_limit_mode` 默认 `observe`、`rate_limited_retry_delay`、限流累计推迟告警上限、默认 QPS、停复机记录保留月数与提前建分区月数);down 删除这些键。 +- [x] 1.5 新增对应 model 与 Store;`internal/infrastructure/systemconfig` 注册上述键(类型、取值范围、枚举),`SystemConfigListRequest.Module` 枚举增加 `polling`;为 JSON 类型注册项增加结构校验接缝。 + +## 2. 网关结构化错误与停复机不重试 + +- [x] 2.1 `internal/gateway`:新增 `GatewayError`(类别、业务码、信息、耗时),`executeHTTPRequest` 与业务错误分支返回它并保留现有错误码;上游信息脱敏截断。 +- [x] 2.2 `flow_card.go`:`StopCard` / `StartCard` 改为 `maxRetries = 0`(同 `SetCardSpeedTier` 写法)。 +- [x] 2.3 全仓检查对网关错误字符串的解析(如按 `Gateway 业务错误: code=` 匹配),改为读取结构化字段,行为保持一致。 + +## 3. 限流器 + +- [x] 3.1 新增限流器:滑动窗口 Lua(账户组 + 全局组 + 预留额度 + `high_waiting`),返回放行或需等待毫秒数;提供立即返回与等待至超时两种调用方式。 +- [x] 3.2 限流组配置读取:数据库 → Redis 缓存,变更后即时刷新;按目标卡 `carrier_id` + 操作查组,未覆盖或停用回落默认 QPS。 +- [x] 3.3 实现限流 `AttemptObserver`,在 `BeforeAttempt` 取名额;与现有审计观察器链式组合;拿不到名额返回 `ErrRateLimited`(结构化错误类别"限流")。 +- [x] 3.4 豁免标记:ctx 携带豁免来源;在后台人工点击停复机、通道阈值、自动续费、手动刷新 4 处入口打标记,其余不改。 +- [x] 3.5 `observe` / `enforce` 模式:`observe` 只计数(含本应被限流次数)不拦截;模式读取受控系统配置并即时生效。 + +## 4. Asynq 遇限流的重试语义 + +- [x] 4.1 `pkg/queue/server.go`:`IsFailure` 对 `ErrRateLimited` 返回 false;`RetryDelayFunc` 对其返回 `rate_limited_retry_delay`,其余回落 `DefaultRetryDelayFunc`。 +- [x] 4.2 现有轮询 handler(`internal/task/polling_*_handler.go`)遇 `ErrRateLimited` 按短延迟 Requeue、不计失败、不写失败审计。 +- [x] 4.3 限流累计推迟告警:记录任务首次被限流时间,超过上限写告警,任务继续重试。 + +## 5. 错误分类与调用计数 + +- [x] 5.1 错误分类器:按适用操作、启用状态、优先级匹配关键字;规则缓存于 Redis 并在变更后刷新;未命中归业务错误。 +- [x] 5.2 网关调用分钟计数:每次尝试结束按"账户 × 操作 × 结果类别"计数并记录耗时分桶,含豁免调用量与本应被限流次数。 +- [x] 5.3 卡上最近错误:查询失败时写该卡该检查类型的最近错误(类别、码、信息、时间)。 + +## 6. 停复机记录替代审计 + +- [x] 6.1 每次实际调用上游停机或复机后写停复机记录(来源、场景、尝试序号、结果、错误类别、上游信息、耗时);写入失败只记日志与计数。 +- [x] 6.2 系统自动停复机不再写 `tb_audit_event` 与集成日志;后台人工点击停复机保留统一审计并同时写记录。 +- [x] 6.3 轮询查询删除集成日志写入(含有变化时的成功记录),业务事实变化的持久化不变。 +- [x] 6.4 Worker 定时任务:提前创建未来分区、按保留期删除过期分区,失败写告警。 + +## 7. 后台接口 + +- [x] 7.1 限流组:`GET/POST /polling-rate-limit-groups`、`GET/PUT/DELETE /polling-rate-limit-groups/:id`、`PUT /:id/status`;保存校验(`reserved_qps < max_qps`、高优先级属于本组、同账户同操作只属一组);修改仅超级管理员并写统一审计。 +- [x] 7.2 错误分类:`GET/POST /gateway-error-classifications`、`PUT/DELETE /:id`、`PUT /:id/status`、`POST /gateway-error-classifications/test`。 +- [x] 7.3 停复机记录:`GET /card-stop-resume-records`(时间范围必填且有上限)、`GET /iot-cards/:id/stop-resume-records`。 +- [x] 7.4 调用量统计:`GET /polling-stats/carriers`(实际 QPS、成功率、按类别失败数、p95、本应被限流次数、豁免调用量)与 `GET /polling-stats/carriers/:carrier_id/trend`。 +- [x] 7.5 枚举:`GET /polling-options`(操作类型、错误类别、匹配方式等)。 +- [x] 7.6 同步可执行路由、`cmd/api/docs.go`、`cmd/gendocs/main.go`,运行 `go run cmd/gendocs/main.go` 重新生成接口文档。 + +## 8. 编译与规范校验 + +- [x] 8.1 `gofmt -w` 变更文件;`go build ./cmd/api ./cmd/worker` 通过。 +- [x] 8.2 `openspec validate add-carrier-gateway-rate-limit` 与 `openspec doctor --json` 通过。 + +## 9. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001 在维护者指定测试环境逐项核对) + +- [x] 9.1 迁移在测试库执行成功;未来分区与 DEFAULT 分区存在。 +- [ ] 9.2 `observe`:超过限流组上限的请求照常发出,统计接口显示本应被限流次数。 +- [ ] 9.3 `enforce`:账户组用满时请求被拒;不同账户互不影响;停用组回落默认 QPS;全局组生效;预留额度下停机仍可放行。 +- [ ] 9.4 内部重试计量:构造查询超时,确认第二次尝试单独取名额。 +- [ ] 9.5 停复机超时只发一次请求,返回结果未知;后台人工点击不受限流。 +- [ ] 9.6 Outbox 消费者遇限流:Asynq 重试计数不增加、按短延迟重试;非限流错误按原规则重试;累计推迟超限写告警。 +- [ ] 9.7 错误分类:测试接口与实际调用的分类一致;"密码错误"归账户配置错误,"已存在"不归已是目标状态。 +- [ ] 9.8 系统自动停复机只写停复机记录、不写统一审计与集成日志;人工点击两者都写;记录写入失败不影响卡状态回写。 +- [ ] 9.9 生成的接口文档包含新增接口;`/system-configs?module=polling` 返回新增配置项且 JSON 结构校验生效。 diff --git a/openspec/changes/add-carrier-polling-scheduler/.openspec.yaml b/openspec/changes/add-carrier-polling-scheduler/.openspec.yaml new file mode 100644 index 00000000..1f5d2804 --- /dev/null +++ b/openspec/changes/add-carrier-polling-scheduler/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/add-carrier-polling-scheduler/design.md b/openspec/changes/add-carrier-polling-scheduler/design.md new file mode 100644 index 00000000..c89c5282 --- /dev/null +++ b/openspec/changes/add-carrier-polling-scheduler/design.md @@ -0,0 +1,42 @@ +## Context + +完整设计见 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 1~5.1、7.2~7.6、8、9、14 节。本文记录实现决策。 + +现状:`internal/polling/scheduler.go` 每秒对 16 个分片 × 5 类型 Lua 出队后逐条 `Enqueue` 到 Asynq(`MaxRetry(0)`、无 TaskID);按 ZCARD 背压(50 万);`polling_base.go` 全局 + 类型并发令牌;优先提示 List `polling:priority:{taskType}` 与手动触发 List;`OnCardStatusChanged` 重新入队全部类型;业务事件经 Outbox `AppendSeriesRequested` 创建"立即、3 分钟、5 分钟"观测序列,读入口经 `BestEffortSeriesDispatcher`(C 端资产详情、后台资产实时状态、C 端与后台实名链接;OpenAPI 不触发)。Redis 单节点;仓库无 NSQ 依赖;自动化测试为 N/A。生产 98,430 张卡、89,962 张开启轮询、37 个运营商账户。 + +## Goals / Non-Goals + +**Goals:** 按账户隔离、不丢卡、同卡串行、去重;业务事件检查合并且受限流;整体切换可回退。 + +**Non-Goals:** 停复机指令队列与设备维度判断(第 6 个 Change,本 Change 中套餐步骤与 Outbox 消费者仍按现有方式同步停复机);批量查询;手动刷新行为。 + +## Decisions + +1. **Redis 结构**(前缀新,不与旧 key 冲突): + - 账户:`polling:carrier:{cid}:priority|queue`(ZSET,member=card_id)、`:running`(ZSET,member=token,score=到期,仅计数)、`:dispatch_lock`。`stop_resume`、`sr_running` 在第 6 个 Change 使用,本 Change 预留领取顺序。 + - 卡:`polling:card:{id}:due`(Hash:各类型到期、优先标记、连续失败、首次失败、最近错误、最近成功时间)、`:claim`(值=token)、`:exec`(值=token+实例)。 +2. **Lua 脚本**:领取(清理过期 running → 计算上限 → 优先/普通按占比 → 预计调用数对比剩余 QPS → `SET claim NX PX` → 推后 score → 写 running)、续期(校验 token → PEXPIRE claim/exec → 推后 score 与 running)、收尾(校验 token → 删 claim/exec → 移出 running → 写 due → 按优先标记选队列设 score)、请求检查(设 due[type]=now、冷却判断、放入 priority 或打标记)。全部单节点 Lua,不跨 slot 问题。 +3. **NSQ 而非 Asynq**(已定保留 NSQ):每账户独立 topic `polling.card.{cid}`,channel `worker`;消费者 `max_in_flight` 按账户参数;`msg_timeout` 大于续期间隔,执行中 `Touch`;租约 > `msg_timeout` × 允许重投次数。动态订阅随运营商账户与配置刷新。 +4. **执行权 key**:NSQ 在消费者假死时会重投同一 token,仅核对 claim 无法区分;`SET exec token+实例 NX PX 续期间隔×2` 抢不到即 FIN。 +5. **步骤函数**:原 4 个 handler 拆为 `step(ctx, StepInput{Card, Result}) (nextDue, err)`;`Result` 预留批量查询与推送结果。套餐步骤本轮流量已触发评估时可跳过(配置)。沿用 `polling_priority_claim.go` 的优先项认领与收尾。卡流量同步锁保留与手动刷新互斥。 +6. **限流等待模式**:NSQ 消费者使用第 1 个 Change 限流器的"等待至超时"模式(不占 Asynq 协程),等待超过单步超时返回限流超时,按短延迟重排不计失败。 +7. **请求检查取代观测序列**:5 处 `AppendSeriesRequested` 与 3 处读入口分发改为调用"请求检查(卡、类型、场景)";场景表 `tb_polling_check_scene` 预置 scene 行(订单支付、套餐激活、停复机成功、设备操作、自动续费购包、C 端资产详情、后台资产实时状态、实名链接),读入口默认配置冷却。观测序列中与卡检查无关的部分(设备信息同步 `query_device_info`)保持不变。 +8. **失败告警**:`tb_polling_failure_alert` 部分唯一索引(卡、检查类型、未关闭),告警写入走异步、失败只记日志;通知走现有 `alert_service.go`。停复机方向的告警在第 6 个 Change 接入同一张表。 +9. **整体切换**:`polling.scheduler_mode` 放受控系统配置;切换由 Worker 监听配置变更执行:停旧调度 → 初始化新结构(按批读库,进度复用 `/polling-stats/init-progress`)→ 开启分发。回退对称。旧 handler 执行前检查模式。 +10. **监控**:队列与执行数读 Redis;QPS、耗时读第 1 个 Change 的分钟计数;告警读告警表。 + +## Risks / Trade-offs + +- [不灰度,新调度出问题影响全部 9 万张卡] → 测试环境按生产规模压测(造卡或回放生产卡 ID),覆盖 tasks 第 11 节全部场景;上线首日重点监控,异常用 `scheduler_mode` 回退。 +- [初始化 10 万张卡耗时] → 测试环境实测并写入上线步骤;初始化期间不领取,切换窗口内轮询暂停。 +- [新增 NSQ 组件] → nsqd 与 nsqlookupd 部署、监控与告警纳入运维;NSQ 消息丢失由租约到期兜底。 +- [Redis 内存增加] → 每卡一个 `due` Hash(约 10 字段),上线前按生产卡量估算并确认 `maxmemory` 与淘汰策略不淘汰轮询 key。 +- [读入口冷却期内页面数据不新] → 已接受;页面照常展示数据库数据。 + +## Migration Plan + +1. 测试环境部署 nsqd + nsqlookupd;执行迁移(三张新表、系统配置项、并发配置表注释)。 +2. 部署 API 与 Worker,`scheduler_mode = legacy`;运营配置账户调度参数与场景。 +3. 切 `carrier`,按 tasks 第 11 节核对;演练回退与再次切换。 +4. 生产:维护者部署 NSQ、执行迁移与发布,选择低峰期切 `carrier`,首日监控。 +5. 稳定运行后另行删除旧分片 key 与旧调度代码。 diff --git a/openspec/changes/add-carrier-polling-scheduler/proposal.md b/openspec/changes/add-carrier-polling-scheduler/proposal.md new file mode 100644 index 00000000..f1ae6011 --- /dev/null +++ b/openspec/changes/add-carrier-polling-scheduler/proposal.md @@ -0,0 +1,37 @@ +## Why + +现有轮询按固定 16 个分片 × 5 种类型调度,与运营商账户无关:慢账户占满共用的 Asynq 协程,影响其他账户与其他业务;出队后到投递前崩溃会丢卡;按集合大小背压,单分片超过 50 万后永久停止出队;投递无去重,同卡同类型可能重复执行,同卡不同类型并行执行。业务事件触发的卡观测序列固定"立即、3 分钟、5 分钟"三连查且不受限流,生产 2026-10-22 批量下单 70 张卡形成单秒 177 次的波峰,并与优先轮询重复查询。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 5 步。 + +## What Changes + +- 新调度:每个运营商账户 3 个延时队列(停复机预留、优先、普通);卡始终留在队列中,领取时写带租约的处理中标记并推后 score,收尾按下次到期设置 score;崩溃后标记到期自动重新领取,不丢卡。 +- 同卡串行:一张卡一个卡任务,按配置的步骤顺序(默认卡状态 → 流量 → 实名 → 套餐)串行执行已到期的检查类型;每步单独超时,某步失败不影响后续步骤。 +- 分发器每秒按账户领取:账户分发锁保证每轮只有一个实例领取;按执行上限、优先占比、预计网关调用数与本秒剩余 QPS 领取。 +- **BREAKING** 卡轮询从 Asynq 改为 NSQ 分发:每个账户独立 topic,`max_in_flight` 控制每进程并发;Redis 是唯一权威状态,NSQ 只做传输。消费前核对派发 token 并抢执行权;执行中续期租约;收尾 Lua 校验 token 写回。 +- 失败处理:按"卡 × 检查类型"记连续失败次数与最近错误,按配置的退避序列重试;限流等待超时不计失败;达到阈值写失败告警(新表 `tb_polling_failure_alert`),首次成功自动恢复;同账户告警超阈值产生账户级告警。 +- **BREAKING** 业务事件与读入口改为"请求检查":订单支付、套餐激活、停复机成功、设备操作、自动续费购包,以及 C 端资产详情、后台资产实时状态、实名链接,不再起观测序列;改为把对应检查设为立即到期放入优先队列,同卡多次请求合并,按场景配置延迟确认与冷却时长(新表 `tb_polling_check_scene`)。OpenAPI 查询保持不触发观测。 +- 优先轮询提示、手动触发队列改为写入账户队列;初始化、生命周期改为操作账户队列与卡到期信息。 +- 运营商账户调度参数(新表 `tb_carrier_polling_param`):执行上限、每进程 `max_in_flight`、优先占比等;步骤顺序、单步超时、退避、告警阈值放受控系统配置。 +- 整体切换:受控系统配置 `polling.scheduler_mode`(`legacy` / `carrier`),不按账户灰度;切换时旧任务直接结束、从数据库初始化全部账户新结构后开始领取;可整体回退。 +- 监控改为按运营商账户;下线按 ZCARD 背压、旧并发计数与并发配置接口、旧提示通道。 + +## Capabilities + +### New Capabilities + +- `carrier-polling-scheduler`:按账户队列与领取、租约与续期、执行权、同卡串行步骤、NSQ 分发与消费、执行上限与优先占比、失败退避与告警、请求检查与场景配置、整体切换与回退、按账户监控。 + +### Modified Capabilities + +- `polling-load-control`:删除"轮询全局并发与背压"(由按账户执行上限与限流取代)。 +- `polling-operations`:删除"轮询并发计数可观测""轮询并发配置覆盖实际任务""轮询任务类型名称可读"(并发配置页下线)。 +- `priority-polling-queue`:"优先调度与执行提示通道"改为写入运营商账户优先队列。 + +## Impact + +- 依赖:第 1 个 Change(限流器,消费者使用等待模式)、第 3 个 Change(去掉保护期)已上线。 +- 部署:测试环境与生产新增 nsqd + nsqlookupd;`go.mod` 新增 NSQ 客户端依赖;yaml 增加 NSQ 地址、`msg_timeout`、租约、续期间隔、分发间隔、卡任务总超时、优雅退出宽限期。 +- 数据库:新增 `tb_carrier_polling_param`、`tb_polling_check_scene`(预置场景)、`tb_polling_failure_alert`;`tb_system_config` 插入步骤与告警参数、`polling.scheduler_mode`;`tb_polling_concurrency_config` 保留不删、表注释标记废弃。 +- 代码:重写 `internal/polling/queue_manager.go`、`scheduler.go`、`initializer.go`、`internal/task/polling_*_handler.go`(拆为步骤函数)、`polling_base.go`、`internal/service/polling/monitoring_service.go`;修改 `lifecycle_service.go`、`priority_manual_service.go`、`manual_trigger_service.go`、`polling_priority_claim.go`;业务事件 5 处与读入口 3 处改为请求检查;`cmd/worker/main.go`、`internal/bootstrap/*` 装配分发器与 NSQ 消费者;删除 `concurrency_service.go` 及其接口。 +- 接口:新增 `/polling-carrier-params`、`/polling-check-scenes`、`/polling-failure-alerts`(列表、汇总、详情、处理)、`/iot-cards/:id/polling-state`;`/polling-stats` 按账户;下线 `/polling-concurrency`。前端新增调度参数、场景、失败告警页,监控页按账户,下线并发配置页。 +- 行为:业务事件后的检查不再固定三连查,按场景配置;读入口受冷却与限流约束。 diff --git a/openspec/changes/add-carrier-polling-scheduler/specs/carrier-polling-scheduler/spec.md b/openspec/changes/add-carrier-polling-scheduler/specs/carrier-polling-scheduler/spec.md new file mode 100644 index 00000000..73b8167d --- /dev/null +++ b/openspec/changes/add-carrier-polling-scheduler/specs/carrier-polling-scheduler/spec.md @@ -0,0 +1,124 @@ +## ADDED Requirements + +### Requirement: 按运营商账户队列调度且不丢卡 + +系统 SHALL 在 `polling.scheduler_mode = carrier` 时,为每个运营商账户维护优先队列与普通队列,每张参与轮询的卡在其所属账户中 MUST 只占一个成员(优先标记决定在哪个队列),score 为该卡各检查类型中最早的到期时间。领取时系统 MUST 写带租约的处理中标记(写成功才算领取到)并把 score 推后到租约到期时间,卡不移出队列;处理结束(成功或失败)由收尾按派发 token 校验后删除处理中标记、写回各检查类型到期时间与失败计数,并把 score 设为下次最早到期时间。处理中崩溃时,租约到期后该卡 MUST 能被重新领取。优先提示、手动触发、生命周期事件 MUST 只修改到期时间与 score,不得新增重复成员。 + +#### Scenario: 执行中进程崩溃 +- **WHEN** 卡任务执行到一半 Worker 崩溃 +- **THEN** 租约到期后该卡被重新领取执行,不需要全量初始化 + +#### Scenario: 重复优先提示 +- **WHEN** 同一张卡 1 秒内收到 3 次优先提示 +- **THEN** 该卡在优先队列中仍只有一个成员,只执行一次 + +### Requirement: 分发器按账户领取 + +系统 SHALL 每秒对每个运营商账户执行一次领取,同一账户每轮 MUST 只由一个分发器实例领取。卡任务领取上限 = 账户执行上限 − 执行中数,且不超过订阅该账户的各进程 `max_in_flight` 之和;先领优先队列,优先领取数不超过本轮上限 × `priority_max_ratio`(为空不限),剩余名额给普通队列,普通队列不足时优先可补满;按卡累计本次预计网关调用数(未开实名管控的账户不计实名),任一限流组累计达到本秒剩余 QPS 即停止领取。只领取已到期的卡。 + +#### Scenario: 多实例同时运行 +- **WHEN** 3 个 Worker 实例同时运行分发器 +- **THEN** 同一账户每秒只被一个实例领取,执行中数不超过该账户执行上限 + +#### Scenario: 读入口刷屏 +- **GIVEN** 账户 A `priority_max_ratio = 0.5`,优先队列积压 1 万张,普通队列有已到期卡 +- **WHEN** 分发器领取账户 A +- **THEN** 本轮至少一半名额给普通队列 + +### Requirement: 卡任务串行执行检查步骤 + +系统 SHALL 对一张卡的一个卡任务按配置的步骤顺序(默认卡状态 → 流量 → 实名 → 套餐)串行执行本次已到期的检查类型。每步 MUST 有单独超时;某步失败只影响该步,后续步骤照常执行;限流等待超时时该步按短延迟重排且不计失败。卡任务总时长 MUST 不超过各步超时之和加余量,到达后取消剩余步骤并按失败收尾。同一张卡的不同检查类型 MUST NOT 并行执行。 + +#### Scenario: 流量步骤失败 +- **WHEN** 卡任务中流量查询超时 +- **THEN** 实名、套餐步骤照常执行,流量按退避间隔重试 + +#### Scenario: 同卡不同类型 +- **WHEN** 卡的状态检查与流量检查同时到期 +- **THEN** 系统在同一个卡任务中先查状态再查流量,不并行发起 + +### Requirement: 租约续期与执行权 + +系统 SHALL 在卡任务执行期间定期续期处理中标记(按 token 校验后延长租约,并同步推后 score 与执行中计数的到期时间)。续期发现 token 已不一致时,系统 MUST 立即停止后续步骤,不再发起新的网关调用,只写入已产生的业务结果。消费者执行前 MUST 核对处理中标记的值等于消息中的派发 token,并抢该 token 的执行权;任一不满足即丢弃消息,保证同一 token 不会被两个消费者同时执行。 + +#### Scenario: 长任务超过单个租约 +- **WHEN** 卡任务因限流等待累计超过一个租约时长 +- **THEN** 续期成功,期间该卡不被重新领取 + +#### Scenario: 消息重投时原消费者仍在执行 +- **WHEN** NSQ 重投同一消息给另一个消费者,原消费者尚未结束 +- **THEN** 后者抢不到执行权,直接丢弃消息 + +### Requirement: 卡轮询经 NSQ 分发 + +系统 SHALL 将领取结果发布到对应运营商账户的 NSQ topic,由订阅该 topic 的消费者执行;每个账户独立 topic,消费者按账户配置的 `max_in_flight` 控制每进程并发。重试与退避 MUST 由 Redis 中的 score 表达,MUST NOT 用 NSQ 重新入队做业务重试。发布失败时,系统 MUST 按 token 删除处理中标记并把 score 恢复为当前时间。卡轮询 MUST NOT 再经过 Asynq;其他业务的 Asynq 队列不受影响。 + +#### Scenario: 慢账户积压 +- **WHEN** 账户 A 上游变慢导致其 topic 积压 +- **THEN** 账户 B 的卡任务照常执行,Outbox 等 Asynq 业务不受影响 + +#### Scenario: 发布失败 +- **WHEN** 领取后发布 NSQ 失败 +- **THEN** 该卡处理中标记被删除、score 恢复为当前时间,下一轮重新领取 + +### Requirement: 检查失败退避与失败告警 + +系统 SHALL 按"卡 × 检查类型"记录连续失败次数、首次失败时间与最近错误;失败后下次到期 = 当前 + 退避间隔,退避随连续失败次数按配置序列拉长,最长不超过该类型正常检查间隔;成功一次即清零。限流等待超时与上游频率限制 MUST NOT 计入连续失败。连续失败次数或持续失败时长达到阈值、或命中"首次即告警"的错误类别时,系统 MUST 写失败告警;同一卡同一检查类型只保留一条未关闭告警,后续失败更新计数;告警后继续按最长退避轮询,首次成功自动转为已恢复。同一运营商账户在时间窗口内的告警数超过阈值时,系统 MUST 产生账户级告警。 + +#### Scenario: 持续超时 +- **WHEN** 某卡流量查询连续失败达到阈值 +- **THEN** 系统写一条失败告警,之后按最长退避继续查询 + +#### Scenario: 上游恢复 +- **WHEN** 已告警的卡下一次查询成功 +- **THEN** 连续失败清零,告警状态变为已恢复 + +#### Scenario: 账户级问题 +- **WHEN** 账户 A 10 分钟内新增失败告警超过阈值 +- **THEN** 系统额外产生一条账户级告警,提示联系上游 + +### Requirement: 业务事件与读入口请求检查 + +系统 SHALL 在订单支付、套餐激活、停复机成功、设备操作(按设备下各卡)、自动续费购包事务提交后,以及 C 端资产详情、后台资产实时状态、获取实名链接时,按场景配置把卡的对应检查类型设为立即到期并放入所属账户优先队列;卡正在执行时打优先标记,执行结束后立即再执行一次。同一张卡多次请求 MUST 合并为一次。场景配置了冷却时长时,该检查类型最近一次成功检查未超过冷却时长 MUST NOT 请求。场景配置了延迟确认序列时,本次检查完成后追加延迟检查(下次到期 = min(正常间隔, 当前 + 确认延迟)),观测结果已满足预期时取消后续确认。系统 MUST NOT 再为卡检查创建"立即、3 分钟、5 分钟"观测序列。OpenAPI 查询 MUST 保持不触发任何检查。场景由代码预置,后台只可修改检查类型、确认延迟与冷却时长。 + +#### Scenario: 批量下单 +- **WHEN** 代理 10 秒内线下支付 27 笔订单,涉及 70 张卡 +- **THEN** 系统只产生约 70 次立即检查(加按场景配置的确认检查),按各账户 QPS 平滑执行 + +#### Scenario: C 端反复刷新 +- **GIVEN** C 端资产详情场景冷却 5 分钟 +- **WHEN** 用户 2 分钟内打开同一资产详情 10 次 +- **THEN** 只在第一次请求检查,其余不请求,页面照常展示数据库数据 + +#### Scenario: 停机后确认 +- **GIVEN** 停复机成功场景配置确认延迟 3 分钟 +- **WHEN** 停机成功后立即检查显示仍在线 +- **THEN** 3 分钟后再检查一次;若立即检查已显示停机,则取消确认 + +### Requirement: 调度参数可后台维护 + +系统 SHALL 提供运营商账户调度参数(执行上限、卡任务与停复机每进程 `max_in_flight`、停复机执行上限、优先占比、单卡停复机最小重试间隔、结果未知确认延迟)的列表与修改,未配置的账户返回全局默认值并标记"默认";保存时 MUST 校验执行上限不大于当前订阅进程 `max_in_flight` 之和并提示。步骤顺序、单步超时、限流短延迟、退避序列、告警阈值通过受控系统配置维护,JSON 值 MUST 做结构校验(步骤只能是 4 种类型且不重复,退避序列递增)。修改仅超级管理员可用并写统一审计,下一轮调度生效。 + +#### Scenario: 步骤重复 +- **WHEN** 超级管理员提交步骤顺序 `[card_status, carddata, carddata]` +- **THEN** 系统拒绝保存 + +### Requirement: 整体切换与回退 + +系统 SHALL 由受控系统配置 `polling.scheduler_mode`(`legacy` / `carrier`)全局决定卡轮询使用旧分片调度还是新调度,MUST NOT 按运营商账户灰度,任一时刻只有一套调度在领取与执行。切到 `carrier` 时:旧调度停止出队,旧 Asynq 轮询任务执行前检查模式并直接结束;系统从数据库按批初始化全部账户新结构,初始化完成前新分发器不领取。回到 `legacy` 时:新分发器停止领取,执行中任务按收尾规则完成,系统重新初始化旧分片队列后旧调度恢复。所有写入轮询结构的入口按当前模式写新或旧结构。 + +#### Scenario: 切换期间旧任务 +- **WHEN** 切到 `carrier` 时 Asynq 中仍有排队的旧轮询任务 +- **THEN** 这些任务执行时直接结束,不调网关、不重入旧队列 + +#### Scenario: 回退 +- **WHEN** 新调度异常,超级管理员把模式改回 `legacy` +- **THEN** 新分发器停止领取,旧分片队列按数据库重建后恢复轮询,无卡丢失 + +### Requirement: 按账户监控与卡轮询状态查询 + +系统 SHALL 按运营商账户展示各队列长度、已到期数、执行中数、实际 QPS、限流等待与超时次数、处理中标记到期次数、失败告警数(按错误类型);提供单卡轮询状态查询(各检查类型下次到期、连续失败与最近错误、是否执行中),只读。系统 SHALL 提供失败告警列表(按运营商账户、检查类型、错误类型、状态、时间筛选)、按账户 × 错误类型汇总、详情与标记已处理。 + +#### Scenario: 排查单卡 +- **WHEN** 运营查询某卡轮询状态 +- **THEN** 系统返回各检查类型下次到期时间、最近错误与是否执行中,不改变任何状态 diff --git a/openspec/changes/add-carrier-polling-scheduler/specs/polling-load-control/spec.md b/openspec/changes/add-carrier-polling-scheduler/specs/polling-load-control/spec.md new file mode 100644 index 00000000..c8cfb1f3 --- /dev/null +++ b/openspec/changes/add-carrier-polling-scheduler/specs/polling-load-control/spec.md @@ -0,0 +1,5 @@ +## REMOVED Requirements + +### Requirement: 轮询全局并发与背压 +**Reason**: 并发计数不以上游运营商账户为单位、数值没有依据,按集合大小背压会在单分片积压超过阈值后永久停止出队。由 `carrier-polling-scheduler` 的按账户执行上限、领取上限与 `carrier-gateway-rate-limit` 的按账户限流取代。 +**Migration**: 运营按账户配置调度参数与限流组;`tb_polling_concurrency_config` 保留不删、表注释标记废弃,代码不再读写。 diff --git a/openspec/changes/add-carrier-polling-scheduler/specs/polling-operations/spec.md b/openspec/changes/add-carrier-polling-scheduler/specs/polling-operations/spec.md new file mode 100644 index 00000000..c114700a --- /dev/null +++ b/openspec/changes/add-carrier-polling-scheduler/specs/polling-operations/spec.md @@ -0,0 +1,13 @@ +## REMOVED Requirements + +### Requirement: 轮询并发计数可观测 +**Reason**: 按任务类型的并发计数随新调度下线,执行情况改为按运营商账户的执行中数与监控展示。 +**Migration**: 使用 `/polling-stats` 按账户监控与 `/polling-carrier-params` 调度参数;`/polling-concurrency` 下线,前端去掉并发配置页。 + +### Requirement: 轮询并发配置覆盖实际任务 +**Reason**: 并发配置由运营商账户调度参数(执行上限、每进程 `max_in_flight`)取代。 +**Migration**: 同上;`tb_polling_concurrency_config` 保留不删、表注释标记废弃。 + +### Requirement: 轮询任务类型名称可读 +**Reason**: 该名称仅用于并发状态接口,接口随并发配置一起下线。 +**Migration**: 检查类型的中文名称由 `/polling-options` 枚举接口提供。 diff --git a/openspec/changes/add-carrier-polling-scheduler/specs/priority-polling-queue/spec.md b/openspec/changes/add-carrier-polling-scheduler/specs/priority-polling-queue/spec.md new file mode 100644 index 00000000..e34a86ae --- /dev/null +++ b/openspec/changes/add-carrier-polling-scheduler/specs/priority-polling-queue/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: 优先调度与执行提示通道 + +优先轮询项 MUST 以持久化事实为权威;执行提示 MUST 通过把该卡对应检查类型设为立即到期、并把卡放入其所属运营商账户的优先队列下发;卡正在执行时只打优先标记,本次执行结束后立即再执行一次。同一张卡在一个账户中只占一个队列成员,多次提示 MUST 合并。手动触发队列取出的卡 MUST 设为立即到期并放入所属账户的普通队列。分发器 MUST 在每轮领取中先领优先队列、再领普通队列,优先领取数受账户 `priority_max_ratio` 约束。提示 MUST NOT 作为活动、权限或尝试次数的判定依据。优先 MUST NOT 绕过运营商账户限流与执行上限。 + +#### Scenario: 提示丢失 + +- **WHEN** 优先提示因缓存服务重启丢失 +- **THEN** 后续普通轮询执行仍按活动优先轮询项认领并执行,仅退化为延迟到该卡下一次到期,不丢事实也不产生第二套补偿 + +#### Scenario: 同一轮的领取顺序 + +- **WHEN** 同一账户的优先队列与普通队列都有已到期的卡 +- **THEN** 分发器先领优先队列,在优先占比内领取后再领普通队列 + +#### Scenario: 执行中收到优先提示 + +- **WHEN** 卡正在执行卡任务时收到优先提示 +- **THEN** 系统打优先标记,本次执行结束后该卡立即再执行一次,不并行执行 diff --git a/openspec/changes/add-carrier-polling-scheduler/tasks.md b/openspec/changes/add-carrier-polling-scheduler/tasks.md new file mode 100644 index 00000000..7efc4eac --- /dev/null +++ b/openspec/changes/add-carrier-polling-scheduler/tasks.md @@ -0,0 +1,86 @@ +## 0. 前置确认 + +- [ ] 0.1 确认第 1 个 Change(限流器)与第 3 个 Change(去掉保护期)已上线;维护者提供测试环境 nsqd + nsqlookupd 地址。 + +## 1. 迁移与配置 + +- [ ] 1.1 新增成对迁移:`tb_carrier_polling_param`(`carrier_id` 唯一、`inflight_limit`、`card_max_in_flight`、`stop_resume_max_in_flight`、`sr_inflight_limit`、`priority_max_ratio`、`stop_resume_min_interval`、`stop_resume_confirm_delay`、审计字段)。 +- [ ] 1.2 新增成对迁移:`tb_polling_check_scene`(`scene` 唯一、`check_types`、`confirm_delays`、`cooldown`),预置 8 个场景,读入口场景带默认冷却。 +- [ ] 1.3 新增成对迁移:`tb_polling_failure_alert`(卡、运营商账户、检查类型、连续失败、首次与最近失败、最近错误、状态、处理人与时间;部分唯一索引 `(card_id, check_type) WHERE status = 未处理`)。 +- [ ] 1.4 新增成对迁移:`tb_system_config` 插入 `polling.scheduler_mode`(默认 `legacy`)、步骤顺序、单步超时、卡任务总超时余量、退避序列、告警阈值、账户级告警窗口与阈值;`tb_polling_concurrency_config` 表注释标记废弃。 +- [ ] 1.5 `pkg/config` 与 `defaults/config.yaml`:NSQ 地址、`msg_timeout`、租约、续期间隔、分发间隔、优雅退出宽限期、默认执行上限与 `max_in_flight`;`go.mod` 引入 NSQ 客户端。 +- [ ] 1.6 注册受控系统配置项并实现 JSON 结构校验(步骤类型合法且不重复、退避序列递增)。 + +## 2. Redis 结构与 Lua + +- [ ] 2.1 常量:账户队列、running、dispatch_lock,卡 due、claim、exec 的 key。 +- [ ] 2.2 领取 Lua:清理过期 running、执行上限、优先占比、预计调用数对比本秒剩余 QPS、`SET claim NX PX`、推后 score、写 running;已有 claim 时推后到剩余 TTL 后。 +- [ ] 2.3 续期 Lua:校验 token,延长 claim、exec,推后 score 与 running。 +- [ ] 2.4 收尾 Lua:校验 token,删 claim、exec,移出 running,写 due 与失败计数,按优先标记设置 score;token 不一致时只写业务结果。 +- [ ] 2.5 请求检查 Lua:设 due[type]=now、冷却判断、放入 priority 或打优先标记,合并重复请求。 + +## 3. 初始化与生命周期 + +- [ ] 3.1 `initializer.go`:按批读库计算各类型 due(复用第 2 个 Change 的分层与重置日前到期计算),写 due 与账户 queue;进度写入 init-progress。 +- [ ] 3.2 `lifecycle_service.go`、`asset_polling_service.go`:增删卡、状态变化、启停轮询改为操作账户队列与 due;恢复轮询时 due 设为当前加抖动。 +- [ ] 3.3 优先提示(`priority_manual_service.go`)与手动触发(`manual_trigger_service.go`)改为写 due 与账户队列。 + +## 4. 分发器 + +- [ ] 4.1 Worker 内每秒对各账户抢分发锁并执行领取 Lua,发布到 `polling.card.{cid}`;发布失败按 token 删 claim、score 恢复为当前时间。 +- [ ] 4.2 读取账户调度参数(缺省用默认),执行上限不超过订阅进程 `max_in_flight` 之和,不满足时收紧并上报。 +- [ ] 4.3 定时动作:统计各账户处理中标记到期次数、写分发器心跳。 + +## 5. NSQ 消费者与卡任务 + +- [ ] 5.1 NSQ 生产者与消费者封装:按账户动态订阅与取消订阅、`max_in_flight`、`Touch`、优雅退出(停止分发与订阅、宽限期内完成后 FIN)。 +- [ ] 5.2 消费入口:核对 claim token、抢 exec 执行权,任一失败即 FIN。 +- [ ] 5.3 将实名、流量、卡状态、套餐 4 个 handler 拆为步骤函数(`StepInput` 含可选外部结果);按配置顺序串行执行到期类型;单步超时、总超时、panic 恢复。 +- [ ] 5.4 执行期间按续期间隔续期;续期失败立即停止后续步骤。 +- [ ] 5.5 网关调用使用限流器等待模式;限流等待超时按短延迟重排不计失败。 +- [ ] 5.6 沿用 `polling_priority_claim.go` 的认领与收尾;保留卡流量同步锁。 + +## 6. 失败退避与告警 + +- [ ] 6.1 收尾按"卡 × 类型"更新连续失败、首次失败、最近错误;按退避序列计算下次到期,频率限制与限流超时不计失败;成功清零。 +- [ ] 6.2 达到阈值或首次即告警类别时写或更新 `tb_polling_failure_alert`;成功后自动转已恢复;通知走现有告警通道。 +- [ ] 6.3 账户级告警:窗口内同账户告警数超过阈值时产生账户级告警。 + +## 7. 业务事件与读入口请求检查 + +- [ ] 7.1 订单支付、套餐激活、停复机成功、设备操作(按设备下各卡)、自动续费购包 5 处 `AppendSeriesRequested` 改为事务提交后请求检查。 +- [ ] 7.2 C 端资产详情、后台资产实时状态、C 端与后台实名链接改为请求检查;OpenAPI 查询保持不触发。 +- [ ] 7.3 延迟确认:按场景在检查完成后追加确认到期;结果已满足预期时取消。 +- [ ] 7.4 卡检查不再创建观测序列;`query_device_info` 等非运营商上游部分保持不变。 + +## 8. 整体切换 + +- [ ] 8.1 Worker 监听 `polling.scheduler_mode`:切 `carrier` 时停旧调度出队、初始化新结构后开启分发;切 `legacy` 时停止领取、等待执行中任务收尾、重建旧分片队列后恢复旧调度。 +- [ ] 8.2 旧轮询 handler 执行前检查模式,为 `carrier` 时直接结束。 +- [ ] 8.3 所有写入轮询结构的入口按当前模式写新或旧结构。 + +## 9. 接口与下线 + +- [ ] 9.1 `/polling-carrier-params` 列表(含默认标记)、详情、修改、删除;`/polling-check-scenes` 列表与修改;`/polling-failure-alerts` 列表、`/summary`、详情、`PUT /:id/status`;`GET /iot-cards/:id/polling-state`。 +- [ ] 9.2 `/polling-stats` 改为按账户(队列长度、到期数、执行中、实际 QPS、限流等待与超时、处理中标记到期、告警数)。 +- [ ] 9.3 下线 `/polling-concurrency`、`concurrency_service.go`、`acquireConcurrency`/`releaseConcurrency`、按 ZCARD 背压;旧提示通道 List 不再写入。 +- [ ] 9.4 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go`,重新生成接口文档。 + +## 10. 编译与规范校验 + +- [ ] 10.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。 +- [ ] 10.2 `openspec validate add-carrier-polling-scheduler` 与 `openspec doctor --json` 通过。 + +## 11. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001;按生产规模造卡或回放生产卡 ID) + +- [ ] 11.1 多实例同时领取:同卡只被领取一次;同一账户每轮只领一次;执行中数不超过执行上限。 +- [ ] 11.2 处理中崩溃:杀掉执行中的 Worker,租约到期后卡被重新领取,不丢卡。 +- [ ] 11.3 长任务续期:执行超过单个租约时续期成功;构造 token 不一致时续期失败并停止后续步骤。 +- [ ] 11.4 NSQ 重投:同一 token 两个消费者只有一个拿到执行权。 +- [ ] 11.5 同卡串行:状态与流量同时到期时在一个卡任务中先后执行;某步失败后续步骤照常。 +- [ ] 11.6 慢账户隔离:人为让账户 A 变慢,账户 B 与 Outbox 等 Asynq 业务不受影响。 +- [ ] 11.7 请求检查:多次请求合并;冷却时长内不请求;执行中请求打优先标记;批量下单不形成单秒波峰;OpenAPI 查询不触发。 +- [ ] 11.8 失败退避与告警:连续失败按序列退避并在阈值写告警,成功后自动恢复;账户级告警触发。 +- [ ] 11.9 整体切换:切 `carrier` 时旧 Asynq 排队任务直接结束、初始化完成前不领取;实测约 10 万张卡初始化耗时并写入上线步骤;回退后旧分片队列完整、无丢卡;再次切换正常。 +- [ ] 11.10 跨账户设备:设备下各卡分属不同账户时各自按账户执行,无重复执行。 +- [ ] 11.11 按生产卡量评估 Redis 内存占用,确认轮询 key 不会被淘汰。 diff --git a/openspec/changes/add-polling-pause-list-and-batch-toggle/.openspec.yaml b/openspec/changes/add-polling-pause-list-and-batch-toggle/.openspec.yaml new file mode 100644 index 00000000..1f5d2804 --- /dev/null +++ b/openspec/changes/add-polling-pause-list-and-batch-toggle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/add-polling-pause-list-and-batch-toggle/design.md b/openspec/changes/add-polling-pause-list-and-batch-toggle/design.md new file mode 100644 index 00000000..26ae78dc --- /dev/null +++ b/openspec/changes/add-polling-pause-list-and-batch-toggle/design.md @@ -0,0 +1,31 @@ +## Context + +依据 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 12、13 节与 14.5 节。 + +现状:单资产接口 `PATCH /assets/:identifier/polling-status`(`internal/routes/asset.go:140`);`iot_card.Service.BatchUpdatePollingStatus` 仅设备级联内部使用(`asset_polling_service.go:96`);风险停机或已销户自动关闭(`internal/application/cardobservation/network.go:69`);支付成功自动恢复已有部分逻辑。导入任务可参照 `internal/routes/phone_asset_association.go` 的 `/unbind-imports`。生产 2026-10-24:8,420 张风险停机或已销户卡,均关闭轮询。 + +## Goals / Non-Goals + +**Goals:** 人工批量开关轮询;清单可查、可导出、有历史;停机被拒集中处理;套餐生效类入口自动恢复与老系统一致。 + +**Non-Goals:** 导卡时直接设为不轮询(导卡后用文件导入关闭);调度读取清单表;激活状态计算口径。 + +## Decisions + +1. **清单只做记录,调度只认 `enable_polling`**:避免为列表扫描卡大表,也避免两处状态不一致;所有写 `enable_polling` 的入口统一经一个"改轮询开关"函数,同事务写清单与审计。 +2. **部分唯一索引**:`(asset_type, asset_id) WHERE status = 已关闭`,保证同一资产只有一条已关闭记录;恢复时更新该记录。 +3. **批量逐项事务**:每项独立事务,部分失败不影响其他项;返回逐项结果。参照 `batch-update-realname-policy` 的请求与返回格式。 +4. **导入任务**:沿用手机号资产解绑导入的上传与任务模型(文件 key 前缀、CSV、逐行结果)。 +5. **自动恢复轮询的位置**:订单 `activatePackage`(覆盖支付、后台下单、批量下单、OpenAPI 钱包下单)与改有效期接口;恢复后调用"请求评估"(第 6 个 Change 上线前为现有复机路径)。 +6. **存量补写**:迁移中按 `enable_polling = false` 一次性插入,来源按 `gateway_extend` 区分。 +7. **停机被拒视图**:基于 `tb_polling_failure_alert` 的筛选视图,不新建表。 + +## Risks / Trade-offs + +- [批量关闭误操作] → 确认框展示影响说明;清单保留操作人与原因,可在清单页批量恢复。 +- [存量补写数据量] → 约 8,500 行,一次性迁移可接受。 +- [自动恢复与人工关闭意图冲突] → 已定对齐老系统:客户购买或改有效期即恢复;需要长期停用的卡运营须另行处理。 + +## Migration Plan + +1. 迁移建表并补写存量。2. 测试环境按 tasks 第 8 节核对。3. 维护者发布生产。4. 回退:代码回退;清单表保留。 diff --git a/openspec/changes/add-polling-pause-list-and-batch-toggle/proposal.md b/openspec/changes/add-polling-pause-list-and-batch-toggle/proposal.md new file mode 100644 index 00000000..87aeeeed --- /dev/null +++ b/openspec/changes/add-polling-pause-list-and-batch-toggle/proposal.md @@ -0,0 +1,33 @@ +## Why + +开关轮询目前只有单资产接口 `PATCH /assets/:identifier/polling-status` 与系统自动规则,没有人工批量操作,卡列表也不能按是否轮询筛选,也没有"谁、何时、为什么关闭"的记录;生产约 8,456 张关闭轮询的卡中约 8,420 张是风险停机或已销户自动关闭,人工关闭的卡无从查起。规则优先后,人工停机不享有豁免,需要保持停机的卡必须关闭轮询,运营需要批量处理与清单;停机被拒的卡也需要单独列出、导出与批量关闭。老系统"不轮询网卡"页面提供了对应能力。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 7 步。 + +## What Changes + +- 卡、设备勾选批量开关轮询:单次最多 500,带目标状态与原因;数据权限校验;同一事务写 `enable_polling`、不轮询清单与审计;提交后按生命周期移入或移出轮询(设备沿用级联到卡)。 +- 文件导入:上传 ICCID 或设备 IMEI 文件选择开启或关闭,异步任务逐行处理、逐行返回结果,部分成功不回滚。 +- 批量开启跳过网关扩展状态为风险停机或已销户的卡,逐项返回跳过原因,不提供强制开启参数。 +- 不轮询清单(新表 `tb_card_polling_pause`):记录关闭来源、原因、操作人、时间与恢复来源;每个资产只保留一条"已关闭"记录,恢复时标记"已恢复"并保留历史;所有改 `enable_polling` 的入口同事务写入(单资产接口、批量、导入、风险停机或已销户自动关闭、设备级联、自动恢复、告警页关闭)。调度不读本表。 +- 存量补写:上线时为当前 `enable_polling = false` 的卡与设备补写清单,风险停机或已销户记为对应自动关闭来源,其余记为历史数据。 +- **BREAKING** 套餐生效类入口自动恢复轮询:订单套餐生效(客户支付成功、后台下单、批量下单、OpenAPI 钱包下单)、后台修改套餐有效期后资产有生效套餐时,先把卡或设备恢复轮询(写清单与审计)再按规则评估复机;风险停机或已销户的卡不恢复。 +- 不轮询清单页面:按运营商账户、来源、状态、ICCID、操作人、时间筛选,导出复用统一导出任务(场景 `polling_pause`),列表勾选可批量开启。 +- 卡列表、设备列表增加"是否轮询"筛选,导出带上该字段。 +- 失败告警页增加"停机被拒"视图与导出(场景 `polling_stop_refused_alert`),可勾选批量关闭轮询(来源"停机被拒告警处理")。 +- 关闭与开启轮询的确认框须提示影响(关闭后不查上游、流量用完与实名不满足不自动停机、激活与重置不自动复机;套餐到期与退款后无套餐仍停机;订单生效与改有效期会自动恢复)。 + +## Capabilities + +### New Capabilities + +- `polling-pause-list`:批量与文件导入开关轮询、不轮询清单与历史、所有开关入口同事务写清单、存量补写、套餐生效类自动恢复轮询、清单查询与导出、卡与设备列表是否轮询筛选。 + +### Modified Capabilities + +- `carrier-polling-scheduler`:失败告警增加"停机被拒"视图、导出与批量关闭轮询(该能力由第 5 个 Change 引入)。 + +## Impact + +- 依赖:开关与清单部分无前置;停机被拒视图依赖第 5 个 Change 的失败告警表与第 6 个 Change 的停复机方向告警;自动恢复轮询后的"按规则评估"在第 6 个 Change 上线前沿用现有复机路径。 +- 数据库:新增 `tb_card_polling_pause`(部分唯一索引:同一资产仅一条已关闭)与存量补写迁移;新的成对迁移。 +- 代码:`internal/service/iot_card` 与 `internal/service/device` 批量接口;`internal/service/polling/asset_polling_service.go`;导入任务(参照 `phone_asset_association` 的 `/unbind-imports`);`internal/application/cardobservation/network.go` 自动关闭写清单;`internal/service/order/service.go` `activatePackage` 与改有效期接口自动恢复;卡、设备列表 Query 与导出;导出场景注册。 +- 接口:新增 `POST /iot-cards/batch-update-polling-status`、`POST /devices/batch-update-polling-status`、`/polling-status-imports`(创建、列表、详情)、`GET /card-polling-pauses`、`POST /polling-failure-alerts/close-polling`、`GET /polling-failure-alerts?view=stop_refused`;卡与设备列表增加 `enable_polling` 筛选。前端新增不轮询清单页、导入页,卡与设备列表增加筛选与批量操作,告警页增加停机被拒视图。 diff --git a/openspec/changes/add-polling-pause-list-and-batch-toggle/specs/carrier-polling-scheduler/spec.md b/openspec/changes/add-polling-pause-list-and-batch-toggle/specs/carrier-polling-scheduler/spec.md new file mode 100644 index 00000000..f18b8aec --- /dev/null +++ b/openspec/changes/add-polling-pause-list-and-batch-toggle/specs/carrier-polling-scheduler/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: 停机被拒视图与批量关闭轮询 + +系统 SHALL 在失败告警中提供"停机被拒"视图:停复机方向为停机,且错误类型为终态或业务错误、或停机失败次数达到阈值的告警;展示 ICCID、运营商账户、所属资产、网络状态、套餐状态、停机原因、上游返回码与信息、首次与最近失败时间、失败次数、是否已关闭轮询、处理状态与处理人。视图 SHALL 可通过统一导出任务导出(场景 `polling_stop_refused_alert`)。系统 SHALL 支持在告警列表勾选批量关闭对应卡或设备的轮询(单次最多 500,规则同批量开关轮询),清单来源记为"停机被拒告警处理",对应告警状态变为"已关闭轮询"。 + +#### Scenario: 导出停机被拒 +- **WHEN** 运营导出停机被拒视图 +- **THEN** 系统创建导出任务,文件包含上述字段 + +#### Scenario: 告警页批量关闭 +- **WHEN** 运营勾选 50 条停机被拒告警关闭轮询 +- **THEN** 对应卡关闭轮询、写清单来源"停机被拒告警处理",告警状态变为已关闭轮询 diff --git a/openspec/changes/add-polling-pause-list-and-batch-toggle/specs/polling-pause-list/spec.md b/openspec/changes/add-polling-pause-list-and-batch-toggle/specs/polling-pause-list/spec.md new file mode 100644 index 00000000..50dd7f60 --- /dev/null +++ b/openspec/changes/add-polling-pause-list-and-batch-toggle/specs/polling-pause-list/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: 勾选批量开关轮询 + +系统 SHALL 提供卡与设备的批量开关轮询:请求包含资产 ID 列表(单次最多 500)、目标状态与原因;系统 MUST 校验数据权限,逐项处理并返回每项结果。每项在同一事务内写 `enable_polling`、不轮询清单与审计,事务提交后按生命周期移入或移出轮询;设备沿用现有级联到设备下各卡。批量开启 MUST 跳过网关扩展状态为风险停机或已销户的卡并返回跳过原因,不提供强制开启参数。 + +#### Scenario: 批量关闭 +- **WHEN** 运营勾选 200 张卡关闭轮询并填写原因 +- **THEN** 200 张卡的 `enable_polling` 变为关闭,各写一条"已关闭"清单记录与审计,并移出轮询 + +#### Scenario: 批量开启含已销户卡 +- **WHEN** 勾选的卡中有 3 张已销户 +- **THEN** 这 3 张返回"已销户,不能开启轮询",其余卡照常开启 + +#### Scenario: 超过单次上限 +- **WHEN** 请求包含 501 个资产 +- **THEN** 系统拒绝请求 + +#### Scenario: 越权资产 +- **WHEN** 列表中包含操作者无数据权限的资产 +- **THEN** 该项返回无权限,其他项照常处理 + +### Requirement: 文件导入开关轮询 + +系统 SHALL 支持上传 ICCID(或设备 IMEI)文件并选择开启或关闭,以异步任务逐行处理;每行独立成功或失败并记录原因,部分成功不回滚;提供任务创建、列表与详情(含逐行结果)。逐行处理规则与勾选批量相同。 + +#### Scenario: 文件中有不存在的 ICCID +- **WHEN** 导入 1000 行,其中 5 行 ICCID 不存在 +- **THEN** 995 行按目标状态处理,5 行记"资产不存在",任务结束状态为部分成功 + +### Requirement: 不轮询清单与历史 + +系统 SHALL 在每次 `enable_polling` 由开启变为关闭时新增一条"已关闭"清单记录(资产类型、资产 ID、ICCID 或 IMEI、卡所属运营商账户、关闭来源、原因、操作人、时间、导入任务),同一资产 MUST 只有一条"已关闭"记录;由关闭变为开启时把该记录标为"已恢复"并写恢复来源、操作人与时间,记录保留。所有改 `enable_polling` 的入口 MUST 在同一事务内写清单:单资产接口、勾选批量、文件导入、风险停机或已销户自动关闭、设备级联、套餐生效类自动恢复、告警页关闭轮询。调度判断 MUST 只以卡与设备的 `enable_polling` 为准,不读本表。上线时系统 MUST 为当前关闭轮询的卡与设备补写清单:网关扩展状态为风险停机或已销户的记为"风险停机或已销户自动关闭",其余记为"历史数据"。 + +#### Scenario: 再次关闭 +- **WHEN** 已恢复的卡再次被关闭 +- **THEN** 系统新增一条"已关闭"记录,历史记录保留 + +#### Scenario: 自动关闭 +- **WHEN** 查卡状态返回已销户而自动关闭轮询 +- **THEN** 同一事务写一条来源为"风险停机或已销户自动关闭"的清单记录 + +### Requirement: 套餐生效类入口自动恢复轮询 + +系统 SHALL 在订单套餐生效(客户支付成功、后台下单、批量下单、OpenAPI 钱包下单)以及后台修改套餐有效期后资产有生效套餐时,先把关闭轮询的卡或设备恢复轮询(写清单"已恢复"与审计),再按停复机规则评估复机。网关扩展状态为风险停机或已销户的卡 MUST NOT 恢复。排队激活、实名激活、流量重置、运营商账户实名管控修改 MUST NOT 自动恢复轮询。 + +#### Scenario: 关闭轮询的卡客户续费 +- **WHEN** 被人工关闭轮询的停机卡客户支付购买套餐并生效 +- **THEN** 系统恢复该卡轮询,随后按规则复机 + +#### Scenario: 已销户卡下单 +- **WHEN** 已销户卡后台下单套餐生效 +- **THEN** 系统不恢复该卡轮询 + +#### Scenario: 排队激活 +- **WHEN** 关闭轮询的卡排队套餐自动激活 +- **THEN** 系统不恢复轮询,不自动复机 + +### Requirement: 清单查询、导出与是否轮询筛选 + +系统 SHALL 提供不轮询清单列表(按运营商账户、来源、状态、ICCID、操作人、时间筛选)与导出(统一导出任务,场景 `polling_pause`),列表勾选可批量开启。卡列表与设备列表 SHALL 支持按是否轮询筛选,导出 MUST 带上是否轮询字段。查询与导出 MUST 遵守现有数据权限。 + +#### Scenario: 筛选人工关闭 +- **WHEN** 运营按来源"勾选批量"筛选不轮询清单 +- **THEN** 返回人工批量关闭的记录,不被系统自动关闭的记录淹没 + +### Requirement: 开关轮询的影响提示 + +系统 SHALL 在关闭与开启轮询的接口文档与返回中提供影响说明,供前端确认框展示:关闭后不查上游,流量用完、实名不满足不自动停机,激活、流量重置不自动复机;套餐到期、退款后无套餐仍自动停机;订单套餐生效、改有效期后有生效套餐会自动恢复轮询并按规则复机;人工停复机照常。设备关闭时设备下所有卡同样适用。 + +#### Scenario: 关闭确认 +- **WHEN** 前端请求关闭轮询前获取影响说明 +- **THEN** 系统返回上述影响条目 diff --git a/openspec/changes/add-polling-pause-list-and-batch-toggle/tasks.md b/openspec/changes/add-polling-pause-list-and-batch-toggle/tasks.md new file mode 100644 index 00000000..70ce6081 --- /dev/null +++ b/openspec/changes/add-polling-pause-list-and-batch-toggle/tasks.md @@ -0,0 +1,49 @@ +## 0. 前置确认 + +- [ ] 0.1 停机被拒视图实施前确认第 5、6 个 Change 已上线;开关与清单部分可先行。 + +## 1. 迁移与模型 + +- [ ] 1.1 新增成对迁移:建 `tb_card_polling_pause`(资产类型、资产 ID、ICCID 或 IMEI、运营商账户、状态、关闭来源、原因、操作人、关闭时间、导入任务 ID、恢复来源、恢复人、恢复时间;部分唯一索引 `(asset_type, asset_id) WHERE status = 已关闭`);down 删表。 +- [ ] 1.2 同迁移补写存量:`enable_polling = false` 的卡与设备,风险停机或已销户记"风险停机或已销户自动关闭",其余记"历史数据"。 +- [ ] 1.3 新增 model 与 Store。 + +## 2. 统一的轮询开关写入 + +- [ ] 2.1 新增"改轮询开关(资产、目标状态、来源、原因、操作人)":同事务写 `enable_polling`、清单与审计,提交后调用生命周期移入或移出。 +- [ ] 2.2 改造全部入口经此函数:单资产接口、风险停机或已销户自动关闭(`network.go`)、设备级联、自动恢复、告警页关闭。 + +## 3. 批量与导入 + +- [ ] 3.1 `POST /iot-cards/batch-update-polling-status`、`POST /devices/batch-update-polling-status`:单次 500、数据权限、逐项事务与结果、批量开启跳过风险停机与已销户。 +- [ ] 3.2 文件导入:`POST /polling-status-imports`、`GET /polling-status-imports`、`GET /polling-status-imports/:id`;异步逐行处理与结果。 + +## 4. 自动恢复轮询 + +- [ ] 4.1 `order/service.go` `activatePackage` 套餐生效后:关闭轮询且非风险停机、已销户的卡或设备先恢复轮询(来源"支付或下单生效"),再请求评估(第 6 个 Change 前为现有复机路径)。 +- [ ] 4.2 改有效期接口:保存后资产有生效套餐时同样恢复(来源"改有效期")。 + +## 5. 查询、筛选与导出 + +- [ ] 5.1 `GET /card-polling-pauses`(筛选与分页);导出场景 `polling_pause`;清单页勾选批量开启复用 3.1。 +- [ ] 5.2 卡列表、设备列表 Query 增加 `enable_polling` 筛选,导出带该字段。 +- [ ] 5.3 开关轮询影响说明随接口文档与返回提供。 + +## 6. 停机被拒视图 + +- [ ] 6.1 `GET /polling-failure-alerts?view=stop_refused` 与展示字段;导出场景 `polling_stop_refused_alert`。 +- [ ] 6.2 `POST /polling-failure-alerts/close-polling`:单次 500,经统一开关写入,来源"停机被拒告警处理",告警状态改为已关闭轮询。 + +## 7. 编译与规范校验 + +- [ ] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;重新生成接口文档。 +- [ ] 7.2 `openspec validate add-polling-pause-list-and-batch-toggle` 与 `openspec doctor --json` 通过。 + +## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001) + +- [ ] 8.1 批量关闭与开启:逐项结果正确;风险停机与已销户跳过;超过 500 被拒;越权项返回无权限。 +- [ ] 8.2 文件导入部分成功不回滚,逐行原因正确。 +- [ ] 8.3 清单:每个入口改开关都写清单;同一资产只有一条已关闭;恢复后保留历史;存量补写数量与生产关闭轮询的卡设备数一致。 +- [ ] 8.4 自动恢复:支付、后台下单、批量下单、OpenAPI 下单、改有效期后恢复轮询并按规则复机;已销户不恢复;排队激活不恢复。 +- [ ] 8.5 卡与设备列表是否轮询筛选与导出字段正确;清单导出与停机被拒导出可用。 +- [ ] 8.6 告警页批量关闭轮询后清单来源与告警状态正确。 diff --git a/openspec/changes/add-stop-resume-command-queue/.openspec.yaml b/openspec/changes/add-stop-resume-command-queue/.openspec.yaml new file mode 100644 index 00000000..1f5d2804 --- /dev/null +++ b/openspec/changes/add-stop-resume-command-queue/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/add-stop-resume-command-queue/design.md b/openspec/changes/add-stop-resume-command-queue/design.md new file mode 100644 index 00000000..f9c166c0 --- /dev/null +++ b/openspec/changes/add-stop-resume-command-queue/design.md @@ -0,0 +1,37 @@ +## Context + +完整设计见 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 6、6.1、6.2、11.5、12 节,第 9 节停复机出口开关。 + +现状:`StopResumeService.EvaluateAndAct`(`stop_resume_service.go:113`)判断后当场调网关,`stopCardWithRetry` / `resumeCardWithRetry` 3 次、每次 sleep 2 秒;设备维度原因抢 `RedisPollingDeviceOpLockKey`(SetNX 30 秒,抢不到跳过)逐张停;`resumeAfterActivation`(4 处)、`reset_service.go:400`、`order/service.go:2196` 起 goroutine 调 `ResumeCardIfStopped`;退款 `refund/service.go:1258` 调 `ManualStopCard`,失败由 Outbox 与 `RecoverRefundPostProcessing` 反复补发;套餐到期由套餐轮询发现。第 5 个 Change 已提供账户队列、分发器、NSQ、失败告警表;第 1 个 Change 已提供限流、错误分类与停复机记录。 + +## Goals / Non-Goals + +**Goals:** 自动停复机一个出口、不风暴、不翻转、不丢;设备下各卡一致;暂停轮询的卡套餐失效照常停。 + +**Non-Goals:** 不改停复机业务规则(第 4 个 Change 已定口径);不改后台人工点击、通道阈值、自动续费的停复机;不做提前停机、停机前续费等老系统规则。 + +## Decisions + +1. **Redis 结构**:账户 `polling:carrier:{cid}:stop_resume`(ZSET)与 `:sr_running`;卡 `polling:card:{id}:stop_resume_cmd`(Hash:动作、原因、场景、来源、版本、失败计数、首次失败、最近错误、待确认)、`:sr_claim`、`:sr_exec`、`:sr_last_call`;设备 `polling:device_eval:queue`(ZSET)、`polling:device:{id}:eval_ver`、`:eval_scenes`(Set)、`:eval_claim`、`:eval_lock`;分发锁 `polling:device_eval:dispatch_lock`。 +2. **统一判断函数**:从 `EvaluateAndAct`、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 抽出"输入卡与资产数据 + 场景 → 目标状态与原因",入口与执行器共用;执行部分抽出供执行器调用一次网关。 +3. **版本号**:覆盖指令 `HINCRBY ver`;领取时随消息带版本;收尾 Lua 比较版本决定移除或保留。 +4. **单卡最小重试间隔按卡合计**:上游"卡状态变更每十分钟只允许调用 1 次"对停机与复机共同计数;领取 Lua 校验 `sr_last_call`,执行器调用前写。 +5. **结果未知在队列内闭环**:不复用现有人工核对策略(`GatewayCardCommandUnknownRecoveryStrategy` 只有人工核对);确认查询属于查状态,计入停复机所在限流组,不受单卡最小间隔约束。 +6. **设备判断**:`ZADD LT` 合并;`SADD eval_scenes`;领取带版本与场景快照;收尾 `SREM` 已处理场景并比较版本。锁内只读与写指令,毫秒级;与旧路径的 `RedisPollingDeviceOpLockKey` 互斥。 +7. **全局开关**:`polling.stop_resume_queue_enabled` 受控系统配置;所有入口通过一个"请求评估"门面按开关决定入队还是旧路径当场执行,避免分散判断。停复机与设备判断的分发不受 `scheduler_mode` 限制:开关开启后对全部账户领取(`scheduler_mode = legacy` 时套餐步骤仍在旧 handler 内,同样改为请求评估)。 +8. **退款完成语义**:请求写入成功即标记 `asset_reset`,补偿扫描只覆盖"请求未写入"的情况,不再因停机失败重放。 +9. **到期即停入口**:`package_activation_handler.go` 到期失效处理处判断无其他有效主套餐后请求评估(场景"套餐到期");接续激活路径不变。 + +## Risks / Trade-offs + +- [停复机比现在晚 1~2 秒] → 已接受;停复机先于卡任务领取并可预留 QPS。 +- [请求评估写 Redis 失败] → 记日志,由该卡下一次套餐步骤兜底;暂停轮询的卡套餐失效停机按已定口径不补偿。 +- [流量重置日集中复机] → 按账户停复机 QPS 与执行上限平滑执行,监控队列长度。 +- [开关切换中旧路径与新路径并存] → 设备锁互斥;开关关闭时队列指令保留,重新开启时执行前重新判断。 + +## Migration Plan + +1. 迁移插入开关(默认关闭)与告警阈值。 +2. 测试环境部署,开关关闭时行为与现状一致;开启后按 tasks 第 10 节核对;演练开、关、再开。 +3. 生产发布后开启开关,首日监控停复机成功率、队列长度与失败告警。 +4. 回退:关闭开关。 diff --git a/openspec/changes/add-stop-resume-command-queue/proposal.md b/openspec/changes/add-stop-resume-command-queue/proposal.md new file mode 100644 index 00000000..7ee07fa8 --- /dev/null +++ b/openspec/changes/add-stop-resume-command-queue/proposal.md @@ -0,0 +1,35 @@ +## Why + +自动停复机没有独立队列,在哪个任务里触发就在哪里同步调网关并 sleep 重试;激活、重置、订单后的复机在裸 goroutine 里执行,失败只打日志;退款走人工停机接口,失败后 Outbox 重投与补偿扫描反复补发(生产 2026-10-22:24 小时失败约 26.8 万次);设备下多张卡(生产约 58% 设备跨运营商账户)可能被并发的判断互相覆盖,造成翻转。另外暂停轮询的卡套餐到期、退款后无套餐也应停机,但现有设计会漏停。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 6 步。 + +## What Changes + +- 新增停复机指令队列:所有自动入口(套餐步骤、Outbox 三个卡观测消费者、套餐激活、流量重置、订单支付后、退款后资产处理、套餐到期、运营商账户实名管控修改)只"请求评估",由统一判断函数生成指令,进入卡所属运营商账户的停复机队列,优先级最高、先于卡任务领取。 +- 同卡只保留一条指令,新覆盖旧并递增版本号;收尾按版本号判断,执行期间生成的新指令不会被删除。 +- 执行器:执行前按规则重新判断;只调用一次上游、不 sleep 重试;单卡最小重试间隔(停机与复机合计,按账户配置);结果未知按"确认延迟 → 查状态 → 已是目标状态结束 / 否则按间隔重发"处理,不沿用现有人工核对恢复;失败按错误分类退避、告警,指令不丢弃;终态不重试。 +- 设备维度判断队列:按设备合并请求、保存并合并请求场景、版本号再判断、设备锁内只判断不调网关,为设备下各卡生成指令进各自账户队列。 +- 到期即停:套餐到期且资产无其他有效套餐时,以"套餐到期"场景直接请求评估(不经过套餐步骤)。 +- **BREAKING** 暂停轮询口径:暂停轮询的卡或设备,套餐到期、退款后无可用主套餐照常停机(只触发一次,不做补偿扫描);其他自动停复机仍不做。 +- **BREAKING** 退款后资产处理不再调用 `ManualStopCard`:改为请求评估,请求写入成功即视为资产后处理完成,停机失败由指令队列退避与告警处理,不再触发 Outbox 重投与补偿扫描。 +- **BREAKING** 激活、重置、订单后的复机去掉裸 goroutine,改为事务提交后请求评估。 +- 运营商账户实名管控修改后对受影响的卡批量请求评估(0 → 1 在线未实名,1 → 0 停机未实名),按账户 QPS 平滑执行。 +- 全局开关 `polling.stop_resume_queue_enabled`(受控系统配置,默认关闭):开启后所有账户的自动停复机经指令队列,不按账户灰度;关闭即回退为各入口当场执行。 +- 停复机方向的失败接入失败告警表;监控增加停复机成功、失败、重试与指令队列长度。 + +## Capabilities + +### New Capabilities + +- `stop-resume-command-queue`:请求评估与统一判断、指令覆盖与版本、执行器与单卡最小重试间隔、结果未知确认、失败退避与告警、设备维度判断与场景合并、自动入口统一、全局开关与回退。 + +### Modified Capabilities + +- `card-stop-resume`:"暂停轮询的卡不自动停复机"增加套餐到期与退款后无套餐照常停机;新增"套餐到期即停"。 + +## Impact + +- 依赖:第 1 个(限流、错误分类、停复机记录)、第 4 个(新规则转正)、第 5 个(账户调度、NSQ、失败告警表)Change 已上线。 +- 数据库:`tb_system_config` 插入 `polling.stop_resume_queue_enabled` 与结果未知告警阈值;新的成对迁移。 +- 代码:`internal/service/iot_card/stop_resume_service.go`(`EvaluateAndAct` 只判断、生成指令;抽出执行部分)、`stop_resume_unified.go`;`internal/infrastructure/cardobservation/event.go`;`internal/service/package/activation_service.go`、`reset_service.go`、`internal/service/order/service.go`;`internal/service/refund/service.go` `stopAsset` 与 `internal/infrastructure/commissiondelivery/event.go` 补偿扫描;`internal/task/package_activation_handler.go`(到期即停);`internal/service/carrier/service.go`;分发器与 NSQ 消费者新增停复机与设备判断;`RedisPollingDeviceOpLockKey` 轮询路径替换。 +- 接口:调度参数中的停复机参数生效;`/polling-failure-alerts` 包含停复机方向;`/iot-cards/:id/polling-state` 返回当前指令;监控增加停复机指标。 +- 行为:自动停复机比现在晚约 1~2 秒;后台人工点击、通道阈值、自动续费的停复机仍直接执行。 diff --git a/openspec/changes/add-stop-resume-command-queue/specs/card-stop-resume/spec.md b/openspec/changes/add-stop-resume-command-queue/specs/card-stop-resume/spec.md new file mode 100644 index 00000000..17720fc1 --- /dev/null +++ b/openspec/changes/add-stop-resume-command-queue/specs/card-stop-resume/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: 暂停轮询的卡不自动停复机 +卡或其绑定设备暂停轮询的,系统 SHALL NOT 对该卡发起由检查结果或周期评估触发的自动停复机(流量用完、实名不满足、卡状态检查后的停复机、套餐步骤评估),也 SHALL NOT 发起套餐激活、套餐重置、充值、排队激活、实名激活、运营商账户实名管控修改等入口触发的自动复机;人工停机与人工复机照常执行。套餐失效类停机 MUST 照常执行,不看暂停轮询:套餐到期且资产无其他有效套餐、退款后资产无可用主套餐时,系统按"套餐到期"或"退款"场景请求评估并停机;该停机对暂停轮询的卡只触发一次,之后由停复机指令按失败处理重试,请求写入失败或缓存数据丢失时不做补偿扫描。请求评估与停复机指令 MUST 携带场景,执行前重新判断沿用同一场景。网关扩展状态为风险停机或已销户的卡 MUST 关闭该卡自身轮询,不论是否绑定设备:查卡状态返回该扩展状态时关闭;停复机调用直接返回命中"自动关闭轮询"规则的终态时,同样关闭并回写网关扩展状态。关闭只作用于该卡,MUST NOT 级联关闭设备与设备下其他卡。机卡分离停机等其他运营商异常状态 MUST NOT 自动关闭轮询,由人工判断。未暂停轮询的卡,不论运营商扩展状态如何,均按停机与复机条件处理,上游拒绝复机时按现有失败处理。 + +#### Scenario: 暂停轮询的卡充值后 +- **WHEN** 已被人工暂停轮询的停机卡充值并激活套餐 +- **THEN** 系统不自动复机;人工复机照常可用 + +#### Scenario: 暂停轮询的卡套餐到期 +- **WHEN** 暂停轮询的开机卡套餐到期且无其他有效套餐 +- **THEN** 系统以"套餐到期"场景生成停机指令并执行 + +#### Scenario: 暂停轮询的卡退款后 +- **WHEN** 暂停轮询的开机卡退款后资产无可用主套餐 +- **THEN** 系统以"退款"场景生成停机指令并执行,只触发一次 + +#### Scenario: 暂停轮询的卡流量用完 +- **WHEN** 暂停轮询的卡流量用完 +- **THEN** 系统不自动停机 + +#### Scenario: 恢复轮询后 +- **WHEN** 维护者为该卡恢复轮询 +- **THEN** 下一次评估起系统按停机与复机条件正常处理 + +#### Scenario: 设备卡出现机卡分离停机 +- **WHEN** 设备下的卡运营商扩展状态为机卡分离停机且未被暂停轮询 +- **THEN** 系统不自动暂停轮询,按停机与复机条件处理 + +#### Scenario: 设备下的卡被销户 +- **WHEN** 查卡状态返回绑定设备的某张卡扩展状态为已销户 +- **THEN** 系统关闭该卡轮询,设备与设备下其他卡的轮询不变 + +#### Scenario: 复机返回风险停机 +- **WHEN** 复机调用返回"当前卡为风险停机状态,仅支持风险复机"且该规则标记自动关闭轮询 +- **THEN** 系统不重试,关闭该卡轮询并回写扩展状态为风险停机 + +## ADDED Requirements + +### Requirement: 套餐到期即停 + +系统 SHALL 在套餐到期失效处理时,若该资产已无其他有效主套餐,直接以"套餐到期"场景请求停复机评估:独立卡当场判断并生成停机指令,设备请求设备维度判断并记录该场景;MUST NOT 依赖卡任务的套餐步骤发现过期。存在待生效的后续主套餐时,按现有到期接续流程先激活后续套餐,不发起停机。 + +#### Scenario: 独立卡到期无后续套餐 +- **WHEN** 独立卡主套餐到期且无后续主套餐 +- **THEN** 系统立即生成 `no_package` 停机指令,不等待下一次套餐检查 + +#### Scenario: 到期有后续套餐 +- **WHEN** 主套餐到期且存在待生效的后续主套餐 +- **THEN** 系统按接续流程激活后续套餐,不生成停机指令 diff --git a/openspec/changes/add-stop-resume-command-queue/specs/stop-resume-command-queue/spec.md b/openspec/changes/add-stop-resume-command-queue/specs/stop-resume-command-queue/spec.md new file mode 100644 index 00000000..a507dac8 --- /dev/null +++ b/openspec/changes/add-stop-resume-command-queue/specs/stop-resume-command-queue/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: 自动停复机统一经指令队列 + +在 `polling.stop_resume_queue_enabled` 开启时,系统 SHALL 让套餐步骤、Outbox 卡观测消费者(流量增加、网络状态变化、实名变化)、套餐激活、流量重置、订单支付后、退款后资产处理、套餐到期、运营商账户实名管控修改这些自动入口只请求评估:按统一判断函数(暂停轮询 → 停机条件 → 复机条件 → 通道阈值锁)得出目标状态,需要动作时生成停复机指令并放入卡所属运营商账户的停复机队列,MUST NOT 在入口当场调用网关。请求评估本身只写共享缓存;写入失败 MUST 记日志,并由该卡下一次套餐步骤兜底。后台人工点击、通道阈值、自动续费发起的停复机 MUST 照旧直接执行。开关关闭时各入口 MUST 恢复当场执行;队列中未执行的指令保留,再次开启后继续执行,执行前重新判断。开关 MUST 全局生效,不按运营商账户灰度。 + +#### Scenario: 订单支付后复机 +- **WHEN** 客户支付后套餐生效,卡处于停机且满足复机条件 +- **THEN** 订单事务提交后系统生成复机指令,由指令执行器调用上游,支付流程不等待也不起 goroutine + +#### Scenario: 开关关闭 +- **WHEN** 超级管理员关闭 `polling.stop_resume_queue_enabled` +- **THEN** 各自动入口恢复当场执行,已在队列中的指令保留不删 + +### Requirement: 同卡一条指令与版本号 + +系统 SHALL 对同一张卡只保留一条停复机指令,新指令覆盖旧指令,方向相反时以最新判断为准、失败计数清零;每次生成或覆盖 MUST 递增指令版本号。执行器领取时记下版本号;收尾时版本号与领取时一致才移除指令,不一致 MUST 保留新指令,并把执行时间设为 max(当前时间, 本卡最近一次停复机调用时间 + 单卡最小重试间隔)。 + +#### Scenario: 停机执行中客户付费 +- **GIVEN** 卡 A 停机指令(版本 1)正在执行 +- **WHEN** 执行期间客户付费生成复机指令(版本 2),随后停机成功 +- **THEN** 收尾保留复机指令,在单卡最小重试间隔后执行复机 + +### Requirement: 指令执行与单卡最小重试间隔 + +系统 SHALL 优先于卡任务领取停复机指令,领取数不超过停复机所在限流组本秒剩余 QPS 与账户停复机执行上限减执行中数。同一张卡两次停复机网关调用(停机与复机合计)间隔 MUST 不小于账户配置的单卡最小重试间隔,未到间隔时推后执行时间、不发起调用。执行前系统 MUST 按指令记录的场景重新判断,已是目标状态或条件不再满足则结束指令。网关只调用一次,不 sleep 重试;成功后按现有回写更新卡状态、停机原因与网络状态序列,并写停复机记录。 + +#### Scenario: 停机后几分钟内购买套餐 +- **GIVEN** 卡 A 5 分钟前被停机,单卡最小重试间隔 10 分钟 +- **WHEN** 客户购买套餐生成复机指令 +- **THEN** 系统在停机后满 10 分钟时才调用复机,期间不调用上游 + +#### Scenario: 执行前已是目标状态 +- **WHEN** 停机指令执行前重新判断发现卡已停机 +- **THEN** 系统结束指令,不调用上游停机 + +### Requirement: 停复机结果未知的确认 + +停复机调用超时、连接失败或上游返回"调用成功""操作进行中""请求被合并"等已受理类频率限制时,系统 SHALL 置待确认标记并保留指令,在账户配置的确认延迟后再次领取:先按规则重新判断,条件不再满足则结束;否则查一次卡状态(受限流、不受单卡最小重试间隔约束),已是目标状态则按成功回写并结束;不是目标状态则清除待确认,满足单卡最小重试间隔时重新调用停复机,否则推后;查状态失败 MUST 保持待确认并按退避重查,不发起停复机。连续结果未知达到阈值时写失败告警,指令不丢弃。 + +#### Scenario: 超时后确认已停机 +- **WHEN** 停机超时,确认延迟后查状态显示已停机 +- **THEN** 系统按成功回写,结束指令,停复机记录结果为确认成功 + +#### Scenario: 查状态也失败 +- **WHEN** 确认查询失败 +- **THEN** 系统不发起停机,按退避再次查询 + +### Requirement: 停复机失败分类处理 + +系统 SHALL 按错误分类处理停复机失败:频率限制不计连续失败,按单卡最小重试间隔与上游窗口退避;终态不可操作不重试、结束指令、写告警(标记自动关闭轮询的终态按 `card-stop-resume` 关闭该卡轮询),停机方向且上游已不可用时按目标状态视为已停;已是目标状态按成功处理;账户配置错误首次即产生账户级告警;临时故障、网络超时、业务错误计入连续失败按退避重试。达到告警阈值后写失败告警,指令 MUST NOT 丢弃,按最长退避继续重试。 + +#### Scenario: 停机持续失败 +- **WHEN** 停机连续失败达到阈值 +- **THEN** 系统写停复机方向的失败告警,指令保留并按最长退避继续重试 + +### Requirement: 设备维度判断合并 + +需要设备维度判断时,系统 SHALL 不在调用方当场判断,而是请求一次设备判断:递增设备判断版本号、把请求场景加入设备的场景集合、设备加入设备判断队列(同一设备只有一个成员)。设备判断执行时 MUST 抢设备判断锁(锁内不调网关),读取最新数据按统一判断函数判断(各卡按所属运营商账户判断实名,暂停轮询按场景集合判断),需要动作时为设备下每张卡生成指令(记录触发场景)进入各卡所属账户的停复机队列。收尾时只移除本次已处理的场景;版本号变大时 MUST 用最新数据再判断一次。同一设备同一时刻只有一个判断在执行。旧路径正在对该设备执行停复机时(持现有设备锁),设备判断 MUST 推后、不并行执行。 + +#### Scenario: 跨账户设备流量用完 +- **GIVEN** 设备 D 有 3 张卡分属联通、移动、广电账户 +- **WHEN** 卡 1 流量入账使设备流量用完,同时卡 2 的消费者读到扣减前数据 +- **THEN** 两次请求合并为一次设备判断,按最新数据为 3 张卡各生成停机指令,进入各自账户队列 + +#### Scenario: 到期与普通请求合并 +- **GIVEN** 设备 D 已暂停轮询 +- **WHEN** 设备套餐到期请求与一次普通轮询请求在判断前合并 +- **THEN** 场景集合含"套餐到期",系统按套餐失效放行停机 + +### Requirement: 自动入口的改造语义 + +系统 SHALL 按以下语义改造自动入口:套餐激活、流量重置、订单支付后在业务事务提交后请求评估,不起 goroutine 当场复机;退款后资产处理在资产无可用主套餐时请求评估(设备按设备维度判断),请求写入成功即标记资产后处理完成,MUST NOT 调用人工停机接口,MUST NOT 因停机失败触发 Outbox 重投或补偿扫描;运营商账户实名管控由 0 改为 1 时对该账户在线且未实名的卡、由 1 改为 0 时对停机且未实名的卡批量请求评估(设备卡按设备),按账户 QPS 平滑执行;卡或设备恢复轮询时不另加触发,由套餐步骤随即评估。 + +#### Scenario: 退款后上游终态 +- **WHEN** 退款后资产无可用主套餐,卡在上游已销户 +- **THEN** 退款资产后处理标记完成,停机指令按终态结束并告警,不再反复补发 + +#### Scenario: 开启实名管控 +- **WHEN** 超级管理员把账户 A 实名管控由 0 改为 1 +- **THEN** 系统对 A 下在线未实名的卡请求评估,停机按 A 的停复机 QPS 逐步执行 diff --git a/openspec/changes/add-stop-resume-command-queue/tasks.md b/openspec/changes/add-stop-resume-command-queue/tasks.md new file mode 100644 index 00000000..bc3e9664 --- /dev/null +++ b/openspec/changes/add-stop-resume-command-queue/tasks.md @@ -0,0 +1,61 @@ +## 0. 前置确认 + +- [ ] 0.1 确认第 1、4、5 个 Change 已上线。 + +## 1. 迁移与配置 + +- [ ] 1.1 新增成对迁移:`tb_system_config` 插入 `polling.stop_resume_queue_enabled`(默认 false)、结果未知告警阈值、停复机退避序列与告警阈值;注册受控系统配置项。 + +## 2. 统一判断与请求评估 + +- [ ] 2.1 抽出统一判断函数(暂停轮询按场景 → 停机条件 → 复机条件 → 通道阈值锁),`EvaluateAndAct`、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 改为调用它。 +- [ ] 2.2 新增"请求评估(卡或设备、场景、来源)"门面:开关开启时单卡生成指令、设备请求设备判断;关闭时走旧路径当场执行。 +- [ ] 2.3 指令写入 Lua:覆盖并 `HINCRBY ver`、方向相反清零失败计数、放入卡所属账户 `stop_resume`。 + +## 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`;指令不丢弃。 + +## 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`;人工设备停复机保持不变。 + +## 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 筛选受影响卡批量请求评估(设备卡按设备)。 + +## 6. 监控与接口 + +- [ ] 6.1 `/polling-stats` 按账户增加停复机队列长度、执行中、成功、失败、重试、结果未知数。 +- [ ] 6.2 `/iot-cards/:id/polling-state` 返回当前指令(动作、原因、场景、版本、失败次数、待确认)。 +- [ ] 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` 通过。 + +## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001) + +- [ ] 8.1 开关关闭:各入口行为与上线前一致。 +- [ ] 8.2 指令覆盖:停机执行中写入复机指令,收尾后复机保留并在最小间隔后执行;无覆盖时指令被移除。 +- [ ] 8.3 单卡最小重试间隔:间隔内不调用上游,score 推后到可调用时间。 +- [ ] 8.4 结果未知:超时后确认延迟查状态,已是目标状态结束、否则按间隔重发、查询失败不重发。 +- [ ] 8.5 失败分类:频率限制不计失败、终态不重试并按标记关闭轮询、已是目标状态按成功、持续失败告警且指令不丢。 +- [ ] 8.6 设备判断:判断期间新请求使版本变化后再判断;到期与普通请求合并后场景保留;跨账户设备下各卡最终状态一致。 +- [ ] 8.7 暂停轮询的卡:套餐到期与退款后停机执行一次;流量用完、激活、重置不自动停复机。 +- [ ] 8.8 退款:上游终态卡退款后不再反复补发停机;资产后处理标记完成。 +- [ ] 8.9 激活、重置、订单后复机经指令队列;流量重置日集中复机按账户 QPS 平滑。 +- [ ] 8.10 实名管控 0 → 1、1 → 0 后受影响卡被批量评估且平滑执行。 +- [ ] 8.11 开关开、关、再开:无重复停复机与漏停;旧路径设备锁持有时设备判断推后。 diff --git a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/.openspec.yaml b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/.openspec.yaml new file mode 100644 index 00000000..1f5d2804 --- /dev/null +++ b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/design.md b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/design.md new file mode 100644 index 00000000..e479d9a8 --- /dev/null +++ b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/design.md @@ -0,0 +1,52 @@ +## Context + +完整推导见 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 11 节。 + +现状: + +- `internal/polling/config_manager.go`:`MatchConfigs` 按 priority 升序遍历启用配置;`MergedTaskIntervals`(第 176 行)对每种类型取最高优先级的非 NULL 间隔,即 NULL = 继承。字段注释(`internal/model/polling.go:17-21`)与 DTO 说明(`polling_config_dto.go:10-14`)写的是"NULL 表示不检查",与实际相反;DTO 校验 `min=30/60`,不允许 0。配置变更经 Redis 通知即时刷新,另每 5 分钟定时刷新。 +- 生产 2026-10-24:`tb_polling_config` 7 条,无 0 值,3 条优先级同为 1,数据库无优先级唯一约束。 +- 卡的下次到期时间在执行收尾时按当时配置算好写入分片 ZSET,配置修改不会改变已排程时间。 +- 流量(`internal/application/cardobservation/traffic_flow.go`,8-11 已切换分支):增量 = 本月最大快照 − 本月已结算;入账时按 `hasActivePackageForCard` 判断有无套餐,无套餐只记已结算。 +- 激活入口:排队、指定、队首激活用 Redis 锁 `RedisPackageActivationLockKey`(30 秒);订单购买用卡或设备行锁 `FOR UPDATE`(`order/service.go:2300`);实名激活 `ActivateByRealname` 无锁。 +- 自动化测试为 N/A。 + +## Goals / Non-Goals + +**Goals:** + +- 按套餐状态与资产状态分层,直接减少网关调用量;无套餐与库存层兜底开机即停。 +- 间隔含义无歧义;调整配置后可预览、可立即生效。 +- 无套餐期间与上一用户用量不计入新套餐;减少重置日前尾部用量丢失。 + +**Non-Goals:** + +- 到期即停(依赖第 6 个 Change 的停复机指令队列与场景合并);本 Change 期间沿用现有套餐轮询发现过期后停机。 +- 激活后复机改为请求评估(第 6 个 Change)。 +- 调度结构改造(第 5 个 Change);本 Change 在现有分片调度上实现,第 5 个 Change 沿用同一套间隔计算函数。 + +## Decisions + +1. **保留 NULL = 继承,新增 0 = 不检查**。备选"把 NULL 改成不检查":会改变生产现有配置的行为,放弃。合并函数改为:遇到非 NULL 即采用(0 表示该类型不入队)。 +2. **稳定排序**:查询 `ORDER BY priority, id`;不加唯一约束(生产已有重复),只在保存时提示。 +3. **套餐状态按使用时计算**:套餐状态修改点约 15 个文件 45 处,不逐处挂钩子;在收尾、初始化、生命周期重新匹配时查询(新增按资产查询套餐状态的单个与批量方法),初始化按批查询避免 N+1。 +4. **重算到期时间**:异步任务按运营商账户分批读卡,读取各类型上次检查时间,按 min 规则只提前;写分片 ZSET 用 `ZADD LT`,天然只提前。 +5. **重置日前加密**:在统一的"计算下次到期"函数中处理窗口截断与收尾提前量,初始化、收尾、生命周期共用;重置时刻取 `tb_carrier.data_reset_day`,与流量计量重置日口径一致。 +6. **观测时间归属**:观测携带 `observed_at`(网关返回时刻);入账判断"有无可扣套餐"时改为"是否存在 `activated_at ≤ observed_at` 的生效主套餐"。这是正确性的根本保证,锁只减少并发与重复结算。 +7. **结算不触发评估**:观测元数据带"激活前结算"标记,`traffic_flow` 入账后不写触发停复机评估的 Outbox 事件(或消费者识别标记跳过);避免先停机、复机又被单卡最小重试间隔挡住。 +8. **统一激活锁**:沿用 `RedisPackageActivationLockKey(carrierType, carrierID)`,实名激活与订单购买即生效补上;锁内顺序为"结算 → 激活事务",TTL 提为大于结算总超时 + 事务耗时。订单路径的行锁保留在事务内。 +9. **结算的限流与超时**:结算是自动触发的查询,受第 1 个 Change 的限流约束,使用"等待至超时"模式(处于同步回调链路,单步超时与总超时均为系统参数)。 + +## Risks / Trade-offs + +- [支付、实名回调链路变长] → 总超时兜底,超时照常激活并告警。 +- [锁 TTL 变长导致激活冲突返回增多] → 各入口沿用现有冲突处理;监控抢锁失败次数。 +- [运营把某层间隔设为 0 后漏查] → 无套餐与库存层强制卡状态正数;匹配预览可在保存前确认。 +- [重置日窗口内查询集中] → 仍受账户 QPS 约束;按监控调整窗口与间隔。 +- [依赖 8-11 未上线生产] → 本 Change 实施前确认 `rebuild-card-traffic-metering-and-stop-resume` 已归档、日快照计量生产开启。 + +## Migration Plan + +1. 迁移:`tb_polling_config` 加 4 个可空列;现有数据不变。 +2. 测试环境部署并按 tasks 第 8 节核对;运营按新维度新增分层配置(先用预览确认),再执行重算到期时间。 +3. 生产由维护者手工迁移与发布。回退:删除新增分层配置即恢复原匹配;代码回退时新列保留。 diff --git a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/proposal.md b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/proposal.md new file mode 100644 index 00000000..1b36371e --- /dev/null +++ b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/proposal.md @@ -0,0 +1,35 @@ +## Why + +开启轮询的卡全部按配置轮询,配置只能区分网络、实名、卡类别与运营商账户:库存卡、无套餐卡与在用卡同频调网关,"无套餐仍在跑流量""库存卡在上游开机"没有专门兜底。流量计量方面,无套餐期间若不查流量,按月对账会把这段用量(以及二次销售前上一用户本月的用量)算进新激活的套餐;流量查询稀疏时,每张卡在重置日清零前最后一次查询到清零之间的用量永远查不到。另外现有配置合并把间隔为空当"继承",字段说明却写"空表示不检查",分层后会让"无套餐层不查流量"失效。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 2 步。 + +## What Changes + +- 轮询配置新增两个匹配维度:套餐状态(有生效套餐 / 套餐已用完 / 只有待生效套餐 / 无套餐)、资产状态(在库 / 已销售 / 已换货 / 已停用),为空表示不限;层级在卡任务收尾、全量初始化与生命周期重新匹配时计算,不在套餐状态修改点挂钩子。 +- 间隔三态:正数按间隔、`0` 不检查(停止向下继承)、空继承;保持"空 = 继承"不改变现有配置行为;修正字段注释与接口文档的错误说明。 +- 保存校验:匹配"无套餐、已销售"或"在库"的配置,卡状态检查间隔必须为正数。已销售无套餐、在库卡未匹配任何配置时告警。 +- 优先级可后台调整:单条修改与整体排序接口;相同优先级按 ID 稳定排序;提供匹配预览(输入 ICCID,返回命中配置与每种检查最终间隔及来源,可带未保存修改预演);提供"立即重算到期时间"(只提前不推后,异步任务,可按运营商账户限定)。 +- 重置日前加密查流量:配置新增 `pre_reset_window`、`pre_reset_carddata_interval`,在运营商账户重置日前窗口内缩短流量检查间隔,最后一次查询贴近清零时刻。 +- 激活前流量结算:资产从"无生效主套餐"变为"有生效主套餐"之前,先查一次流量入账(只记已结算),再激活;结算观测**不触发停复机评估**;结算失败或超时不阻塞激活并告警;整次结算设总超时;结算受运营商账户限流约束。 +- 流量按观测时间归属:观测时间早于套餐激活时间的用量只记已结算,不扣新套餐(对所有流量观测生效)。 +- 统一资产级激活锁:实名激活与订单购买即生效补上现有 `RedisPackageActivationLockKey`,结算与激活在锁内执行。 +- 激活后复机沿用现有路径,第 6 个 Change 上线后改为请求评估。 +- 按层统计、漏配告警、重置日前加密效果统计接口。 + +## Capabilities + +### New Capabilities + +- `tiered-polling`:轮询配置的套餐状态与资产状态维度、间隔三态与合并规则、优先级调整与稳定排序、匹配预览、重算到期时间、重置日前加密、无套餐与库存层兜底校验、按层统计。 + +### Modified Capabilities + +- `card-traffic-metering`:新增激活前流量结算与"按观测时间归属"规则(该能力由 `rebuild-card-traffic-metering-and-stop-resume` 引入,本 Change 要求其先归档)。 +- `package-lifecycle`:无生效主套餐到有生效主套餐的各激活入口统一资产级激活锁,激活前先做流量结算。 + +## Impact + +- 依赖:`rebuild-card-traffic-metering-and-stop-resume` 已完成并归档、日快照计量已在生产开启并核对;第 1 个 Change(限流器)已上线,结算查询受限流约束。 +- 数据库:`tb_polling_config` 新增 4 个可空列(`package_state`、`asset_status`、`pre_reset_window`、`pre_reset_carddata_interval`);新的成对迁移。 +- 代码:`internal/polling/config_manager.go`(三态合并、稳定排序、新维度、重置日前到期计算)、`internal/service/polling/config_service.go` 与 DTO、`internal/store/postgres/package_usage_store.go`(按资产查询套餐状态,单个与批量)、`internal/polling/initializer.go`、`lifecycle_service.go` 与重入队处传入套餐状态;`internal/service/package/activation_service.go`、`internal/service/order/service.go`(激活锁与结算);`internal/application/cardobservation/traffic_flow.go`(观测时间归属、结算标记不触发评估)。 +- 接口:`/polling-configs` 增加 4 个字段、间隔支持 `0`;新增 `PUT /polling-configs/priorities`、`GET /polling-configs/match-preview`、`POST /polling-configs/recalculate-due`、`GET /polling-configs/recalculate-due/:task_id`、`GET /polling-stats/tiers`、`GET /polling-stats/pre-reset`。前端轮询配置页改为"按间隔 / 不检查 / 继承"三态并支持拖拽排序。 +- 行为:激活链路多一次流量查询,有总超时;生产现有 7 条轮询配置无 `0` 值,行为不变。 diff --git a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/card-traffic-metering/spec.md b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/card-traffic-metering/spec.md new file mode 100644 index 00000000..89d1dfa8 --- /dev/null +++ b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/card-traffic-metering/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: 流量按观测时间归属 + +系统 SHALL 为每次流量观测记录网关返回时的观测时间;入账时若资产已有生效主套餐但观测时间早于该套餐的激活时间,本次增量 MUST 只记为已结算、不扣减该套餐。该规则 MUST 对全部流量观测生效(轮询、激活前结算、手动刷新)。 + +#### Scenario: 慢返回的结算晚于另一入口激活 +- **GIVEN** 资产无生效套餐,激活前结算 10:00:00 发出查询,另一入口 10:00:02 激活了套餐 +- **WHEN** 结算结果 10:00:05 返回并入账,观测时间早于激活时间 +- **THEN** 本次增量只记已结算,不扣新套餐 + +#### Scenario: 激活后的观测正常扣减 +- **WHEN** 观测时间晚于套餐激活时间 +- **THEN** 增量按现有规则扣减套餐 + +### Requirement: 激活前流量结算 + +对已切换日快照计量的卡,系统 SHALL 在资产从"无生效主套餐"变为"有生效主套餐"之前(实名激活、排队激活、指定激活、队首激活、订单购买即生效),对独立卡查一次流量、对设备逐张查设备下各卡,并按现有观测入账(此时无套餐,增量只记已结算),然后再执行激活。已有生效主套餐(续费、排队衔接)与流量重置 MUST NOT 执行结算。结算观测 MUST 携带"激活前结算"标记,入账照常但 MUST NOT 触发停复机评估;激活提交后的评估是激活链路唯一一次评估。结算查询 MUST 受运营商账户限流约束;单卡限流等待超过单步超时、查询失败或整次结算达到总超时时,系统 MUST 跳过剩余结算、照常激活,并按卡写告警。未切换日快照计量的卡 MUST 不做结算。 + +#### Scenario: 二次销售激活 +- **GIVEN** 卡上一用户本月已用 3 GB,当前无生效套餐 +- **WHEN** 新用户购买套餐即生效 +- **THEN** 系统先查流量并把 3 GB 记为已结算,再激活,新套餐不承担这 3 GB + +#### Scenario: 结算不触发停机 +- **WHEN** 结算观测入账时资产无套餐 +- **THEN** 系统不生成"无有效套餐"停机,激活提交后再评估一次停复机 + +#### Scenario: 结算超时 +- **WHEN** 设备下第 3 张卡结算时整次结算达到总超时 +- **THEN** 系统跳过剩余卡、照常激活,并为未结算的卡写告警 diff --git a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/package-lifecycle/spec.md b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/package-lifecycle/spec.md new file mode 100644 index 00000000..9f90f761 --- /dev/null +++ b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/package-lifecycle/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: 无套餐到有套餐的激活入口共用资产级激活锁 + +系统 SHALL 让所有使资产从"无生效主套餐"变为"有生效主套餐"的入口(实名激活、排队激活、指定激活、队首激活、订单购买即生效)在同一把资产级激活锁内执行"激活前流量结算 → 激活事务"。锁 TTL MUST 大于结算总超时与激活事务耗时之和;抢锁失败的入口 MUST 按各自现有的冲突处理返回或重试,不得在锁外执行激活。订单购买路径原有的卡或设备行锁 MUST 保留。 + +#### Scenario: 实名激活与订单购买并发 +- **WHEN** 同一资产的实名回调激活与客户购买即生效同时到达 +- **THEN** 两者串行执行,后执行者看到已有生效主套餐,不再做结算并按排队规则处理 + +#### Scenario: 续费不加结算 +- **WHEN** 资产已有生效主套餐时续费 +- **THEN** 系统不做激活前结算,按现有排队衔接处理 diff --git a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/tiered-polling/spec.md b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/tiered-polling/spec.md new file mode 100644 index 00000000..bcf557da --- /dev/null +++ b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/specs/tiered-polling/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: 按套餐状态与资产状态分层匹配 + +系统 SHALL 在现有匹配维度(网络与实名状态、卡类别、运营商账户)之外,按套餐状态(有生效套餐、套餐已用完、只有待生效套餐、无套餐)与资产状态(在库、已销售、已换货、已停用)匹配轮询配置;维度为空表示不限。绑定设备的卡 MUST 按设备计算套餐状态,独立卡按卡计算,口径与现有"是否有有效套餐"判断一致。系统 MUST 在卡任务执行完收尾、全量初始化、生命周期重新匹配时计算层级,卡的层级最迟在下一次执行时更新。 + +#### Scenario: 套餐到期后降层 +- **GIVEN** 卡原匹配"有生效套餐"层,套餐已过期 +- **WHEN** 该卡下一次轮询执行完收尾 +- **THEN** 系统按"无套餐"层的间隔计算各检查类型下次到期时间 + +#### Scenario: 设备卡同层 +- **WHEN** 设备下 2 张卡分属不同运营商账户,设备有生效套餐 +- **THEN** 两张卡都按"有生效套餐"匹配 + +### Requirement: 检查间隔三态与合并 + +系统 SHALL 区分每种检查类型间隔的三种取值:正数(秒)表示按此间隔检查并停止向下查找;`0` 表示不检查并停止向下查找;空表示继承,继续查找优先级更低的配置。系统 MUST 按优先级升序、优先级相同按配置 ID 升序遍历启用的匹配配置,对每种检查类型取第一个非空取值。接口 MUST 接受 `0` 或不小于该类型最小间隔的正数,拒绝其他值。 + +#### Scenario: 无套餐层不查流量 +- **GIVEN** "无套餐"层流量间隔为 `0`,优先级更低的兜底配置流量间隔为 3600 +- **WHEN** 无套餐卡匹配这两条配置 +- **THEN** 该卡不做流量检查 + +#### Scenario: 空值继承 +- **GIVEN** 高优先级配置实名间隔为空,低优先级配置实名间隔为 7200 +- **WHEN** 卡同时匹配两条配置 +- **THEN** 该卡实名检查间隔为 7200 + +#### Scenario: 相同优先级 +- **WHEN** 两条启用配置优先级都为 1 且同时匹配一张卡 +- **THEN** 系统按配置 ID 较小者优先,结果在多次计算间保持一致 + +### Requirement: 无套餐层与库存层必须检查卡状态 + +系统 SHALL 拒绝保存匹配"无套餐且已销售"或"在库"的配置中卡状态检查间隔为空或 `0` 的情况。已销售无套餐、在库的卡未匹配到任何配置时,系统 MUST 告警且不入队。 + +#### Scenario: 库存层未配卡状态 +- **WHEN** 维护者保存资产状态为"在库"、卡状态间隔为 `0` 的配置 +- **THEN** 系统拒绝保存并提示该层必须配置卡状态检查 + +### Requirement: 优先级调整、匹配预览与重算到期时间 + +系统 SHALL 允许超级管理员单条修改配置优先级,以及一次提交全部配置顺序整体重排(事务内完成)。保存时若与其他启用配置优先级相同且匹配条件可能重叠,系统 MUST 提示但不拦截。系统 SHALL 提供匹配预览:输入 ICCID(可附带未保存的修改),返回该卡各匹配维度取值、命中配置列表,以及每种检查类型最终采用的间隔与来源(按间隔、不检查、继承自某配置、未匹配),预览 MUST 不写任何数据。系统 SHALL 提供"立即重算到期时间"异步任务(可按运营商账户限定),新到期时间 = min(当前到期, 上次检查时间 + 新间隔),只提前不推后,只更新调度状态、不调网关,返回任务 ID 与进度。 + +#### Scenario: 整体排序 +- **WHEN** 超级管理员提交新的配置顺序 +- **THEN** 系统按顺序重排优先级,各进程在配置刷新后按新顺序匹配 + +#### Scenario: 预览未保存修改 +- **WHEN** 运营带上"把某配置流量间隔改为 0"的未保存修改预览一张卡 +- **THEN** 系统返回修改后的最终间隔与来源,配置与调度状态不变 + +#### Scenario: 缩短间隔后重算 +- **GIVEN** 某层流量间隔从 86400 改为 3600,卡 A 上次查流量在 2 小时前、当前到期在 20 小时后 +- **WHEN** 执行重算到期时间 +- **THEN** 卡 A 流量检查到期时间改为上次检查后 1 小时(即立即到期),且仍受运营商账户限流约束执行 + +### Requirement: 重置日前加密查流量 + +系统 SHALL 在配置了 `pre_reset_window` 与 `pre_reset_carddata_interval` 的层内,于卡所属运营商账户重置时刻(重置日当天 0 点,业务时区)前的窗口内,按窗口间隔安排流量检查:窗口内下次到期 = 当前 + 窗口间隔,若越过重置时刻则改为"重置时刻 − 收尾提前量"(已过则不再加查);窗口前按正常间隔算出的到期越过窗口起点时截断到窗口起点;越过重置时刻后恢复正常间隔。两列为空表示该配置不加密。 + +#### Scenario: 窗口内最后一次查询 +- **GIVEN** 重置日为 1 号,窗口 6 小时,窗口间隔 30 分钟,收尾提前量 5 分钟,当前为 31 号 23:40 +- **WHEN** 计算流量下次到期 +- **THEN** 下次到期为 31 号 23:55,而不是 1 号 00:10 + +#### Scenario: 截断到窗口起点 +- **GIVEN** 正常间隔 1 天,当前为 31 号 10:00,窗口起点 31 号 18:00 +- **WHEN** 计算流量下次到期 +- **THEN** 下次到期为 31 号 18:00 + +### Requirement: 分层与加密效果可观测 + +系统 SHALL 提供按层的卡数与漏配告警卡数统计,以及按运营商账户、按重置周期的"有生效套餐但窗口内未查到流量"卡数与最后一次查询距重置时刻的分布。 + +#### Scenario: 查看分层统计 +- **WHEN** 运营查询分层统计 +- **THEN** 系统返回每层卡数与未匹配任何配置的已销售无套餐、在库卡数 diff --git a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/tasks.md b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/tasks.md new file mode 100644 index 00000000..a0a063f3 --- /dev/null +++ b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/tasks.md @@ -0,0 +1,56 @@ +## 0. 前置确认 + +- [ ] 0.1 确认 `rebuild-card-traffic-metering-and-stop-resume` 已完成并归档,日快照计量已在生产开启并核对结算结果;确认第 1 个 Change 已上线。 + +## 1. 迁移与模型 + +- [ ] 1.1 新增成对迁移:`tb_polling_config` 增加 `package_state`、`asset_status`、`pre_reset_window`、`pre_reset_carddata_interval`(均可空);down 删列。 +- [ ] 1.2 `model.PollingConfig` 增加字段;修正间隔字段注释为"正数按间隔、0 不检查、空继承"。 + +## 2. 配置合并与分层匹配 + +- [ ] 2.1 `config_manager.go`:配置加载按 `priority, id` 排序;`MergedTaskIntervals` 采用第一个非空取值,`0` 表示不检查。 +- [ ] 2.2 新增套餐状态与资产状态匹配;`package_usage_store` 增加按资产查询套餐状态(单个与批量),设备卡按设备计算。 +- [ ] 2.3 `initializer.go`(批量)、`lifecycle_service.go`、执行收尾重入队处传入套餐状态与资产状态。 +- [ ] 2.4 已销售无套餐、在库卡未匹配任何配置时告警且不入队。 + +## 3. 重置日前加密 + +- [ ] 3.1 统一"计算下次到期"函数:窗口内间隔、越过重置时刻改为"重置时刻 − 收尾提前量"、窗口前截断到窗口起点、越过后恢复正常;初始化、收尾、生命周期共用。 +- [ ] 3.2 收尾提前量写入系统参数。 + +## 4. 配置接口 + +- [ ] 4.1 `/polling-configs` DTO 增加 4 个字段,间隔校验改为"0 或不小于最小间隔",说明文字按三态;保存校验无套餐已销售与在库层卡状态必须为正数;优先级重复且条件可能重叠时返回提示。 +- [ ] 4.2 `PUT /polling-configs/priorities`:事务内整体重排,写统一审计,发配置变更通知。 +- [ ] 4.3 `GET /polling-configs/match-preview?iccid=`(可带 `config_overrides`):返回维度取值、命中配置、每类最终间隔与来源;只读。 +- [ ] 4.4 `POST /polling-configs/recalculate-due`(可选运营商账户)与 `GET /polling-configs/recalculate-due/:task_id`:异步分批,只提前不推后,不调网关。 +- [ ] 4.5 `GET /polling-stats/tiers`、`GET /polling-stats/pre-reset`。 +- [ ] 4.6 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go` 并重新生成接口文档。 + +## 5. 流量按观测时间归属 + +- [ ] 5.1 观测携带 `observed_at`;`traffic_flow.go` 判断可扣套餐时只认 `activated_at ≤ observed_at` 的生效主套餐,否则只记已结算。 + +## 6. 激活前结算与统一激活锁 + +- [ ] 6.1 实名激活与订单购买即生效补上 `RedisPackageActivationLockKey`;各入口锁 TTL 调整为大于结算总超时 + 事务耗时;抢锁失败沿用现有冲突处理。 +- [ ] 6.2 锁内、激活事务前:判断资产当前无生效主套餐且卡已切换日快照计量时执行结算(独立卡一次、设备逐张),使用限流等待模式;单步超时与总超时为系统参数。 +- [ ] 6.3 结算观测带"激活前结算"标记,入账后不触发停复机评估。 +- [ ] 6.4 结算失败、超时或被跳过的卡写告警(卡、资产、错误);不阻塞激活。 + +## 7. 编译与规范校验 + +- [ ] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。 +- [ ] 7.2 `openspec validate add-tiered-polling-and-pre-activation-settlement` 与 `openspec doctor --json` 通过。 + +## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001) + +- [ ] 8.1 现有配置在升级后各卡最终间隔不变(用匹配预览抽查)。 +- [ ] 8.2 间隔 `0` 停止继承、空值继承、相同优先级按 ID 稳定。 +- [ ] 8.3 无套餐已销售、在库层卡状态为空或 0 时保存被拒;未匹配告警出现。 +- [ ] 8.4 整体排序后匹配结果按新顺序;重算到期时间只提前不推后。 +- [ ] 8.5 重置日前窗口:最后一次查询落在"重置时刻 − 收尾提前量",越过后恢复正常间隔。 +- [ ] 8.6 二次销售卡购买即生效:先结算再激活,新套餐不承担上一用户本月用量;结算不产生停机。 +- [ ] 8.7 构造结算慢返回且另一入口先激活:激活前用量只记已结算。 +- [ ] 8.8 实名激活与订单购买并发:串行执行,无重复结算;结算超时照常激活并告警。 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/.openspec.yaml b/openspec/changes/finalize-unified-stop-resume-rule/.openspec.yaml new file mode 100644 index 00000000..1f5d2804 --- /dev/null +++ b/openspec/changes/finalize-unified-stop-resume-rule/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/design.md b/openspec/changes/finalize-unified-stop-resume-rule/design.md new file mode 100644 index 00000000..c8dbc6a7 --- /dev/null +++ b/openspec/changes/finalize-unified-stop-resume-rule/design.md @@ -0,0 +1,40 @@ +## Context + +依据 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 12 节、第五部分第 3 节前置条件与"上线前演练"。 + +现状: + +- `internal/service/iot_card/stop_resume_unified.go`:`unifiedStopResumeEnabled()` 读 `cfg.StopResume.UnifiedRuleEnabled`;`autoResumableStopReason`、`autoControlSuspended`、`unifiedRealnameOK` 在开关开启时生效,关闭时走 `isRealnameOK` 旧分支与 `isPollingStopReason`。 +- 迁移 242:`tb_carrier.realname_control_enabled` 默认 0;生产库仍为迁移 240(未上线)。 +- `internal/task/polling_realname_handler.go:70`:行业卡跳过实名查询。 +- `internal/domain/cardobservation/network.go:88` `ShouldStopPollingForRisk` 要求 `isStandalone`;生产 8,420 张风险停机或已销户卡均为独立卡且已关闭轮询。 +- 停复机调用返回终态时只按失败处理,不关闭轮询。 + +## Goals / Non-Goals + +**Goals:** 停复机只有一套口径;实名轮询与实名管控口径一致;风险停机与已销户无论如何发现都关闭轮询;开启前可预演影响。 + +**Non-Goals:** 不改停复机触发路径与执行方式(第 6 个 Change);不改暂停轮询的套餐失效停机与自动恢复轮询(第 6、7 个 Change);不改激活状态计算口径。 + +## Decisions + +1. **先演练开启、再删除开关**:保留开关会长期双口径,但演练必须早于开启;故第一次发布只加演练与补齐、不改口径,开启并稳定后第二次发布删除开关。 +2. **复机含 `carrier_stopped`**:用户已定"只看本系统规则"。上游终态卡由关闭轮询兜底,不进入自动复机;未识别的终态由错误分类在复机失败时识别并关闭轮询。 +3. **关闭轮询只作用于卡自身**:设备下其他卡仍可能正常使用;`autoControlSuspended` 按卡判断,该卡不再参与自动停复机,设备维度判断对其余卡照常。 +4. **终态关闭轮询由规则标记驱动**:在错误分类表加 `auto_disable_polling`,而不是在代码里写死关键字,符合"数值与分组由配置决定"。关闭时同事务回写 `gateway_extend`,并写不轮询清单(第 7 个 Change 上线后;之前只改 `enable_polling` 并写审计)。 +5. **演练用读侧 Query**:复用统一判断函数,输入卡与资产当前数据和可选的假设实名管控配置,输出目标状态;按批读取、只读,不经聚合根写状态。明细导出复用统一导出任务。 + +## Risks / Trade-offs + +- [开启实名管控后行业卡被停] → 已知口径变化;演练可按假设配置预演,运营按结果决定是否开启。 +- [演练全量扫描较慢] → 按账户分批、只读;导出走异步任务。 +- [删除开关后无法快速回到旧口径] → 前置要求新规则已在生产稳定运行;必要时代码回退。 + +## Migration Plan + +演练必须在新规则开启之前可用,因此分两次发布: + +1. 第一次发布:迁移(错误分类表加列)、演练接口、风险停机与已销户关闭轮询补齐;总开关仍关闭,停复机行为不变。演练始终按新规则判断,不受总开关影响。 +2. 运营:设置需要实名的账户;生产执行演练并导出核对;需保持停机的卡关闭轮询。维护者开启总开关并重启全部进程,首日监控。 +3. 第二次发布(新规则稳定后):删除总开关、旧口径与部署环境变量,实名轮询改按账户实名管控跳过。 +4. 回退:第一次发布后可直接关闭总开关回到旧口径;第二次发布后需代码回退。 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/proposal.md b/openspec/changes/finalize-unified-stop-resume-rule/proposal.md new file mode 100644 index 00000000..9ac51a79 --- /dev/null +++ b/openspec/changes/finalize-unified-stop-resume-rule/proposal.md @@ -0,0 +1,35 @@ +## Why + +`Iteration/8-11` 已实现停复机新规则,但靠总开关与旧口径并存:代码里同时保留两套实名判断与复机条件,实名轮询仍按行业卡跳过,与"按运营商账户实名管控"的新口径不一致。后续停复机指令队列(第 6 个 Change)需要唯一口径。另一方面,生产 2026-10-24 实查:停机、开启轮询且有生效套餐的卡中,`not_realname` 15,059 张、`carrier_stopped` 998 张、停因为空 83 张、`manual` 22 张;所有账户实名管控默认 0,新规则开启即可能批量复机,必须先演练。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 4 步。 + +## What Changes + +- **BREAKING** 新规则转正:删除总开关 `stop_resume.unified_rule_enabled`(`JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`)及其配置、部署变量与全部分支;删除旧口径(行业卡放行、卡或设备 `realname_policy` 放行、只对轮询停因自动复机、`ListByDeviceIDAndPollingStopReasons`)。 +- 复机不看停机原因,包括运营商侧主动停机 `carrier_stopped`:是否复机只由本系统规则决定。 +- **BREAKING** 实名轮询改为:卡所属运营商账户未开实名管控时跳过查实名;不再按行业卡跳过。 +- 风险停机、已销户必须关闭轮询:保留现有查状态自动关闭;补齐绑定设备的卡(只关该卡自身,不级联设备)与停复机调用直接返回终态两种情况;错误分类规则增加"自动关闭轮询"标记,只允许终态类别勾选。 +- 新增停复机规则演练接口:对全部停机与开机卡按规则只判断不执行,按运营商账户 × 停机原因 × 判断结果输出汇总与明细,可传入假设的实名管控配置预演,明细可导出。 +- 通道阈值新周期解锁后的复机条件随转正去掉总开关表述;同时去掉已下线的"保护期强制复机"表述。 +- 上线前演练作为发布前置步骤写入任务。 + +## Capabilities + +### New Capabilities + +(无) + +### Modified Capabilities + +- `card-stop-resume`:删除总开关要求;停机、复机、实名管控要求去掉"总开关开启时"前提;复机明确包含 `carrier_stopped`;新增实名轮询按账户实名管控跳过、风险停机与已销户关闭轮询补齐、规则演练。(该能力由 `rebuild-card-traffic-metering-and-stop-resume` 引入,本 Change 要求其先归档。) +- `polling-operations`:删除"停复机遵循实际生效实名策略"(旧口径)。 +- `carrier-channel-traffic-threshold`:"通道阈值停机与周期恢复"去掉总开关与保护期表述,复机条件固定为 `card-stop-resume` 的复机条件。 +- `gateway-call-observability`:错误分类规则增加"自动关闭轮询"标记(该能力由第 1 个 Change 引入)。 + +## Impact + +- 依赖:`rebuild-card-traffic-metering-and-stop-resume` 已归档并上线生产(总开关可仍关闭);第 1 个 Change(错误分类)与第 3 个 Change(去掉保护期)已上线。 +- **分两次发布**:第一次发布演练接口与关闭轮询补齐,总开关仍关闭、停复机行为不变;运营为需要实名的账户设置 `realname_control_enabled = 1`,用演练核对"将被复机 / 将被停机"的卡并导出确认,需保持停机的卡先关闭轮询,然后维护者开启总开关;新规则稳定运行后第二次发布删除总开关与旧口径、实名轮询改口径。 +- 数据库:新增成对迁移,`tb_gateway_error_classification` 增加 `auto_disable_polling` 列并为"已销户""风险停机"类规则置位。 +- 代码:`internal/service/iot_card/stop_resume_unified.go` 并入主逻辑;`stop_resume_service.go` 删除旧分支;`internal/task/polling_realname_handler.go:70`;`internal/domain/cardobservation/network.go` `ShouldStopPollingForRisk` 去掉独立卡限制;停复机失败处理接入"自动关闭轮询";`pkg/config/config.go`、`loader.go`、`defaults/config.yaml`、`.gitea/workflows/deploy.yaml`、`docker-compose.prod.yml` 删除总开关;新增演练 Query 与接口。 +- 接口:新增 `GET /stop-resume-rule/dry-run` 与导出场景 `stop_resume_rule_dry_run`;错误分类接口增加 `auto_disable_polling` 字段。 +- 行为:开启实名管控的账户下,行业卡未实名也会被停机;未开实名管控的账户不再查实名。 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/specs/card-stop-resume/spec.md b/openspec/changes/finalize-unified-stop-resume-rule/specs/card-stop-resume/spec.md new file mode 100644 index 00000000..25235d87 --- /dev/null +++ b/openspec/changes/finalize-unified-stop-resume-rule/specs/card-stop-resume/spec.md @@ -0,0 +1,109 @@ +## REMOVED Requirements + +### Requirement: 停复机新规则总开关 +**Reason**: 新规则转正为唯一口径,不再保留旧口径与总开关。 +**Migration**: 删除配置项与部署变量 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`;发布前完成实名管控配置与规则演练。 + +## MODIFIED Requirements + +### Requirement: 自动停机条件 +系统 SHALL 对开机的卡在满足任一条件时自动停机:卡(绑定设备时按设备)没有套餐;流量已用完;卡所属通道开启实名管控且卡未实名。停机原因按命中条件分别记为 `no_package`、`traffic_exhausted`、`not_realname`。无套餐与流量用完扩散到设备下全部开机卡,未实名只停该卡。系统 MUST NOT 因卡当前开机是由外部、人工或其他原因造成而改变判断。 + +#### Scenario: 套餐到期 +- **WHEN** 开机卡绑定的设备没有套餐 +- **THEN** 系统停机设备下全部开机卡,停因为 `no_package` + +#### Scenario: 通道开启实名管控的未实名卡 +- **WHEN** 卡所属通道开启实名管控,卡未实名且开机 +- **THEN** 系统只停这张卡,停因为 `not_realname` + +#### Scenario: 通道未开启实名管控的未实名卡 +- **WHEN** 卡所属通道未开启实名管控,卡未实名、有套餐、流量未用完且开机 +- **THEN** 系统不停机 + +### Requirement: 自动复机条件不看停机原因 +系统 SHALL 对停机的卡在同时满足以下条件时自动复机:卡(绑定设备时按设备)有套餐;流量未用完;卡已实名或卡所属通道未开启实名管控。系统 MUST NOT 以停机原因决定是否复机:运营商侧主动停机(`carrier_stopped`)、卡管人工停机(`manual`)、历史保护期停机(`protect_period`)、未实名、无套餐、流量耗尽及停因为空的卡满足条件都复机,是否复机只由本系统规则决定。持有本周期通道阈值锁的卡 MUST 拒绝复机。设备复机时系统 SHALL 逐张判断设备下全部停机卡,不满足实名条件的卡跳过并记停因 `not_realname`,其他卡照常复机。外部反复停机时,系统每次评估均按上述条件复机,不设次数上限。停机原因只用于展示与审计。上游对复机返回终态时,按"风险停机与已销户关闭轮询"处理,不重试。 + +#### Scenario: 外部停机后满足条件 +- **WHEN** 卡被外部停机后记为 `carrier_stopped`,有套餐、流量未用完、实名条件满足 +- **THEN** 下一次评估时系统自动复机并清空停因 + +#### Scenario: 人工停机后满足条件 +- **WHEN** 管理员手动停机的卡有套餐、流量未用完、实名条件满足 +- **THEN** 下一次评估时系统自动复机 + +#### Scenario: 开启实名管控的通道中未实名的停机卡 +- **WHEN** 卡所属通道开启实名管控,卡未实名、停机、有套餐 +- **THEN** 系统不复机;卡实名后下一次评估复机 + +#### Scenario: 持有通道阈值锁 +- **WHEN** 停机卡在本周期持有通道阈值锁,其他条件均满足 +- **THEN** 系统拒绝复机并保留锁 + +### Requirement: 通道实名管控配置、权限与审计 +系统 SHALL 在运营商通道上提供独立的实名管控开关 `realname_control_enabled`(0 不管控、1 管控),默认 0,MUST NOT 复用通道现有的实名链接类型等字段。开启时,通道下所有卡停复机只按卡是否已实名判断,MUST NOT 以行业卡身份或卡、设备上的实名策略放行;实名策略仍只用于购买与首次实名激活流程。仅超级管理员和平台用户 SHALL 在通道新建或编辑时配置该开关,非平台账号提交该字段 MUST 被拒绝;修改并入通道更新审计。从 0 改为 1 时,管理端 MUST 二次确认并提示该通道未实名的开机卡数。查询卡所属通道的实名管控失败时,系统本次不对该卡发起停复机并记录日志。人工复机同样按本要求判断实名。 + +#### Scenario: 默认不管控 +- **WHEN** 通道未修改实名管控,非行业卡未实名、卡实名策略为 `after_order`、有套餐且流量未用完 +- **THEN** 系统不因未实名停机,人工复机不因未实名被拒 + +#### Scenario: 开启后行业卡不再放行 +- **WHEN** 通道开启实名管控,行业卡未实名且开机 +- **THEN** 系统停机该卡,停因为 `not_realname` + +#### Scenario: 越权修改实名管控 +- **WHEN** 非超级管理员、非平台用户提交通道实名管控字段 +- **THEN** 系统拒绝请求且不修改配置 + +#### Scenario: 查询通道失败 +- **WHEN** 停复机评估时卡所属通道的实名管控查询失败 +- **THEN** 系统本次不对该卡发起停复机命令并记录日志 + +### Requirement: 暂停轮询的卡不自动停复机 +卡或其绑定设备暂停轮询的,系统 SHALL NOT 对该卡发起自动停机或自动复机,包括套餐激活、套餐重置、充值等非轮询触发的复机;人工停机与人工复机照常执行。网关扩展状态为风险停机或已销户的卡 MUST 关闭该卡自身轮询,不论是否绑定设备:查卡状态返回该扩展状态时关闭;停复机调用直接返回命中"自动关闭轮询"规则的终态时,同样关闭并回写网关扩展状态。关闭只作用于该卡,MUST NOT 级联关闭设备与设备下其他卡。机卡分离停机等其他运营商异常状态 MUST NOT 自动关闭轮询,由人工判断。未暂停轮询的卡,不论运营商扩展状态如何,均按停机与复机条件处理,上游拒绝复机时按现有失败处理。 + +#### Scenario: 暂停轮询的卡充值后 +- **WHEN** 已被人工暂停轮询的停机卡充值并激活套餐 +- **THEN** 系统不自动复机;人工复机照常可用 + +#### Scenario: 恢复轮询后 +- **WHEN** 维护者为该卡恢复轮询 +- **THEN** 下一次评估起系统按停机与复机条件正常处理 + +#### Scenario: 设备卡出现机卡分离停机 +- **WHEN** 设备下的卡运营商扩展状态为机卡分离停机且未被暂停轮询 +- **THEN** 系统不自动暂停轮询,按停机与复机条件处理 + +#### Scenario: 设备下的卡被销户 +- **WHEN** 查卡状态返回绑定设备的某张卡扩展状态为已销户 +- **THEN** 系统关闭该卡轮询,设备与设备下其他卡的轮询不变 + +#### Scenario: 复机返回风险停机 +- **WHEN** 复机调用返回"当前卡为风险停机状态,仅支持风险复机"且该规则标记自动关闭轮询 +- **THEN** 系统不重试,关闭该卡轮询并回写扩展状态为风险停机 + +## ADDED Requirements + +### Requirement: 实名轮询按通道实名管控跳过 + +系统 SHALL 在卡所属运营商账户未开启实名管控时跳过该卡的实名查询(结果不影响停复机),开启时照常查询;MUST NOT 再按卡类别(行业卡)跳过实名查询。 + +#### Scenario: 未开管控账户的普通卡 +- **WHEN** 卡所属账户 `realname_control_enabled = 0`,实名检查到期 +- **THEN** 系统不调用网关查实名,按正常间隔安排下次检查 + +#### Scenario: 开启管控账户的行业卡 +- **WHEN** 卡所属账户开启实名管控,行业卡实名检查到期 +- **THEN** 系统调用网关查实名 + +### Requirement: 停复机规则演练 + +系统 SHALL 提供只判断不执行的停复机规则演练:对停机卡与开机卡按本能力规则判断目标状态,按运营商账户 × 停机原因 × 判断结果(将复机、将停机、不动作及不动作原因)返回汇总与明细,可按运营商账户与停机原因筛选,可传入假设的通道实名管控配置预演。演练 MUST NOT 调用网关、MUST NOT 写任何卡、指令或调度状态。明细 SHALL 可通过统一导出任务导出。接口仅超级管理员与平台用户可用。 + +#### Scenario: 开启前预演 +- **WHEN** 运营在所有账户实名管控为 0 时执行演练 +- **THEN** 系统返回各账户将被复机的 `not_realname` 卡数与明细,卡状态不变 + +#### Scenario: 假设开启管控 +- **WHEN** 运营假设账户 A 开启实名管控执行演练 +- **THEN** 系统按假设配置返回账户 A 将停机的未实名开机卡数,实际配置不变 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/specs/carrier-channel-traffic-threshold/spec.md b/openspec/changes/finalize-unified-stop-resume-rule/specs/carrier-channel-traffic-threshold/spec.md new file mode 100644 index 00000000..98393594 --- /dev/null +++ b/openspec/changes/finalize-unified-stop-resume-rule/specs/carrier-channel-traffic-threshold/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: 通道阈值停机与周期恢复 +系统 SHALL 为每个已启用运营商通道按通道既有的上游流量重置日(与网关计数器清零同一事实,上海时区周期边界)判断运营商回传的每张卡当前周期累计流量,累计流量仅取网关累计读数换算,不使用本地用量统计或套餐真流量;每张卡独立判断,不扩散到设备维度,不进入既有停复机的设备扩散路径。达到或超过阈值时,系统为当前周期创建通道阈值停机锁并创建可靠停机任务;同一周期重复判定、并发或任务重放不得重复创建锁或重复发起停机。改阈值不生效于当前周期已持锁卡:当前周期维持拒绝复机,新周期按新阈值判断。 + +持锁卡在当前周期内 MUST 拒绝一切复机,包括自动复机、手动复机和机卡分离复机;拒绝时保留锁并记录拒绝事实。停机调用失败或结果未知时保留锁与任务状态,由本能力新增的恢复扫描处理:只查询运营商状态回填、确认成功后补写卡状态;自提交起超过约定窗口仍无法确认结果的,标记异常并转人工处理,不自动删除锁。本能力新增停因 `channel_threshold`,不参与既有自动复机判定,复机仅由周期处理发起。 + +新周期开始时,系统 SHALL 对仍持锁卡解除通道锁;复机条件为 `card-stop-resume` 的复机条件:有套餐、流量未用完、卡已实名或卡所属通道未开启实名管控,且卡与其绑定设备未暂停轮询;不以其他停机原因或网关扩展状态阻止复机,条件满足时创建自动复机任务,不满足时只解锁不调用运营商。复机失败或结果未知同样由恢复扫描处理。卡更换运营商后,旧周期锁保留,新周期按新运营商的重置日计算。 + +#### Scenario: 达量后人工复机 +- **WHEN** 当前计费周期内持有通道阈值停机锁的卡请求复机 +- **THEN** 系统拒绝复机且保留该锁 + +#### Scenario: 达量后机卡分离复机被拒 +- **WHEN** 当前计费周期内的持锁卡触发机卡分离复机 +- **THEN** 系统拒绝复机、保留该锁并记录拒绝事实 + +#### Scenario: 停机结果未知恢复确认 +- **WHEN** 通道阈值停机调用后结果未知,恢复扫描向运营商查询确认卡已停机 +- **THEN** 系统补写卡停机状态并将任务结果记为已确认 + +#### Scenario: 停机结果未知超期转人工 +- **WHEN** 通道阈值停机任务自提交起超过约定窗口仍无法确认结果 +- **THEN** 系统标记异常并留存安全失败原因转人工处理,不自动删除锁 + +#### Scenario: 换运营商后周期归属 +- **WHEN** 持锁卡更换运营商后到达新计费周期 +- **THEN** 旧周期锁保留,新周期按新运营商的上游流量重置日计算并处理 + +#### Scenario: 新周期存在其他停因 +- **WHEN** 新周期开始的持锁卡停因记录为人工停机,有套餐、流量未用完、实名条件满足且未暂停轮询 +- **THEN** 系统解除通道阈值锁并创建自动复机任务 + +#### Scenario: 新周期卡已暂停轮询 +- **WHEN** 新周期开始的持锁卡已被暂停轮询 +- **THEN** 系统解除通道阈值锁但不调用运营商复机 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/specs/gateway-call-observability/spec.md b/openspec/changes/finalize-unified-stop-resume-rule/specs/gateway-call-observability/spec.md new file mode 100644 index 00000000..ad5c5788 --- /dev/null +++ b/openspec/changes/finalize-unified-stop-resume-rule/specs/gateway-call-observability/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: 终态规则可标记自动关闭轮询 + +系统 SHALL 允许错误分类规则标记"自动关闭轮询",且只允许类别为"终态不可操作"的规则勾选;保存其他类别的规则并勾选时 MUST 拒绝。初始数据中"已销户""风险停机"类终态规则 MUST 置位,"物联网号码封存"等其他终态不置位。 + +#### Scenario: 非终态规则勾选 +- **WHEN** 维护者为"频率限制"类规则勾选自动关闭轮询 +- **THEN** 系统拒绝保存 + +#### Scenario: 号码封存 +- **WHEN** 停机返回"用户状态是物联网号码封存,禁止办理物联网停机" +- **THEN** 该次归为终态不可操作,不自动关闭轮询,按失败告警处理 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/specs/polling-operations/spec.md b/openspec/changes/finalize-unified-stop-resume-rule/specs/polling-operations/spec.md new file mode 100644 index 00000000..0d264352 --- /dev/null +++ b/openspec/changes/finalize-unified-stop-resume-rule/specs/polling-operations/spec.md @@ -0,0 +1,5 @@ +## REMOVED Requirements + +### Requirement: 停复机遵循实际生效实名策略 +**Reason**: 停复机新规则转正,实名门槛唯一由 `card-stop-resume` 的通道实名管控决定,行业卡与卡、设备实名策略不再参与停复机。 +**Migration**: 需要实名的运营商账户设置 `realname_control_enabled = 1`;发布前用停复机规则演练核对受影响卡。 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/tasks.md b/openspec/changes/finalize-unified-stop-resume-rule/tasks.md new file mode 100644 index 00000000..2d47584d --- /dev/null +++ b/openspec/changes/finalize-unified-stop-resume-rule/tasks.md @@ -0,0 +1,51 @@ +## 0. 前置确认 + +- [ ] 0.1 确认 `rebuild-card-traffic-metering-and-stop-resume` 已归档并上线生产(总开关可仍为关闭);第 1、3 个 Change 已上线。 + +## 第一次发布:演练与口径补齐(总开关仍关闭,行为不变) + +## 1. 迁移 + +- [ ] 1.1 新增成对迁移:`tb_gateway_error_classification` 增加 `auto_disable_polling boolean not null default false`,为"已销户""风险停机"类终态规则置位;down 删列。 + +## 2. 规则演练 + +- [ ] 2.1 新增演练 Query:按批读取卡与资产数据,复用新规则判断函数(不受总开关影响,始终按新规则判断),支持假设的实名管控配置;只读。 +- [ ] 2.2 `GET /stop-resume-rule/dry-run`(运营商账户、停机原因筛选,汇总 + 分页明细),仅超级管理员与平台用户;导出场景 `stop_resume_rule_dry_run`。 +- [ ] 2.3 同步路由与文档生成入口,重新生成接口文档。 + +## 3. 风险停机与已销户关闭轮询 + +- [ ] 3.1 `ShouldStopPollingForRisk` 去掉独立卡限制,只关闭该卡自身 `enable_polling`,不级联设备。 +- [ ] 3.2 停复机调用失败时按错误分类查 `auto_disable_polling`:置位则不重试、关闭该卡轮询、回写 `gateway_extend`、写审计。 +- [ ] 3.3 错误分类接口增加 `auto_disable_polling` 字段,非终态类别勾选时拒绝。 + +## 4. 第一次发布的编译与验证 + +- [ ] 4.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;`openspec validate finalize-unified-stop-resume-rule` 通过。 +- [ ] 4.2 测试环境:演练不改任何状态;开启总开关后抽取 `not_realname`、`carrier_stopped`、`manual`、停因为空各若干张,实际评估结果与演练一致。 +- [ ] 4.3 测试环境:绑定设备的卡返回已销户时只关该卡轮询;复机返回风险停机时不重试并关闭轮询;号码封存不关闭轮询。 + +## 5. 开启新规则(运营与维护者,第一次发布上线后) + +- [ ] 5.1 为需要实名的运营商账户设置 `realname_control_enabled = 1`。 +- [ ] 5.2 生产执行演练并导出明细,运营确认"将被复机 / 将被停机"清单;需保持停机的卡先关闭轮询。 +- [ ] 5.3 维护者在生产开启 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` 并重启全部进程;首日监控复机成功率与终态错误数,确认稳定运行。 + +## 第二次发布:转正(新规则在生产稳定运行后) + +## 6. 删除总开关与旧口径 + +- [ ] 6.1 `stop_resume_unified.go` 并入主逻辑:删除 `unifiedStopResumeEnabled` 与各分支,`autoResumableStopReason` 恒为真,`autoControlSuspended`、`unifiedRealnameOK` 无条件生效。 +- [ ] 6.2 `stop_resume_service.go` 删除旧口径:`isRealnameOK` 行业卡与 `realname_policy` 分支、`isPollingStopReason` 限定、`ListByDeviceIDAndPollingStopReasons` 的调用。 +- [ ] 6.3 删除 `pkg/config/config.go`、`loader.go`、`defaults/config.yaml` 的总开关,`.gitea/workflows/deploy.yaml` 与 `docker-compose.prod.yml` 中的 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`。 +- [ ] 6.4 通道阈值新周期复机条件去掉开关分支,固定为新规则复机条件。 + +## 7. 实名轮询 + +- [ ] 7.1 `polling_realname_handler.go`:删除按行业卡跳过;卡所属账户未开实名管控时跳过查询并按正常间隔重入队;账户查询失败按可恢复失败处理。 + +## 8. 第二次发布的编译与验证 + +- [ ] 8.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;全仓检索无 `UnifiedRuleEnabled` 残留;`openspec validate` 与 `openspec doctor --json` 通过。 +- [ ] 8.2 测试环境:未开管控账户不查实名;开启管控账户的行业卡查实名并按未实名停机;通道阈值新周期解锁后按新规则复机或只解锁;停复机行为与第一次发布开启总开关后一致。 diff --git a/openspec/changes/remove-device-protect-period/.openspec.yaml b/openspec/changes/remove-device-protect-period/.openspec.yaml new file mode 100644 index 00000000..1f5d2804 --- /dev/null +++ b/openspec/changes/remove-device-protect-period/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/remove-device-protect-period/design.md b/openspec/changes/remove-device-protect-period/design.md new file mode 100644 index 00000000..5bb5c24a --- /dev/null +++ b/openspec/changes/remove-device-protect-period/design.md @@ -0,0 +1,27 @@ +## Context + +依据 `docs/engineering/轮询按运营商账户限流技术方案.md` 第一部分第 6 节、第四部分"保护期整套机制"。 + +现状:后台设备停复机设置 `protect:device:{id}:stop|start`(1 小时,互斥,`internal/service/device/service.go:1506,1663`);保护期内拒绝相反人工操作;`polling:protect` 轮询(`internal/task/polling_protect_handler.go`)方向不一致时 `ForceStopCard` / `ForceStartCard`;自动停复机 `EvaluateAndAct` 不看保护期,造成反复翻转。`ProtectCheckSkipsForRealname`(`stop_resume_unified.go`)仅保护期轮询使用。资产 DTO 有 `device_protect_status`(`asset_dto.go:38,144`、`client_asset_dto.go:75`)。 + +## Goals / Non-Goals + +**Goals:** 删除保护期的设置、检查、轮询与展示逻辑;不破坏现有客户端;数据库只加不删。 + +**Non-Goals:** 不改停复机业务规则(第 4、6 个 Change);不删 `protect_period` 常量与历史数据;不删数据库列。 + +## Decisions + +1. **字段保留、固定返回 `none`**:直接删字段会让读取该字段的老版本小程序出错;固定值兼容成本最低。备选"字段 omitempty 后不返回":老客户端读不到同样可能出错,放弃。 +2. **数据库列只改注释**:遵循"数据库只加不删";`protect_check_interval`、`last_protect_check_at` 列注释标记已废弃,代码中去掉读写。 +3. **存量 Redis 保护期 key 不清理**:TTL 只有 1 小时,上线后自然过期;代码不再读取,即使残留也无影响。 +4. **Asynq 中已排队的 `polling:protect` 任务**:保留一个空处理函数直接返回成功一个发布周期,避免"未注册任务类型"错误堆积,下一次发布再移除;或上线前确认队列为空。选择保留空处理函数,更稳妥。 + +## Risks / Trade-offs + +- [运营依赖保护期防止误操作] → 已定"规则优先、人工停机无豁免",需要保持停机的关闭轮询(第 7 个 Change 提供批量操作)。 +- [前端仍展示保护期] → 固定 `none` 时前端显示"无",不报错;前端随后去掉展示。 + +## Migration Plan + +1. 迁移只改列注释。2. 测试环境验证后由维护者发布生产。3. 回退:代码回退即可恢复保护期逻辑,数据库无需回退。 diff --git a/openspec/changes/remove-device-protect-period/proposal.md b/openspec/changes/remove-device-protect-period/proposal.md new file mode 100644 index 00000000..7cf9e9ce --- /dev/null +++ b/openspec/changes/remove-device-protect-period/proposal.md @@ -0,0 +1,32 @@ +## Why + +后台对设备停机或复机时会设置 1 小时保护期,保护期内阻止相反的人工操作,`protect` 轮询发现方向不一致时强制停复机。但自动停复机不检查保护期,保护期内会出现"自动停机 → 保护期强制复机 → 再自动停机"的反复翻转,且与"只要符合规则就自动停复机"的原则冲突。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 3 步:整体下线保护期,为后续调度与停复机改造减负。 + +## What Changes + +- **BREAKING** 后台设备停机、复机不再设置保护期,也不再因保护期拒绝相反操作;单卡手动停复机去掉保护期检查。 +- **BREAKING** 下线 `polling:protect` 轮询任务类型:不再注册、调度、统计与监控;轮询并发配置与任务类型名称不再包含保护期检查。 +- 轮询配置的 `protect_check_interval` 不再读写,接口不再接受与返回该字段;数据库列保留,列注释标记已废弃。 +- 卡字段 `last_protect_check_at` 不再读写;数据库列保留,列注释标记已废弃。 +- 资产接口(后台与 C 端)的 `device_protect_status` **字段保留**,固定返回"无保护期"并在接口文档标记废弃,待前端与老版本小程序不再读取后另行删除。 +- 停机原因常量 `protect_period` 保留(历史数据与对外开放接口映射仍需要),不再产生新值;删除 `RedisDeviceProtectKey`、`DeviceProtectPeriodDuration`。 +- 存量保护期 Redis key 上线后自然过期(TTL 1 小时),不做迁移。 + +## Capabilities + +### New Capabilities + +(无) + +### Modified Capabilities + +- `polling-operations`:"轮询并发配置覆盖实际任务"去掉 `protect`;"轮询任务类型名称可读"去掉"保护期检查"。 +- `asset-device`:新增"设备停复机不设保护期"要求,并约定 `device_protect_status` 字段的兼容返回。 + +## Impact + +- 依赖:无前置 Change。第 4 个 Change 改写通道阈值规范时一并去掉"保护期强制复机"表述。 +- 数据库:新增成对迁移,只更新 `tb_polling_config.protect_check_interval`、`tb_iot_card.last_protect_check_at` 的列注释(标记废弃);不删列、不改数据。 +- 代码:删除 `internal/task/polling_protect_handler.go` 与 `pkg/queue/handler.go` 中的注册;`internal/service/device/service.go`(不设、不查保护期);`internal/service/iot_card/stop_resume_service.go`(手动停复机去掉保护期检查);`stop_resume_unified.go` 删除 `ProtectCheckSkipsForRealname`;`internal/polling/config_manager.go`、`initializer.go`、调度器去掉 `protect` 类型;`internal/model/dto/polling_config_dto.go`、`asset_dto.go`、`client_asset_dto.go`、`internal/service/asset/service.go`;`pkg/constants`。 +- 接口:`/polling-configs` 不再含 `protect_check_interval`;`/polling-concurrency` 不再含 `protect`;资产详情 `device_protect_status` 固定为无保护期并标记废弃。重新生成接口文档。 +- 前端:轮询配置页去掉保护期间隔;资产详情(后台与 C 端)不再展示保护期状态。 diff --git a/openspec/changes/remove-device-protect-period/specs/asset-device/spec.md b/openspec/changes/remove-device-protect-period/specs/asset-device/spec.md new file mode 100644 index 00000000..55d0b2e3 --- /dev/null +++ b/openspec/changes/remove-device-protect-period/specs/asset-device/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: 设备停复机不设保护期 + +系统 SHALL 在后台对设备停机或复机后不设置保护期,MUST NOT 因此前的设备停机或复机拒绝相反的人工操作;单卡手动停复机 MUST NOT 检查保护期。系统 MUST NOT 调度保护期一致性检查,也 MUST NOT 产生停机原因 `protect_period` 的新记录;历史记录中的 `protect_period` 保留并照常展示,对外开放接口的停机原因映射保持不变。 + +#### Scenario: 设备停机后立即复机 +- **WHEN** 运营对设备停机后 10 分钟内点击复机 +- **THEN** 系统按复机规则执行,不因保护期拒绝 + +#### Scenario: 停机后自动规则复机 +- **WHEN** 设备被人工停机后满足自动复机条件 +- **THEN** 系统按规则自动复机,不存在保护期内的强制停机或强制复机 + +### Requirement: 保护期状态字段兼容返回 + +系统 SHALL 在后台资产详情与 C 端资产详情中继续返回 `device_protect_status` 字段,其值 MUST 固定为无保护期(`none`),接口文档 MUST 标记该字段已废弃。字段删除另行变更,前提是后台前端与 C 端(含老版本小程序)均不再读取。 + +#### Scenario: 老版本客户端读取 +- **WHEN** 老版本小程序读取设备资产详情 +- **THEN** 响应中仍有 `device_protect_status`,值为 `none` diff --git a/openspec/changes/remove-device-protect-period/specs/polling-operations/spec.md b/openspec/changes/remove-device-protect-period/specs/polling-operations/spec.md new file mode 100644 index 00000000..db86bd0e --- /dev/null +++ b/openspec/changes/remove-device-protect-period/specs/polling-operations/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: 轮询并发配置覆盖实际任务 + +系统 SHALL 为 `realname`、`carddata`、`package` 与 `card_status` 四类实际轮询任务各维护一项可查询、可更新的并发配置;`stop_start` 与已下线的 `protect` 不得作为轮询并发配置返回或接受维护。`card_status` 初始最大并发数 MUST 为 300。 + +#### Scenario: 查询完整配置集合 +- **WHEN** 授权操作者查询轮询并发配置列表 +- **THEN** 返回上述四类任务且不含 `stop_start` 与 `protect` + +#### Scenario: 维护高于一千的既有并发配置 +- **WHEN** 授权操作者为已配置轮询任务提交大于 1000 的正整数最大并发数 +- **THEN** 系统保存该值并使后续轮询限流读取该值 + +#### Scenario: 提交保护期并发配置 +- **WHEN** 授权操作者提交 `protect` 的并发配置 +- **THEN** 系统拒绝该请求 + +### Requirement: 轮询任务类型名称可读 + +系统 SHALL 在轮询并发状态中返回与任务类型一致的中文名称:实名检查、流量检查、套餐检查或卡状态检查。 + +#### Scenario: 查询卡状态并发配置 +- **WHEN** 授权操作者查询 `card_status` 的并发状态 +- **THEN** 返回的 `task_type_name` 为“卡状态检查” diff --git a/openspec/changes/remove-device-protect-period/tasks.md b/openspec/changes/remove-device-protect-period/tasks.md new file mode 100644 index 00000000..e565408e --- /dev/null +++ b/openspec/changes/remove-device-protect-period/tasks.md @@ -0,0 +1,32 @@ +## 1. 迁移 + +- [ ] 1.1 新增成对迁移:更新 `tb_polling_config.protect_check_interval`、`tb_iot_card.last_protect_check_at` 列注释为"已废弃";down 恢复原注释。 + +## 2. 设备与单卡停复机 + +- [ ] 2.1 `internal/service/device/service.go`:设备停机、复机不再设置保护期,不再因保护期拒绝相反操作。 +- [ ] 2.2 `internal/service/iot_card/stop_resume_service.go`:手动停复机单卡(第 969、1036 行附近)去掉保护期检查。 +- [ ] 2.3 删除 `RedisDeviceProtectKey`、`DeviceProtectPeriodDuration`;保留 `StopReasonProtectPeriod` 并在注释说明不再产生新值。 + +## 3. 下线保护期轮询 + +- [ ] 3.1 删除 `internal/task/polling_protect_handler.go`;`pkg/queue/handler.go` 对 `polling:protect` 改为空处理函数直接成功(下一次发布再移除)。 +- [ ] 3.2 调度器、初始化器、生命周期、监控与统计去掉 `protect` 类型;删除 `ProtectCheckSkipsForRealname`。 +- [ ] 3.3 轮询并发配置与任务类型名称去掉 `protect`,提交 `protect` 时拒绝。 +- [ ] 3.4 `PollingConfig` 模型与 DTO 去掉 `ProtectCheckInterval`;`config_manager.go` 匹配去掉保护期;`IotCard` 模型去掉 `LastProtectCheckAt` 的读写。 + +## 4. 资产接口兼容 + +- [ ] 4.1 后台与 C 端资产详情的 `device_protect_status` 固定返回 `none`,DTO 说明标记已废弃;`internal/service/asset/service.go` 不再读取保护期 key。 +- [ ] 4.2 重新生成接口文档。 + +## 5. 编译与规范校验 + +- [ ] 5.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;全仓检索无残留的保护期 key 读写。 +- [ ] 5.2 `openspec validate remove-device-protect-period` 与 `openspec doctor --json` 通过。 + +## 6. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001) + +- [ ] 6.1 设备停机后立即复机成功,不被保护期拒绝;单卡手动停复机同样。 +- [ ] 6.2 不再产生 `polling:protect` 任务与 `protect_period` 停机原因;历史 `protect_period` 记录正常展示。 +- [ ] 6.3 资产详情 `device_protect_status` 恒为 `none`;轮询配置接口不含保护期间隔;并发配置不含 `protect`。 diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index d524c2d4..1d5683de 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -584,6 +584,22 @@ const ( AuditActionPollingConfigDeleted = "polling_config.delete" // AuditActionPollingConfigStatusUpdated 表示启用或禁用轮询配置。 AuditActionPollingConfigStatusUpdated = "polling_config.update_status" + // AuditActionRateLimitGroupCreated 表示创建运营商账户网关限流组。 + AuditActionRateLimitGroupCreated = "rate_limit_group.create" + // AuditActionRateLimitGroupUpdated 表示更新运营商账户网关限流组。 + AuditActionRateLimitGroupUpdated = "rate_limit_group.update" + // AuditActionRateLimitGroupStatusUpdated 表示启用或停用运营商账户网关限流组。 + AuditActionRateLimitGroupStatusUpdated = "rate_limit_group.update_status" + // AuditActionRateLimitGroupDeleted 表示删除运营商账户网关限流组。 + AuditActionRateLimitGroupDeleted = "rate_limit_group.delete" + // AuditActionGatewayErrorRuleCreated 表示创建上游错误分类规则。 + AuditActionGatewayErrorRuleCreated = "gateway_error_rule.create" + // AuditActionGatewayErrorRuleUpdated 表示更新上游错误分类规则。 + AuditActionGatewayErrorRuleUpdated = "gateway_error_rule.update" + // AuditActionGatewayErrorRuleStatusUpdated 表示启用或停用上游错误分类规则。 + AuditActionGatewayErrorRuleStatusUpdated = "gateway_error_rule.update_status" + // AuditActionGatewayErrorRuleDeleted 表示删除上游错误分类规则。 + AuditActionGatewayErrorRuleDeleted = "gateway_error_rule.delete" // AuditActionPollingConcurrencyUpdated 表示更新轮询任务并发配置。 AuditActionPollingConcurrencyUpdated = "polling_concurrency.update" // AuditActionPollingConcurrencyReset 表示人工重置轮询并发计数。 @@ -759,6 +775,10 @@ const ( AuditResourceNotificationCleanupBatch = "notification_cleanup_batch" // AuditResourcePollingConfig 表示轮询配置资源。 AuditResourcePollingConfig = "polling_config" + // AuditResourceRateLimitGroup 表示运营商账户网关限流组资源。 + AuditResourceRateLimitGroup = "rate_limit_group" + // AuditResourceGatewayErrorRule 表示上游错误分类规则资源。 + AuditResourceGatewayErrorRule = "gateway_error_rule" // AuditResourcePollingConcurrencyConfig 表示轮询并发配置资源。 AuditResourcePollingConcurrencyConfig = "polling_concurrency" // AuditResourcePollingAlertRule 表示轮询告警规则资源。 diff --git a/pkg/constants/gateway_rate_limit.go b/pkg/constants/gateway_rate_limit.go new file mode 100644 index 00000000..80c5d361 --- /dev/null +++ b/pkg/constants/gateway_rate_limit.go @@ -0,0 +1,243 @@ +package constants + +import ( + "fmt" + "time" +) + +// 卡网关操作:限流组、错误分类与调用统计共用的操作标识。 +const ( + // GatewayOpQueryFlow 查流量。 + GatewayOpQueryFlow = "query_flow" + // GatewayOpQueryStatus 查卡状态。 + GatewayOpQueryStatus = "query_status" + // GatewayOpQueryRealname 查实名。 + GatewayOpQueryRealname = "query_realname" + // GatewayOpStop 停机。 + GatewayOpStop = "stop" + // GatewayOpStart 复机。 + GatewayOpStart = "start" +) + +// GatewayCardOperations 是受运营商账户限流约束的卡网关操作全集。 +var GatewayCardOperations = []string{GatewayOpQueryFlow, GatewayOpQueryStatus, GatewayOpQueryRealname, GatewayOpStop, GatewayOpStart} + +// GatewayOperationName 返回卡网关操作的中文名称。 +func GatewayOperationName(op string) string { + switch op { + case GatewayOpQueryFlow: + return "查流量" + case GatewayOpQueryStatus: + return "查状态" + case GatewayOpQueryRealname: + return "查实名" + case GatewayOpStop: + return "停机" + case GatewayOpStart: + return "复机" + default: + return "未知" + } +} + +// IsGatewayCardOperation 判断是否为合法的卡网关操作。 +func IsGatewayCardOperation(op string) bool { + for _, item := range GatewayCardOperations { + if item == op { + return true + } + } + return false +} + +// 网关调用错误类别(结构化错误与分钟计数共用)。 +const ( + // GatewayErrorNetwork 网络错误(连接失败、DNS 等)。 + GatewayErrorNetwork = "network" + // GatewayErrorTimeout 请求超时,结果未知。 + GatewayErrorTimeout = "timeout" + // GatewayErrorHTTP HTTP 状态码异常。 + GatewayErrorHTTP = "http" + // GatewayErrorInvalidResponse 响应无效。 + GatewayErrorInvalidResponse = "invalid_response" + // GatewayErrorUpstream 上游业务错误(再经错误分类细分)。 + GatewayErrorUpstream = "upstream" + // GatewayErrorRateLimited 本系统限流拒绝,未发出请求。 + GatewayErrorRateLimited = "rate_limited" + // GatewayResultSuccess 调用成功(计数用)。 + GatewayResultSuccess = "success" +) + +// 上游错误分类类别(tb_gateway_error_classification.category)。 +const ( + // GatewayClassRateLimitedUpstream 上游频率限制。 + GatewayClassRateLimitedUpstream = "rate_limited_upstream" + // GatewayClassTerminal 上游终态不可操作。 + GatewayClassTerminal = "terminal" + // GatewayClassAlreadyTarget 上游表示卡已是目标状态。 + GatewayClassAlreadyTarget = "already_target" + // GatewayClassTransient 上游临时故障。 + GatewayClassTransient = "transient" + // GatewayClassBusiness 上游业务错误(未命中规则的默认类别)。 + GatewayClassBusiness = "business" + // GatewayClassAccountConfig 账户配置错误(凭证、接入参数)。 + GatewayClassAccountConfig = "account_config" +) + +// GatewayErrorClasses 是上游错误分类类别全集。 +var GatewayErrorClasses = []string{GatewayClassRateLimitedUpstream, GatewayClassTerminal, GatewayClassAlreadyTarget, GatewayClassTransient, GatewayClassBusiness, GatewayClassAccountConfig} + +// GatewayErrorClassName 返回上游错误分类类别中文名称。 +func GatewayErrorClassName(class string) string { + switch class { + case GatewayClassRateLimitedUpstream: + return "上游频率限制" + case GatewayClassTerminal: + return "终态不可操作" + case GatewayClassAlreadyTarget: + return "已是目标状态" + case GatewayClassTransient: + return "临时故障" + case GatewayClassBusiness: + return "业务错误" + case GatewayClassAccountConfig: + return "账户配置错误" + default: + return "未知" + } +} + +// 错误分类匹配方式。 +const ( + // GatewayMatchContains 包含关键字。 + GatewayMatchContains = "contains" + // GatewayMatchPrefix 以关键字开头。 + GatewayMatchPrefix = "prefix" + // GatewayMatchRegex 正则匹配。 + GatewayMatchRegex = "regex" +) + +// 限流豁免来源:只有这 4 个入口可以绕过运营商账户限流。 +const ( + // RateLimitExemptManualClick 后台人工点击停复机。 + RateLimitExemptManualClick = "manual_click" + // RateLimitExemptChannelThreshold 通道阈值停复机。 + RateLimitExemptChannelThreshold = "channel_threshold" + // RateLimitExemptAutoRenewal 自动续费停复机。 + RateLimitExemptAutoRenewal = "auto_renewal" + // RateLimitExemptManualRefresh 手动刷新。 + RateLimitExemptManualRefresh = "manual_refresh" +) + +// 限流模式(polling.rate_limit_mode)。 +const ( + // RateLimitModeObserve 只统计不拦截。 + RateLimitModeObserve = "observe" + // RateLimitModeEnforce 拦截。 + RateLimitModeEnforce = "enforce" +) + +// 轮询模块受控系统配置。 +const ( + // SystemConfigModulePolling 是轮询与网关限流配置模块。 + SystemConfigModulePolling = "polling" + // SystemConfigPollingRateLimitMode 网关限流模式。 + SystemConfigPollingRateLimitMode = "polling.rate_limit_mode" + // SystemConfigPollingGatewayDefaultQPS 默认每秒请求上限。 + SystemConfigPollingGatewayDefaultQPS = "polling.gateway_default_qps" + // SystemConfigPollingRateLimitedRetryDelay Asynq 任务遇限流后的固定重试延迟(秒)。 + SystemConfigPollingRateLimitedRetryDelay = "polling.rate_limited_retry_delay_seconds" + // SystemConfigPollingRateLimitDeferAlert 限流累计推迟告警阈值(秒)。 + SystemConfigPollingRateLimitDeferAlert = "polling.rate_limit_defer_alert_seconds" + // SystemConfigPollingStopResumeRecordRetention 停复机记录保留月数。 + SystemConfigPollingStopResumeRecordRetention = "polling.stop_resume_record_retention_months" + // SystemConfigPollingStopResumeRecordPremake 停复机记录提前建分区月数。 + SystemConfigPollingStopResumeRecordPremake = "polling.stop_resume_record_premake_months" +) + +// TaskTypeStopResumeRecordPartition 停复机记录分区维护定时任务(每日执行,使用数据清理队列)。 +const TaskTypeStopResumeRecordPartition = "stop_resume_record:partition" + +// 停复机记录动作与结果(tb_card_stop_resume_record)。 +const ( + // StopResumeActionStop 停机。 + StopResumeActionStop = "stop" + // StopResumeActionStart 复机。 + StopResumeActionStart = "start" + // StopResumeRecordSuccess 成功。 + StopResumeRecordSuccess = "success" + // StopResumeRecordFailed 失败。 + StopResumeRecordFailed = "failed" + // StopResumeRecordUnknown 结果未知。 + StopResumeRecordUnknown = "unknown" + // StopResumeRecordAlreadyTarget 已是目标状态。 + StopResumeRecordAlreadyTarget = "already_target" + // StopResumeRecordSkipped 跳过(未到单卡最小重试间隔)。 + StopResumeRecordSkipped = "skipped" +) + +const ( + // RateLimitGroupStatusDisabled 限流组停用。 + RateLimitGroupStatusDisabled = 0 + // RateLimitGroupStatusEnabled 限流组启用。 + RateLimitGroupStatusEnabled = 1 + // GatewayRateLimitWindow 限流滑动窗口长度。 + GatewayRateLimitWindow = time.Second + // GatewayRateLimitConfigTTL 限流组与模式在进程内缓存的时长,超过即重新读取,保证后台修改数秒内生效。 + GatewayRateLimitConfigTTL = 5 * time.Second + // GatewayCardLastErrorTTL 卡最近一次网关错误保留时长。 + GatewayCardLastErrorTTL = 30 * 24 * time.Hour + // GatewayCallStatTTL 分钟计数保留时长(覆盖只统计模式一周的评估需要)。 + GatewayCallStatTTL = 8 * 24 * time.Hour + // GatewayCardCarrierCacheTTL ICCID 到运营商账户映射的缓存时长。 + GatewayCardCarrierCacheTTL = 10 * time.Minute + // GatewayProviderMessageMaxLen 上游信息落库或展示的最大长度(按字符)。 + GatewayProviderMessageMaxLen = 200 + // StopResumeRecordQueryMaxDays 停复机记录列表单次查询的最大时间跨度(天)。 + StopResumeRecordQueryMaxDays = 31 +) + +// RedisGatewayQPSKey 返回账户限流组的滑动窗口 Key;carrierID 为 0 表示无法解析账户的卡(单独计量)。 +func RedisGatewayQPSKey(carrierID uint, groupCode string) string { + return fmt.Sprintf("gateway:qps:%d:%s", carrierID, groupCode) +} + +// RedisGatewayGlobalQPSKey 返回全局限流组的滑动窗口 Key。 +func RedisGatewayGlobalQPSKey(groupCode string) string { + return fmt.Sprintf("gateway:qps:global:%s", groupCode) +} + +// RedisGatewayQPSHighWaitingKey 返回限流组正在等待名额的高优先级请求数 Key。 +func RedisGatewayQPSHighWaitingKey(carrierID uint, groupCode string) string { + return RedisGatewayQPSKey(carrierID, groupCode) + ":high_waiting" +} + +// RedisGatewayCallStatKey 返回网关调用分钟计数 Key(Hash:结果类别 → 次数、耗时分桶)。 +func RedisGatewayCallStatKey(carrierID uint, op, minute string) string { + return fmt.Sprintf("gateway:stat:%d:%s:%s", carrierID, op, minute) +} + +// RedisGatewayCardCarrierKey 返回 ICCID 到运营商账户 ID 映射的缓存 Key。 +func RedisGatewayCardCarrierKey(iccid string) string { + return fmt.Sprintf("gateway:card_carrier:%s", iccid) +} + +// RedisGatewayRateLimitGroupsKey 返回限流组配置缓存 Key。 +func RedisGatewayRateLimitGroupsKey() string { + return "gateway:rate_limit_groups" +} + +// RedisGatewayErrorClassificationKey 返回错误分类规则缓存 Key。 +func RedisGatewayErrorClassificationKey() string { + return "gateway:error_classification" +} + +// RedisRateLimitDeferStartKey 返回任务首次被限流时间的 Key,用于累计推迟告警。 +func RedisRateLimitDeferStartKey(taskKey string) string { + return fmt.Sprintf("gateway:rate_limit_defer:%s", taskKey) +} + +// RedisGatewayCardLastErrorKey 返回卡某网关操作最近一次错误的 Key(Hash:类别、码、信息、时间)。 +func RedisGatewayCardLastErrorKey(iccid, op string) string { + return fmt.Sprintf("gateway:last_error:%s:%s", iccid, op) +} diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 6a5be80d..45eac82e 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -64,6 +64,7 @@ func BuildDocHandlers() *bootstrap.Handlers { CUCCRealnameCallback: callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), CUCCRealnameRemovalCallback: callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil), PollingConfig: admin.NewPollingConfigHandler(nil), + GatewayRateLimit: admin.NewGatewayRateLimitHandler(nil, nil, nil), PollingConcurrency: admin.NewPollingConcurrencyHandler(nil), PollingMonitoring: admin.NewPollingMonitoringHandler(nil), PollingAlert: admin.NewPollingAlertHandler(nil), diff --git a/pkg/queue/server.go b/pkg/queue/server.go index dfed933c..f8dffcdc 100644 --- a/pkg/queue/server.go +++ b/pkg/queue/server.go @@ -1,6 +1,8 @@ package queue import ( + "time" + "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/hibiken/asynq" @@ -14,8 +16,36 @@ type Server struct { logger *zap.Logger } +// RateLimitedRetry 定义任务因运营商账户网关限流失败时的重试语义:不计为失败、按固定短延迟重试。 +// Match 判断错误是否为限流拒绝;Delay 返回当前配置的固定延迟;OnDeferred 在每次因限流推迟时回调(用于累计推迟告警)。 +type RateLimitedRetry struct { + Match func(error) bool + Delay func() time.Duration + OnDeferred func(task *asynq.Task) +} + +// ServerOption 是 Asynq 服务器的可选配置。 +type ServerOption func(*serverOptions) + +type serverOptions struct { + rateLimited *RateLimitedRetry +} + +// WithRateLimitedRetry 让限流错误不计入任务重试次数并按固定短延迟重试,其他错误行为不变。 +func WithRateLimitedRetry(retry RateLimitedRetry) ServerOption { + return func(o *serverOptions) { + if retry.Match != nil { + o.rateLimited = &retry + } + } +} + // NewServer 创建新的 Asynq 服务器 -func NewServer(redisClient *redis.Client, queueCfg *config.QueueConfig, logger *zap.Logger) *Server { +func NewServer(redisClient *redis.Client, queueCfg *config.QueueConfig, logger *zap.Logger, options ...ServerOption) *Server { + var serverOpts serverOptions + for _, option := range options { + option(&serverOpts) + } // 从 Redis 客户端获取配置 opts := redisClient.Options() @@ -28,6 +58,38 @@ func NewServer(redisClient *redis.Client, queueCfg *config.QueueConfig, logger * concurrency = constants.DefaultConcurrency } + asynqConfig := asynq.Config{ + // 并发数 + Concurrency: concurrency, + // 队列优先级配置 + Queues: queues, + // 重试延迟函数(指数退避) + RetryDelayFunc: asynq.DefaultRetryDelayFunc, + // 是否记录详细日志 + LogLevel: asynq.WarnLevel, + } + if retry := serverOpts.rateLimited; retry != nil { + // 限流错误不是任务本身的失败:IsFailure 返回 false 时 Asynq 不增加重试计数,任务不会因限流被归档; + // 重试间隔固定为短延迟,避免按指数退避把"流量用完停机"等推迟数小时。 + asynqConfig.IsFailure = func(err error) bool { + return !retry.Match(err) + } + asynqConfig.RetryDelayFunc = func(n int, err error, task *asynq.Task) time.Duration { + if retry.Match(err) { + if retry.OnDeferred != nil { + retry.OnDeferred(task) + } + if retry.Delay != nil { + if delay := retry.Delay(); delay > 0 { + return delay + } + } + return 5 * time.Second + } + return asynq.DefaultRetryDelayFunc(n, err, task) + } + } + // 创建 Asynq 服务器配置 asynqServer := asynq.NewServer( asynq.RedisClientOpt{ @@ -35,16 +97,7 @@ func NewServer(redisClient *redis.Client, queueCfg *config.QueueConfig, logger * Password: opts.Password, DB: opts.DB, }, - asynq.Config{ - // 并发数 - Concurrency: concurrency, - // 队列优先级配置 - Queues: queues, - // 重试延迟函数(指数退避) - RetryDelayFunc: asynq.DefaultRetryDelayFunc, - // 是否记录详细日志 - LogLevel: asynq.WarnLevel, - }, + asynqConfig, ) return &Server{