diff --git a/.gitignore b/.gitignore index 948a4c2..d0a3496 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ docs !docs/ docs/* !docs/权限.md +!docs/2026-10-26-轮询账户限流-后台接入交付.md dist-ssr *.local .cursorrules diff --git a/docs/2026-10-26-轮询账户限流-后台接入交付.md b/docs/2026-10-26-轮询账户限流-后台接入交付.md new file mode 100644 index 0000000..35c27da --- /dev/null +++ b/docs/2026-10-26-轮询账户限流-后台接入交付.md @@ -0,0 +1,128 @@ +# 轮询账户限流:后台接入交付 + +本次仅修改本后台前端,未修改独立 C 端、未推送 Git 或部署生产、未配置数据库菜单。真实接口权限和调度行为仍需测试环境联调。本地写操作全部通过 mock 验证。 + +## 后台配置与使用 + +新增菜单、隐藏详情、按钮编码和平台账号限制见 [权限增量清单](./权限.md#轮询管理运营商账户限流与调度2026-10-26-增量)。原有网关、轮询配置预览/统计、停复机记录 URL 和权限保留。仅超级管理员的维护项不列入普通配置清单。 + +UI 沿用店铺列表表头、可选列、分页和店铺详情返回/标题/两列布局;窄屏详情单列。时间统一店铺格式,运营商账户按名称远程搜索,搜索条件 >=4 才出现展开。 + +新增配置字段现可保存,旧文档“仅预览”已失效。四项间隔:空继承、0 禁止且不再继承、正数执行。编辑清空使用 clear_fields,保存 warnings 单独展示;不再发送保护期间隔。 + +## C 端需要同步哪些 + +**必须修改(文档明确要求)** + +1. C 端资产详情调用 `GET /api/c/v1/asset/info` 后,不再展示保护期状态(`device_protect_status`),包括标签、说明、倒计时和相关状态分支(如有)。 +2. 废弃字段可能仍返回固定 `none`,也可能缺失;兼容解析,但不能再依赖它决定页面展示或是否允许操作。不要把 `none` 当成新的异常状态。 + +**随新后端回归验证(不是要求 C 端增加后台功能)** + +- 订单/套餐生效、有效期变化后,重新读取资产实际 `enable_polling`;后台可能自动恢复轮询,不要仅凭旧前端缓存维持“已关闭”。 +- 指令队列启用时,业务受理不等于上游已完成停复机。若 C 端有即时完成提示,改为区分“已受理/处理中”和实际状态,并在合理刷新后确认。 +- 资产详情读入口的请求检查、延迟确认及冷却由后端场景配置控制,C 端不要自行复制调度逻辑或高频循环触发。 + +C 端不需要新增本次后台菜单、告警/调度参数/导入接口;本仓库没有修改任何 C 端代码。若 C 端本来没有保护期 UI 或逻辑,第一项只需核查删除残留引用。 + +## 接口覆盖 + +文档 46 个新增操作中,已有 GatewayService 17 个、PollingConfigService 7 个,本次 PollingSchedulerService 补 22 个。自动测试逐条解析原文附录的 46 个方法/路径,与实际服务调用对照,不以数量代替路径验收。 + +下表路径省略 `/api/admin`。精确 URL、Query/Body 测试位于 `gateway.test.mjs`、`pollingConfigExtensions.test.mjs`、`pollingScheduler.test.mjs`。 + +| HTTP 操作 | 服务方法 | 后台入口 | +| --- | --- | --- | +| `DELETE /gateway-error-classifications/{id}` | `GatewayService.deleteRule` | 上游错误分类 | +| `DELETE /polling-carrier-params/{carrier_id}` | `PollingSchedulerService.resetCarrierParam` | 账户参数维护 | +| `DELETE /polling-rate-limit-groups/{id}` | `GatewayService.deleteGroup` | 网关限流组维护 | +| `GET /card-polling-pauses` | `PollingSchedulerService.getPauses` | 不轮询清单 | +| `GET /card-stop-resume-records` | `GatewayService.getRecords` | 停复机记录 | +| `GET /gateway-error-classifications` | `GatewayService.getRules` | 上游错误分类 | +| `GET /iot-cards/{id}/polling-state` | `PollingSchedulerService.getCardState` | 单卡/设备绑定卡/告警详情 | +| `GET /iot-cards/{id}/stop-resume-records` | `GatewayService.getCardRecords` | 单卡/告警详情 | +| `GET /polling-carrier-params` | `PollingSchedulerService.getCarrierParams` | 运营商账户调度参数 | +| `GET /polling-carrier-params/{carrier_id}` | `PollingSchedulerService.getCarrierParam` | 账户参数详情 | +| `GET /polling-check-scenes` | `PollingSchedulerService.getScenes` | 请求检查场景 | +| `GET /polling-configs/match-preview` | `PollingConfigService.previewMatch` | 配置匹配预览 | +| `GET /polling-configs/recalculate-due/{task_id}` | `PollingConfigService.getRecalculateTask` | 重算进度 | +| `GET /polling-failure-alerts` | `PollingSchedulerService.getAlerts` | 告警普通/停机被拒 | +| `GET /polling-failure-alerts/summary` | `PollingSchedulerService.getAlertSummary` | 告警账户汇总 | +| `GET /polling-failure-alerts/{id}` | `PollingSchedulerService.getAlert` | 告警详情 | +| `GET /polling-options` | `GatewayService.getOptions` | 网关各页枚举 | +| `GET /polling-rate-limit-groups` | `GatewayService.getGroups` | 网关限流组 | +| `GET /polling-rate-limit-groups/{id}` | `GatewayService.getGroup` | 网关限流组详情 | +| `GET /polling-stats/carrier-queues` | `PollingSchedulerService.getQueues` | 账户队列监控 | +| `GET /polling-stats/carriers` | `GatewayService.getCarrierStats` | 网关汇总/限流模式 | +| `GET /polling-stats/carriers/{carrier_id}/trend` | `GatewayService.getCarrierTrend` | 账户调用趋势 | +| `GET /polling-stats/pre-reset` | `PollingConfigService.getPreResetStats` | 重置日前统计 | +| `GET /polling-stats/tiers` | `PollingConfigService.getTierStats` | 分层统计 | +| `GET /polling-status-imports` | `PollingSchedulerService.getImports` | 导入任务列表 | +| `GET /polling-status-imports/{id}` | `PollingSchedulerService.getImport` | 导入逐行结果 | +| `GET /polling-status/impact` | `PollingSchedulerService.getImpact` | 单项/批量/导入确认 | +| `GET /stop-resume-rule/dry-run` | `PollingSchedulerService.dryRun` | 规则演练(只读) | +| `POST /devices/batch-update-polling-status` | `PollingSchedulerService.toggleDevices` | 设备批量/清单恢复 | +| `POST /gateway-error-classifications` | `GatewayService.createRule` | 上游错误分类 | +| `POST /gateway-error-classifications/test` | `GatewayService.testRule` | 错误分类测试 | +| `POST /iot-cards/batch-update-polling-status` | `PollingSchedulerService.toggleCards` | 卡批量/清单恢复 | +| `POST /polling-configs/match-preview` | `PollingConfigService.previewDraft` | 配置草稿预览 | +| `POST /polling-configs/recalculate-due` | `PollingConfigService.recalculateDue` | 重算到期 | +| `POST /polling-failure-alerts/close-polling` | `PollingSchedulerService.closeAlertPolling` | 告警批量关闭卡轮询 | +| `POST /polling-rate-limit-groups` | `GatewayService.createGroup` | 网关限流组维护 | +| `POST /polling-status-imports` | `PollingSchedulerService.createImport` | 创建导入 | +| `POST /stop-resume-rule/dry-run/export` | `PollingSchedulerService.exportDryRun` | 演练导出 | +| `PUT /gateway-error-classifications/{id}` | `GatewayService.updateRule` | 上游错误分类 | +| `PUT /gateway-error-classifications/{id}/status` | `GatewayService.setRuleStatus` | 上游错误分类 | +| `PUT /polling-carrier-params/{carrier_id}` | `PollingSchedulerService.saveCarrierParam` | 账户参数维护 | +| `PUT /polling-check-scenes/{scene}` | `PollingSchedulerService.saveScene` | 请求检查场景维护 | +| `PUT /polling-configs/priorities` | `PollingConfigService.updatePriorities` | 整体优先级(拖动/上移下移) | +| `PUT /polling-failure-alerts/{id}/status` | `PollingSchedulerService.handleAlert` | 告警处理 | +| `PUT /polling-rate-limit-groups/{id}` | `GatewayService.updateGroup` | 网关限流组维护 | +| `PUT /polling-rate-limit-groups/{id}/status` | `GatewayService.setGroupStatus` | 网关限流组维护 | + +### 16 个既有接口改动的处理 + +| 既有接口 | 接入结果 | +| --- | --- | +| GET /polling-configs | 列表新增资产/套餐条件、重置日前窗口和间隔列 | +| GET /polling-configs/:id | 编辑读入全部新增字段,废弃保护间隔不再参与 | +| POST /polling-configs | 白名单保存新增匹配及窗口字段、三态间隔,展示 warnings | +| PUT /polling-configs/:id | clear_fields 显式清空,0 保留,展示 warnings | +| GET /assets/resolve/:identifier | 不再映射废弃保护期展示;其余资产身份不变 | +| GET /assets/:identifier/realtime-status | 不再更新保护期 UI 字段 | +| POST /assets/:identifier/refresh | 沿用刷新读取,界面无保护期展示 | +| GET /api/c/v1/asset/info | 独立 C 端同步项,本仓库不改 | +| PUT /carriers/:id | 0→1 首次不带确认;展示后端实际影响后确认重提 | +| GET /system-configs | 新增 polling 模块,复用 string/int/bool/json 编辑与校验 | +| GET /iot-cards/standalone | 三态是否轮询筛选,保留 false | +| GET /devices | 新增筛选与是否轮询列 | +| PATCH /assets/:identifier/polling-status | 先读 impact 确认,拒绝/取消恢复 UI,不提供强制参数 | +| POST /storage/upload-url | purpose=polling_status_import;校验 key 前缀后上传,成功才建任务 | +| POST /export-tasks | 三个新增场景;卡/设备保留 enable_polling 筛选 | +| GET /export-tasks | 三个场景列表、共用详情与下载权限;历史文件不做客户端改写 | + +`PUT /system-configs/:key` 继续走原编辑能力,新增键来自服务器;本次不会自动修改 scheduler、rate_limit_mode、queue_enabled。 + +## 验证与限制 + +本地复现: + +```powershell +$tests = @(rg --files scripts/tests -g '*.test.mjs') +node --import ./scripts/tests/register-esbuild.mjs --test $tests +.\node_modules\.bin\vue-tsc.cmd --noEmit +node scripts/tests/verifyBuild.mjs +node scripts/tests/verifyBuild.mjs --serve --scheduler +``` + +最后一项在 `http://127.0.0.1:5190` 启动本地 mock,所有 /api 被拦截;可使用 local-scheduler-platform/admin/agent/enterprise 模拟 token。该结果不等于线上后端已联调。 + +已覆盖:46 路径映射、22 新方法请求结构、null/0/false、清空与废弃字段、权限/身份、500 条和 CSV 50000 行边界、引号换行、部分成功、影响说明失败/取消、上传失败、迟到请求/账号范围切换/卸载、模板编译。旧实名管控回归改成后端实际文案确认。 + +生产构建的既有循环依赖、常量 v-model 和大 chunk 警告仍存在;未在本次跨范围重构。后端若将实名确认提示升级为独立错误码,可替换当前的语义识别,避免依赖提示中的“实名/确认”关键词。当前未知网络错误或不相关错误不会触发确认重试。 + +2026-10-26 最终回归:全量 191 项通过(本次调度接入新增 17 项,保留并升级原有回归)。类型检查、生产构建、针对性 ESLint / Prettier / 新增组件样式检查、全仓 UTF-8 编码检查通过。 + +浏览器使用本地 mock 验证了平台账号入口、特殊告警视图、批量 impact 确认与四类逐项结果、CSV 上传建任务/详情、演练汇总与明细、时间格式、网关 80% 成功率及 p95 超桶显示;检查了浅/深色与 375/390/768/1024/1440 宽度布局。开发热更新仍可能遇到项目既有 API 循环依赖,刷新后进行验收,不把热更新错误当作真实接口结果。 + +真实后端账号、数据范围、错误码、异步终态和导出文件内容需由部署后的联调确认;本次未进行线上写入、Git 推送或部署。 diff --git a/docs/权限.md b/docs/权限.md index 56b3fbc..fec6015 100644 --- a/docs/权限.md +++ b/docs/权限.md @@ -258,3 +258,111 @@ | 停复机记录 | 资产管理 → 单卡信息,设备绑定卡操作区 | `device_info:stop_resume_records` | 查询、重置、分页与刷新使用页面权限,无需另配按钮。运营商名称搜索下拉需要当前账号能读取 `/api/admin/carriers`;这些前端编码不替代后端独立鉴权。 + +## 轮询管理:轮询配置预览与统计 + +本次只需新增一个菜单;已有「轮询配置」`/polling-management/config` 保留,不改旧 CRUD 权限。以下仅列普通账号需要配置的项。 + +| 菜单名称 | URL | 父菜单 | 菜单权限编码 | +| --- | --- | --- | --- | +| 轮询配置统计 | `/polling-management/config-stats` | 轮询管理 `/polling-management` | `polling_config_stats:page` | + +| 按钮名称 | 所属菜单 | 按钮权限编码 | +| --- | --- | --- | +| 匹配预览 / 草稿预览 / 预览未保存修改 | 轮询配置 `/polling-management/config` | `polling_config:match_preview` | +| 查询重算进度 | 轮询配置 `/polling-management/config` | `polling_config:recalculate_progress` | +| 查看重置日前加密查流量效果统计 | 轮询配置统计 | `polling_config_stats:pre_reset` | +| 查看分层轮询统计 | 轮询配置统计 | `polling_config_stats:tiers` | + +统计菜单权限和区域权限需同时授权;若仅授权其中一个统计区域,另一区域不会请求接口。进度查询权限只允许查询已有任务,不授予创建任务的能力。上述编码是前端配置约定,后端独立鉴权。 + +### 接入说明 + +- 当前配置预览使用 GET;草稿预览使用 POST,并在 Query 和 Body 同时传入同一 ICCID。多条草稿可覆盖已有配置或以 id=0 新增,预览不调用持久化 CRUD。 +- 草稿中实名、流量、套餐、卡状态检查区分继承(null)、禁止且不再继承(0)、指定秒数(正数)。保护期已下线,表单和请求均移除。重置日前加密需明确开启,窗口和窗口内流量间隔成对设置。 +- 以 2026-10-26 完整接口文档为准:资产状态、套餐状态、重置日前窗口字段现已支持创建/编辑保存;编辑清空通过 `clear_fields`,保存成功展示 `warnings`。 +- 时间使用与店铺列表相同的 `formatDateTime`。运营商账户按名称远程搜索,仍提交数字 ID;搜索条件 >=4 时才显示展开按钮。 +- 任务进度每 3 秒查询一次,终态、失败、关闭、路由离开和卸载后停止;保留真实 task_id、任务状态及 string 类型的响应账户 ID。 +- 统计周期为 null 时显示「无周期数据」,真实 0 保留。分层统计标明 5 分钟缓存,未匹配配置的在库卡和已销售无套餐卡突出告警;刷新不宣称清除服务器缓存。 + +### 本地验证 + +```powershell +node --test scripts/tests/pollingConfigExtensions.test.mjs +.\node_modules\.bin\vue-tsc.cmd --noEmit +node scripts/tests/verifyBuild.mjs +node scripts/tests/verifyBuild.mjs --serve --polling +``` + +最后一条命令启动 `http://127.0.0.1:5190` 的本地模拟接口页面;所有 `/api` 请求被拦截,不会对真实后端保存配置或创建任务。当前未进行真实后端联调、菜单数据写入、Git 推送或生产部署。 + +2026-10-26 验证:新增轮询配置测试 20 项、全量回归 174 项通过;类型、生产构建、针对性 lint / 格式 / 编码检查通过。浏览器验证了当前预览、草稿控件、异步进度、空周期/零值统计、普通账号入口及小屏布局。生产构建仍有项目既有循环依赖、其他页面常量绑定与大 chunk 警告。 + +## 轮询管理:运营商账户限流与调度(2026-10-26 增量) + +本节在前两次配置的基础上新增,旧菜单和旧权限不必重建。仅列非超级管理员专属的菜单/按钮;标注“平台”的项只允许 user_type=1/2,权限码不能突破身份限制。前端编码是本项目约定,后端仍需独立授权。 + +### 新增可见菜单 + +| 菜单名称 | URL | 父菜单 | 菜单权限 | +| --- | --- | --- | --- | +| 运营商账户调度参数 | `/polling-management/carrier-params` | 轮询管理 `/polling-management` | `polling_carrier_params:page` | +| 请求检查场景 | `/polling-management/check-scenes` | 轮询管理 | `polling_check_scene:page` | +| 失败告警 | `/polling-management/failure-alerts` | 轮询管理 | `polling_failure_alert:page` | +| 账户队列监控 | `/polling-management/carrier-queues` | 轮询管理 | `polling_carrier_queue:page` | +| 停复机规则演练(平台) | `/polling-management/stop-resume-dry-run` | 轮询管理 | `stop_resume_dry_run:page` | +| 不轮询清单(平台) | `/polling-management/polling-pauses` | 轮询管理 | `polling_pause:page` | +| 轮询开关导入(平台) | `/polling-management/polling-status-imports` | 轮询管理 | `polling_status_import:page` | +| 导出不轮询清单(平台) | `/asset-management/export-task-management/export-polling-pause` | 导出管理 `/asset-management/export-task-management` | `export_task:polling_pause_page` | +| 导出停机被拒告警(平台) | `/asset-management/export-task-management/export-polling-stop-refused-alert` | 导出管理 | `export_task:polling_stop_refused_alert_page` | +| 导出停复机规则演练(平台) | `/asset-management/export-task-management/export-stop-resume-rule-dry-run` | 导出管理 | `export_task:stop_resume_rule_dry_run_page` | + +### 新增隐藏详情菜单 + +这些是独立页面,不是按钮编码,需配置隐藏路由;URL 参数保留,不替换为具体 ID。 + +| 菜单名称 | URL | 父菜单 | 菜单权限 | +| --- | --- | --- | --- | +| 运营商账户调度参数详情 | `/polling-management/carrier-params/detail/:carrier_id` | 轮询管理 | `polling_carrier_params:detail_page` | +| 失败告警详情 | `/polling-management/failure-alerts/detail/:id` | 轮询管理 | `polling_failure_alert:detail_page` | +| 轮询开关导入详情(平台) | `/polling-management/polling-status-imports/detail/:id` | 轮询管理 | `polling_status_import:detail_page` | + +三个导出场景共用已有隐藏详情 `/asset-management/export-task-management/export-task-detail?id=任务ID`,沿用 `export_task:detail_page`,不再新增详情路由。 + +### 新增按钮权限编码 + +| 所属页面 | 按钮/能力 | 编码 | +| --- | --- | --- | +| 运营商账户调度参数 | 查看详情 | `polling_carrier_params:detail` | +| 失败告警 | 查看详情 | `polling_failure_alert:detail` | +| 失败告警 | 账户汇总 | `polling_failure_alert:summary` | +| 失败告警 | 标记已处理(仅未处理状态) | `polling_failure_alert:handle` | +| 失败告警 | 批量关闭卡轮询(平台) | `polling_failure_alert:close_polling` | +| 失败告警 | 导出停机被拒(平台) | `polling_failure_alert:export` | +| 停复机规则演练 | 导出演练明细(平台) | `stop_resume_dry_run:export` | +| 不轮询清单 | 批量开启轮询(平台) | `polling_pause:restore` | +| 不轮询清单 | 创建导出任务(平台) | `polling_pause:export` | +| 轮询开关导入 | 上传并创建任务(平台) | `polling_status_import:create` | +| 轮询开关导入 | 查看任务与逐行结果(平台) | `polling_status_import:detail` | +| IoT 卡列表 | 批量开关轮询(平台/代理) | `iot_card:batch_polling` | +| 设备列表 | 批量开关轮询(平台/代理) | `device:batch_polling` | +| 单卡资产详情 / 卡级告警详情 | 查询单卡轮询状态 | `iot_info:polling_state` | +| 设备资产详情的绑定卡 | 查询该绑定卡轮询状态 | `device_info:polling_state` | +| 导出不轮询清单 | 查看详情 / 下载 | `export_task:polling_pause_detail` / `export_task:polling_pause_download` | +| 导出停机被拒告警 | 查看详情 / 下载 | `export_task:polling_stop_refused_alert_detail` / `export_task:polling_stop_refused_alert_download` | +| 导出停复机规则演练 | 查看详情 / 下载 | `export_task:stop_resume_rule_dry_run_detail` / `export_task:stop_resume_rule_dry_run_download` | + +“详情”按钮及对应隐藏详情菜单需同时授权。告警详情内查看停复机记录复用已有 `iot_info:stop_resume_records`;资产单项轮询开关复用 `iot_info:auto_polling` / `device_info:auto_polling`,无需新编码。请求检查场景、队列监控无新增普通写按钮。系统配置沿用旧菜单与权限,仅新增 polling 模块选项。 + +### 操作边界 + +- 批量只提交当前勾选 ID,最多 500 条,不扩展成全量筛选;原因必填,先读 impact 再确认。四类逐项结果完整保留,部分成功不回滚。 +- 企业账号不开放批量;代理资产范围由后端校验。风险停机、已销户不提供“强制开启”。 +- 账户级告警 card_id=0 不请求单卡接口、不允许勾选关闭卡轮询;停机被拒视图隐藏并剔除不支持的筛选。 +- 导入仅 CSV,首行为表头,首列保留文本,最多 50000 数据行;上传成功后才创建任务。任务终态/请求失败停止自动刷新。 +- 演练仅判断不执行;明细 result 不改变汇总,导出也不带 result;演练允许每页 500,其他列表最大 100。 +- 调度、限流、指令队列开关不会因本次前端接入自动修改。旧并发配置仅 legacy 模式有效。 + +C 端同步项和接口覆盖/验证见 [本次接入交付说明](./2026-10-26-轮询账户限流-后台接入交付.md)。 + +本次验证:191 项回归通过;类型、生产构建、代码/样式/格式与 UTF-8 检查通过。仅本地 mock 验收,没有写入菜单数据、推送或部署;生产构建仍有项目既有循环依赖与大 chunk 警告。 diff --git a/openspec/changes/add-polling-config-preview-and-statistics/proposal.md b/openspec/changes/add-polling-config-preview-and-statistics/proposal.md new file mode 100644 index 0000000..59c62bc --- /dev/null +++ b/openspec/changes/add-polling-config-preview-and-statistics/proposal.md @@ -0,0 +1,45 @@ +# Change: 接入轮询配置匹配预览、调序、重算和统计 + +## Why + +`docs/轮询配置管理_API简化版.md` 新增 7 个接口。当前 `PollingConfigService` 仅有配置 CRUD、启停和启用列表,轮询配置页面没有匹配预览、优先级整体调整、异步重算及统计入口。需要按已明确的契约补齐,并延续之前菜单、权限和界面交付标准。 + +## What Changes + +- 为全部 7 个新增接口补充服务方法、请求/响应类型及契约测试。 +- 在既有「轮询配置」`/polling-management/config` 内增加匹配预览入口:支持当前配置 GET 预览,以及携带多条未保存修改的 POST 预览。支持新增草稿(id=0)、覆盖配置和模拟停用,展示卡信息、命中配置和最终任务间隔;预览不写数据。 +- 草稿表单支持资产状态、套餐状态、五项检查间隔及重置日前窗口字段,准确区分前四项间隔的 null 继承、0 禁用和正数间隔;保护期 null 不检查。新增字段仅进入文档明确的 overrides,不擅自扩展旧 CRUD 保存契约。 +- 为超级管理员接入优先级排序提交和确认后的异步重算,界面和事件双重身份检查,不创建普通用户可分配的专属操作权限。 +- 在既有配置页面提供按 task_id 查询重算进度入口;启动后可自动查询,关闭、卸载和终态清理轮询,失败保留任务 ID 与错误信息。 +- 在「轮询管理」下新增「轮询配置统计」`/polling-management/config-stats`,包含重置日前加密查流量统计、分层统计两个独立授权区域;不额外增加一级菜单或详情路由。 +- 界面沿用店铺列表及店铺详情标准;业务时间使用 `formatDateTime`;搜索展开仅在条件数 >=4 时启用;运营商账户复用按名称搜索的远程下拉。 +- 完成后在 `docs/权限.md` 与最终回复列明实际新增菜单名称、URL、父菜单、页面权限和按钮权限,仅列普通账号配置。 + +## Impact + +- Affected specs: `polling-config-extensions`(新增);仓库当前没有已归档 specs,已完成的 `add-gateway-rate-limit-management` 提供可复用账户搜索和通用交互能力,不更改它的接口与权限。 +- Affected code: `src/api/modules/pollingConfig.ts`、`src/types/api/pollingConfig.ts`、类型统一导出、新权限常量、`src/views/polling-management/config/`、新增统计页、路由别名和静态路由、`scripts/tests/`、`docs/权限.md`。 +- 保留当前工作区全部既有修改,包括网关限流、停复机时间控件、用户部署文件和 `package.json`。 +- 不执行真实配置修改、优先级提交、重算任务创建、Git 推送或线上部署;写接口只用本地 mock 验证。 + +## Contract boundaries + +- POST 匹配预览同时在 Query 与 JSON Body 传同一 ICCID;Body 包含必填 iccid,不能只传其中一处。 +- 优先级接口没有明确成功 data DTO,不读取虚构的返回列表;成功后主动刷新配置列表。 +- 重算 carrier_id 请求为 number/null,响应为 string,任务状态和计数按接口原值展示;不把重算完成误解为停复机执行或强制推后轮询。 +- 两个统计接口提供明确 DTO,按该结构展示;周期 null 显示无数据,0 保留为真实零。分层统计注明 5 分钟缓存,未匹配项突出告警;统计刷新不宣称清除后端缓存。 +- 新文档只明确 overrides 支持额外配置字段,未给出新版持久化 CRUD 契约。因此保留原保存行为,用独立草稿表单承载新增预览字段;待后端提供明确保存契约后再扩展原保存表单。 + +## Proposed ordinary-user configuration + +| 项目 | URL / 编码 | 父菜单 / 位置 | +| --- | --- | --- | +| 轮询配置统计 | `/polling-management/config-stats`,页面权限 `polling_config_stats:page` | 轮询管理 | +| 匹配预览(当前配置/草稿) | `polling_config:match_preview` | 既有轮询配置 | +| 查询重算进度 | `polling_config:recalculate_progress` | 既有轮询配置 | +| 查看重置日前统计 | `polling_config_stats:pre_reset` | 轮询配置统计 | +| 查看分层统计 | `polling_config_stats:tiers` | 轮询配置统计 | + +以上是前端配置约定,后端独立鉴权。既有轮询配置菜单和旧 CRUD 权限不因本次接入强制迁移。 + +用户于 2026-10-24 回复「执行」,并于 2026-10-26 回复「继续」,已批准按本提案实施。 diff --git a/openspec/changes/add-polling-config-preview-and-statistics/specs/polling-config-extensions/spec.md b/openspec/changes/add-polling-config-preview-and-statistics/specs/polling-config-extensions/spec.md new file mode 100644 index 0000000..fd4acde --- /dev/null +++ b/openspec/changes/add-polling-config-preview-and-statistics/specs/polling-config-extensions/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: 当前及未保存配置匹配预览 + +The frontend SHALL implement GET and POST `/api/admin/polling-configs/match-preview`. ICCID MUST be nonempty and at most 30 characters. POST MUST send the identical ICCID in query and JSON body, with optional overrides. Results MUST show the card, all matched configurations and final task intervals, preserving interval/disabled/unmatched modes, inherited and draft indicators. Preview MUST NOT call create/update/delete/status APIs. + +#### Scenario: 当前配置预览 + +- **WHEN** 获匹配预览权限的用户输入 ICCID 查询当前配置 +- **THEN** 前端 MUST 发起 GET 查询并展示 card、matched、intervals,不擅自推导匹配顺序或最终间隔。 + +#### Scenario: 草稿覆盖预览 + +- **WHEN** 用户添加 id=0 的新草稿或覆盖已有配置并提交 +- **THEN** 前端 MUST 发起 POST,Query 与 Body 的 iccid 一致,overrides 保留原有顺序、id 和 disabled +- **AND** 编辑未保存字段 MUST 不触发真实写接口,旧预览结果 MUST 不污染新的卡或草稿。 + +### Requirement: 草稿字段与检查语义 + +The draft form SHALL support every documented override field. The four ordinary task intervals MUST distinguish null/omitted inheritance, zero disable-without-inheritance and positive intervals subject to documented minima. Protect interval null MUST mean no check and non-null values MUST be integers of at least 30. Pre-reset window fields MUST use documented minima and clearly explain that both are required for effect. New preview-only fields MUST NOT be silently added to undocumented persisted CRUD bodies. + +#### Scenario: 禁用与继承不同 + +- **WHEN** 用户将实名、流量、套餐或卡状态检查设为禁用 +- **THEN** 对应值 MUST 为 0,不得变成 null 或被过滤掉 +- **AND** 继承模式 MUST 使用 null/省略值,正数模式 MUST 校验对应 30 或 60 秒下限。 + +#### Scenario: 新字段隔离 + +- **WHEN** 草稿设置资产状态、套餐状态或重置日前窗口 +- **THEN** 字段 MUST 仅进入已定义的 overrides 契约,不得因预览自动保存配置。 + +### Requirement: 优先级调整与异步重算 + +The frontend SHALL implement PUT `/api/admin/polling-configs/priorities` and POST `/api/admin/polling-configs/recalculate-due` with superadmin-only UI and event guards, explicit confirmation and duplicate-submit prevention. Ordered IDs MUST be nonempty, unique and valid; no undefined success DTO SHALL be assumed. Recalculation MUST be described as advance-only asynchronous work, not immediate completion of all card actions. + +#### Scenario: 保存优先级顺序 + +- **WHEN** 超级管理员确认有效顺序 +- **THEN** 前端 MUST 提交 ordered_ids 并在成功后重新获取列表 +- **AND** 普通账号 MUST 无法通过 UI 或处理函数发起调序或重算启动。 + +#### Scenario: 启动重算 + +- **WHEN** 超级管理员选择运营商账户或全部并确认 +- **THEN** 前端 MUST 提交 number/null carrier_id、保留 task_id 并展示真实任务状态,不在 POST 返回时直接宣称重算完成。 + +### Requirement: 任务进度查询 + +The frontend SHALL expose GET `/api/admin/polling-configs/recalculate-due/{task_id}` under a normal-user progress permission. It MUST show task ID, status, total/processed/advanced, response string carrier ID, errors and formatted timestamps. Automatic polling MUST cease on terminal status, closure, unmount or scope change, and late responses MUST be discarded. + +#### Scenario: 查询已知任务 + +- **WHEN** 获进度权限的用户输入非空 task_id +- **THEN** 前端 MUST 对路径参数编码后查询并展示实际数据 +- **AND** 查询权限 MUST 不授予创建重算任务的能力。 + +#### Scenario: 终态及关闭 + +- **WHEN** 任务 completed/failed 或对话框关闭、组件卸载 +- **THEN** 自动查询 MUST 停止,不得后台无限轮询或由旧结果覆盖新任务。 + +### Requirement: 轮询配置统计 + +The frontend SHALL provide a new config statistics page under polling management for GET `/api/admin/polling-stats/pre-reset` and GET `/api/admin/polling-stats/tiers`, gated by page and separate ordinary view permissions. It MUST preserve null versus zero, label approximate counts, highlight unmatched-card warnings and indicate the five-minute tiers cache without promising forced cache invalidation. + +#### Scenario: 重置日前统计 + +- **WHEN** 用户查看有数据或 null 的 previous/upcoming 周期 +- **THEN** 前端 MUST 展示运营商、重置时间、套餐卡数、窗口检查/未检查及接口给定的分钟桶 +- **AND** null MUST 显示无周期数据,真实 0 MUST 保留,不将桶擅自转换成未定义的差分或精确比例。 + +#### Scenario: 分层统计 + +- **WHEN** 用户打开分层统计 +- **THEN** 前端 MUST 展示 by_config、by_package_state、computed_at、total_cards 和两个未匹配告警计数 +- **AND** config_id=0 MUST 显示未匹配,不假定该配置存在。 + +### Requirement: 界面一致性与配置交付 + +The frontend SHALL reuse shop-list/detail conventions, `formatDateTime`, carrier-name remote search and search expansion only for at least four fields. Handoff MUST list the actual new menu name, URL, parent, page codes and ordinary action codes, excluding superadmin-exclusive configuration and distinguishing local mock verification from live integration. + +#### Scenario: 菜单权限配置 + +- **WHEN** 本次实现交付 +- **THEN** MUST 提供轮询管理下统计页及现有配置页新增按钮的准确配置 +- **AND** 既有 CRUD 权限 MUST 不因未声明迁移被移除,不创建新的一级菜单。 diff --git a/openspec/changes/add-polling-config-preview-and-statistics/tasks.md b/openspec/changes/add-polling-config-preview-and-statistics/tasks.md new file mode 100644 index 0000000..84b4bca --- /dev/null +++ b/openspec/changes/add-polling-config-preview-and-statistics/tasks.md @@ -0,0 +1,30 @@ +## 1. 契约与权限 + +- [x] 1.1 用户确认提案并记录审批。 +- [x] 1.2 为 7 个接口补充类型及服务,区分预览 overrides 与既有 CRUD 请求。 +- [x] 1.3 新增普通功能权限常量,复用超级管理员身份门禁与账户名称搜索。 + +## 2. 页面接入 + +- [x] 2.1 在轮询配置中接入当前配置 GET 预览和草稿 POST 预览,包含多条覆盖、停用模拟及完整结果。 +- [x] 2.2 校验 ICCID、优先级、字段长度、null/0/正数间隔语义和成对生效的重置日前窗口。 +- [x] 2.3 接入超级管理员调序界面、顺序与 ID 校验、确认和防重复提交。 +- [x] 2.4 接入超级管理员异步重算启动、普通任务进度查询、终态与生命周期清理。 +- [x] 2.5 新增轮询配置统计页,对接 pre-reset / tiers,准确展示空值、真实零、告警与缓存时间。 +- [x] 2.6 注册一个统计菜单路由,沿用店铺页面、时间格式、条件数决定展开及运营商名称搜索标准。 + +## 3. 验证与交付 + +- [x] 3.1 覆盖全部 7 个新增接口 URL、方法、Query / Body、task_id 编码和返回契约。 +- [x] 3.2 覆盖草稿语义、预览不写数据、权限、确认、防重、进度终态/卸载和迟到响应测试。 +- [x] 3.3 检查列表、预览、统计、空状态、失败重试及响应式布局。 +- [x] 3.4 运行相关和全量回归、类型检查、生产构建、针对性 lint / 格式 / 编码检查。 +- [x] 3.5 在 `docs/权限.md` 与最终回复交付普通菜单和按钮配置及真实验证范围。 + +## 验证记录(2026-10-26) + +- 新增轮询配置测试 20/20,全量 Node 回归 174/174;类型检查、生产构建、针对性 ESLint/Stylelint、格式与 UTF-8 检查通过。 +- 本地 mock 浏览器复核配置列表、当前配置预览、草稿控件、确认后重算任务与自动终态停止、两种统计以及普通账号进度入口。375px 统计/进度无页面横向溢出,1440px 预览和统计截图已复核,1024px 普通账号入口已检查。 +- 补正原配置列表检查间隔列的无效 slots 渲染,并使用唯一列 prop;原保存字段不变。 +- 所有模拟 API 在本地拦截,未进行真实后端联调、菜单数据库写入、Git 推送或生产部署。生产构建仍提示项目既有 API 循环依赖、其他页面常量绑定及大 chunk 警告。 +- 菜单与普通按钮编码已写入 `docs/权限.md`;不输出仅超级管理员专属配置。 diff --git a/openspec/changes/update-carrier-polling-scheduler-integration/design.md b/openspec/changes/update-carrier-polling-scheduler-integration/design.md new file mode 100644 index 0000000..33af94e --- /dev/null +++ b/openspec/changes/update-carrier-polling-scheduler-integration/design.md @@ -0,0 +1,41 @@ +## Context + +项目已经有网关管理、轮询配置预览与统计、单卡历史、统一导出任务、预签名 CSV 上传、运营商账户编辑和系统配置。最新文档把这些基础能力扩展为按账户调度与停复机指令队列,必须做契约升级,而不是另建同名模块。 + +## Decisions + +- 为新增调度/告警/轮询开关契约建立独立 API 和类型模块,已存在的接口在原服务扩展。分页、枚举、账户搜索、时间格式、权限门禁和异步生命周期优先复用已验证能力。 +- 仅平台账号使用现有平台身份判断(user_type=1/2),仅超级管理员使用 isSuperAdmin。每个写操作同时在入口和处理函数检查,后端继续独立鉴权;禁止用按钮权限绕过身份。 +- 详情遵循店铺详情的独立隐藏兄弟路由,普通页与详情按钮使用不同权限编码;需要数据库菜单配置的隐藏详情与导出任务菜单一并交付。单卡轮询状态通过已有资产信息弹窗/页签进入,卡 ID 来自授权资产详情,绑定卡不使用设备 ID 代替。 +- 展示 effective 与 configured 参数的区别;configured 为空并非零值。执行上限与 consume_capacity 的关系在可判断时前置校验,无法获知的停复机进程容量不凭空假设,由服务端验证。 +- 批量、告警关闭和导入共享接口影响说明及逐项结果展示。每次确认使用目标状态对应的 impact;获取失败不展示硬编码替代文案,更不绕过确认。跨筛选、视图、资产或账号变更时清理勾选和迟到结果。 +- 系统配置修改沿用现有权限,增加 polling 模块、枚举选择与 JSON 合法性提示;不因前端接入自动切换 scheduler、rate_limit_mode 或 queue_enabled。 +- CSV 文件保留标识符原始文本,客户端只做文档明确的格式/数量校验;后端仍负责逐行资产范围、风险状态和权限判定。 +- 模式与范围明确:observe 不宣称已拦截,队列前 500 条的 pending 计数标注近似范围;近似 QPS/p95 保留单位和接口定义。未知枚举保留原值。 + +## Proposed route organization + +在已有「轮询管理」`/polling-management` 下新增以下可见菜单,均非仅超级管理员专属页面;其中平台限定页面还需身份控制: + +| 菜单 | URL | 账号边界 | +| --- | --- | --- | +| 运营商账户调度参数 | `/polling-management/carrier-params` | 查看按后端权限;维护仅超级管理员 | +| 请求检查场景 | `/polling-management/check-scenes` | 查看按后端权限;维护仅超级管理员 | +| 失败告警 | `/polling-management/failure-alerts` | 查看、处理按权限;批量关闭仅平台 | +| 账户队列监控 | `/polling-management/carrier-queues` | 按后端权限 | +| 停复机规则演练 | `/polling-management/stop-resume-dry-run` | 仅平台 | +| 不轮询清单 | `/polling-management/polling-pauses` | 仅平台 | +| 轮询开关导入 | `/polling-management/polling-status-imports` | 仅平台 | + +隐藏详情拟用 `/polling-management/carrier-params/detail/:carrier_id`、`/polling-management/failure-alerts/detail/:id`、`/polling-management/polling-status-imports/detail/:id`。导出管理沿用现有 `/asset-management/export-task-management/` 下同场景命名规则,不新建一级菜单。最终以实际注册路由和权限交付为准。 + +## Risks / Trade-offs + +- 本次跨多个既有业务页,必须保留已有 dirty worktree:使用定点修改和基于当前状态的回归,禁止重置前一轮实现。 +- C 端不在本仓库,明确外部待办;不得越出命名项目搜索或修改其他工程。 +- 文档 46/16 是后端相对旧版本的统计,并非当前代码差异;以接口覆盖矩阵逐项闭环。 +- 分页读取过程中数据可能变化,不能凭前端缓存自动扩大写操作目标。导入、批量操作发生部分成功后明确不可回滚,不提供虚构的整批恢复。 + +## Validation + +覆盖所有新增/变更接口方法与 Query/Body、true/false/null/0、clear_fields、warnings、保护期删除、账号类型 1/2/3/4、五百条上限、CSV 流程、部分成功、账户级告警、特殊视图、导出场景以及异步终态/关闭/卸载。运行类型、生产构建、全量 Node 回归和针对性 lint/编码检查;浏览器使用本地 mock,明确区别于真实后端联调。 diff --git a/openspec/changes/update-carrier-polling-scheduler-integration/proposal.md b/openspec/changes/update-carrier-polling-scheduler-integration/proposal.md new file mode 100644 index 0000000..a073aaa --- /dev/null +++ b/openspec/changes/update-carrier-polling-scheduler-integration/proposal.md @@ -0,0 +1,43 @@ +# Change: 按最新运营商账户限流契约补齐调度、告警与轮询开关管理 + +## Why + +`docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md` 是前两轮简化文档之后的完整变更契约,包含 46 个新增接口与 16 个改动接口。当前前端已实现其中的网关管理和轮询配置扩展基础,但仍缺少账户调度、失败告警、演练、单卡排查、批量开关及导入清单,并存在必须升级的持久化配置和已废弃保护期字段。 + +本次以该日期文档为准做增量接入,不重复建设前两轮已有页面,不把文档中所有“新增”理解为当前项目均未实现。 + +## What Changes + +1. 轮询配置保存升级:创建/编辑/列表/详情支持 asset_status、package_state、pre_reset_window、pre_reset_carddata_interval;四项检查间隔支持 null 继承、0 禁用、正数间隔。编辑清空显式使用 clear_fields,保存成功展示 warnings;移除 protect_check_interval。更新草稿、匹配预览和拖拽优先级,保留既有预览、进度及统计权限。 +2. 保护期退场:移除后台相关输入与展示,不再请求/保存保护期间隔;兼容后端仍返回但废弃的 device_protect_status,不影响其他资产功能。 +3. 现有网关页面增量:错误分类支持 terminal 类别专属 auto_disable_polling 与测试结果;限流组展示真实 observe/enforce 模式;网关调用统计按已补全 DTO 展示账户/操作汇总、失败分类、豁免来源和趋势,不再保留“字段未定义”的临时限制。成功率按 0–1 显示,p95=-1 显示超出最大桶,不伪造精确耗时。 +4. 轮询管理新增「运营商账户调度参数」「请求检查场景」「失败告警」「账户队列监控」「停复机规则演练」「不轮询清单」「轮询开关导入」。失败告警使用普通列表、账户汇总、停机被拒三个视图;单卡轮询状态放入现有单卡及设备绑定卡入口,不新建一级菜单。 +5. 调度参数、场景修改严格仅超级管理员;规则演练、文件导入、不轮询清单、告警批量关闭及指定导出严格仅平台账号(user_type=1/2)。普通权限不得越过账号身份边界。 +6. 卡/设备列表补齐 enable_polling 筛选、列和最多 500 项的批量开关。批量提交前取 impact 文案并确认、要求原因;展示成功/跳过/失败/无权限逐项结果,部分成功不回滚。设备级联语义保持后端决定;风险停机/已销户不提供强制开启。 +7. 单资产开关改为展示接口 impact 确认文案并处理拒绝响应;订单或有效期变动后读取后端实际状态,不在前端模拟自动恢复。 +8. 导入复用预签名上传:purpose=polling_status_import、CSV 表头与首列标识符、最多 50000 行、file_key 前缀校验,提交后列表与逐行结果按真实状态显示。长 ICCID 保持字符串,不转数字;上传失败不得创建任务,异步轮询要清理。 +9. 导出复用统一任务,增加 polling_pause、polling_stop_refused_alert、stop_resume_rule_dry_run 场景、列表/详情/下载权限和必要菜单路由;卡/设备导出携带 enable_polling,客户端不重写旧历史文件。 +10. 系统配置支持 polling 模块和文档中的 string/int/bool/json 配置,以服务器返回校验规则和错误为准;轮询并发页提示仅 legacy 调度生效,并链接账户参数。运营商实名管控 0→1 改为展示后端返回的实际影响说明,确认后带 realname_control_confirm 重提,不先擅自确认。 +11. 延续 UI 标准:店铺列表和详情组件、统一 formatDateTime、运营商账户名称搜索、日期范围按临期资产样式、搜索条件 >=4 才显示展开。普通账号交付清单只列实际新增/变更菜单、URL、父菜单及 xxx:xxx 权限;保留旧配置、不列仅超级管理员专属项。 + +## Impact + +- Affected specs: `carrier-polling-scheduler-integration`(新增综合接入规范)。当前无已归档 specs;前两轮 `add-gateway-rate-limit-management`、`add-polling-config-preview-and-statistics` 已完成,不更改其既有路由 URL 与权限编码。 +- Affected code: pollingConfig / gateway / asset / card / device / carrier / storage / exportTask / systemConfig 的 API、类型与界面,相关路由、权限、上传组件、导出场景配置及测试。 +- 不执行后端限流调度算法或数据迁移,不在真实环境触发批量操作、导入、调序、重算、配置修改、Git 推送或生产部署。测试写操作只使用本地 mock。 +- 保留工作区已有未提交的轮询配置实现与其他用户修改。 + +## Scope boundaries and contract risks + +- 文档要求的 C 端资产详情不在当前后台仓库:检索未发现独立 C 端工程。本次只落实本仓库可定位的后台部分,C 端保护期下线在交付中明确列为外部同步项,不声称已经修改。 +- 本文 46 个新增接口中,前两轮已经建立 24 个方法,预计补齐剩余 22 个;实施前按路径与方法逐项核对,不以数量替代验收。附录已给出多数新 DTO,旧接口的完整 OpenAPI 未随本文提供时,不猜测未定义字段或清空规则。 +- 网关统计内部字段已经补全,覆盖前一提案的“未知字段动态展示”临时约定。轮询配置新持久化字段已经明确,覆盖上一提案“新增字段仅用于预览”的暂定边界。旧说明与回归测试需同步升级。 +- warnings 未定义固定结构时以实际字符串/对象安全展示,不假设固定业务键;clear_fields 仅使用文档支持的可空字段,不任意清除配置。 +- 演练 page_size 特例最大 500,其余文档分页最大 100;演练结果筛选只影响明细不影响汇总,导出接口没有 result 参数时不擅自加入。假设实名管控 on/off 账户集合不得重叠。 +- 停机被拒视图仅发送文档支持的五类筛选;切换视图清除忽略项及旧勾选。账户级告警 card_id=0 不得当作有效单卡 ID 请求历史或轮询状态。 + +## Delivery + +先实现配置/保护期和公共契约,再接调度与排查,随后接批量/导入/导出,最后完成权限矩阵、mock 浏览器和回归验证。普通菜单与按钮配置写入 `docs/权限.md` 并在最终回复给出增量清单;现有菜单和权限不重复要求用户新增。 + +用户于 2026-10-26 批准执行,并明确仅修改后台前端;交付时另列 C 端同步事项,不修改 C 端代码。 diff --git a/openspec/changes/update-carrier-polling-scheduler-integration/specs/carrier-polling-scheduler-integration/spec.md b/openspec/changes/update-carrier-polling-scheduler-integration/specs/carrier-polling-scheduler-integration/spec.md new file mode 100644 index 0000000..785e32e --- /dev/null +++ b/openspec/changes/update-carrier-polling-scheduler-integration/specs/carrier-polling-scheduler-integration/spec.md @@ -0,0 +1,98 @@ +## ADDED Requirements + +### Requirement: 最新轮询配置保存契约 + +The admin frontend SHALL align create/update/list/detail and preview with the latest configuration fields, including asset/package matching, pre-reset window, four tri-state intervals, clear_fields and save warnings. It MUST remove protect_check_interval and must not confuse zero with null or silently discard successful-save warnings. Existing routes and permissions MUST be preserved. + +#### Scenario: 保存及清空 + +- **WHEN** 用户保存新增条件、禁用间隔或清空可空字段 +- **THEN** 前端 MUST 使用明确的更新字段和 clear_fields,保留 0、false,展示实际 warnings +- **AND** 不得继续发送保护期间隔,预览必须与同一次编辑值一致。 + +### Requirement: 保护期移除与兼容 + +The frontend SHALL remove deprecated protection-period input and UI in this repository while tolerating device_protect_status in old responses. It MUST disclose that the independently deployed C-end code is outside this repository rather than claim to modify it. + +#### Scenario: 旧响应兼容 + +- **WHEN** 后台详情仍返回固定 none 的保护期字段 +- **THEN** 页面 MUST 不显示保护期,不因废弃字段缺失影响资产查询或其他功能。 + +### Requirement: 账户调度、场景与状态排查 + +The frontend SHALL provide carrier scheduling parameter list/detail, request-check scenes, carrier queue monitoring and single-card polling state with the documented effective/configured values, queue states, confirmations and stop/resume commands. Superadmin-only writes MUST be guarded both in UI and handlers. Null defaults and real zeros MUST remain distinct. + +#### Scenario: 调度参数维护 + +- **WHEN** 超级管理员修改参数或恢复默认 +- **THEN** 前端 MUST 校验可确定的容量、比例和递增延迟约束,提交对应接口并展示服务端拒绝原因 +- **AND** 普通按钮权限 MUST 不能启用专属写操作。 + +#### Scenario: 单卡状态排查 + +- **WHEN** 获授权用户从单卡或设备绑定卡查看状态 +- **THEN** 前端 MUST 使用真实 card_id 请求 polling-state,展示各检查类型及 stop_resume_command +- **AND** 账户级告警 card_id=0 MUST 不得作为有效卡身份进入该入口。 + +### Requirement: 失败告警与停机被拒视图 + +The frontend SHALL provide ordinary, carrier-summary and stop-refused alert views, details, handled status and platform-only close-polling actions. Only unresolved alerts may be marked handled. The special view MUST hide and omit check_type/error_category/alert_level. Bulk results MUST distinguish success, skipped, failed and unauthorized/nonexistent without suggesting rollback. + +#### Scenario: 切换特殊视图 + +- **WHEN** 用户切换到停机被拒 +- **THEN** 请求 MUST 只带该视图支持的筛选,清除旧选择,展示额外设备/停机/套餐/轮询字段 +- **AND** 导出 MUST 使用独立场景与对应权限。 + +#### Scenario: 批量关闭 + +- **WHEN** 有权限的平台账号确认关闭告警对应卡的轮询 +- **THEN** 前端 MUST 提交合法 alert_ids 和原因,逐项展示结果并刷新 +- **AND** 不能把账户级告警当设备级操作,不能自动重试整批已部分成功的请求。 + +### Requirement: 平台规则演练与实名确认 + +The frontend SHALL provide platform-only dry-run summary, paginated details, hypothetical carrier controls and export without executing gateway actions. Hypothetical on/off carrier sets MUST not overlap. Real carrier control changes from off to on MUST display the backend impact message and only resubmit with realname_control_confirm after explicit confirmation. + +#### Scenario: 只读演练 + +- **WHEN** 用户运行演练或按结果筛选明细 +- **THEN** 前端 MUST 仅请求 dry-run,汇总保留后端定义的范围,不调用真实停复机或修改管控接口 +- **AND** 明细 result 筛选不得被描述为已改变汇总。 + +### Requirement: 轮询开关、清单与 CSV 导入 + +The frontend SHALL support card/device polling filters, batch toggles, platform pause-history and CSV import workflows. It MUST load impact descriptions from the API before confirmation, require reasons, enforce unique 1–500 asset IDs, preserve false filters, maintain string identifiers and show all item outcomes. Enterprise accounts MUST not invoke batch toggles. Upload purpose and returned key prefix MUST match the documented polling-status-import contract; file limits and async cleanup MUST be enforced. + +#### Scenario: 确认并批量开关 + +- **WHEN** 有权限账号选择资产开启或关闭 +- **THEN** 前端 MUST 展示对应 impact 后确认、提交、展示部分成功结果 +- **AND** 风险停机或已销户卡不得获得强制开启选项,403 必须回滚前端开关并保留原状态。 + +#### Scenario: 导入任务 + +- **WHEN** 平台账号上传带表头的合法 CSV 并确认 +- **THEN** 前端 MUST 先获取预签名、上传成功后再提交任务,保留 ICCID 原文,展示任务与逐行错误 +- **AND** 上传失败不得创建任务,终态、关闭和卸载 MUST 停止自动查询。 + +### Requirement: 系统模式与监控指标 + +The frontend SHALL add the polling system-config module, support documented JSON editing, display actual rate-limit/scheduler mode, and explain legacy-only concurrency behavior. Gateway monitoring SHALL use the now-defined operation DTOs, show success rates in the correct scale, identify approximate p95/queue-sample limits and not invent missing metrics. + +#### Scenario: 观察模式 + +- **WHEN** 接口返回 observe +- **THEN** 页面 MUST 说明只统计不拦截,区分 would_limit 与真正 rate_limited +- **AND** 不得因前端接入自动修改生产调度或限流开关。 + +### Requirement: 导出、路由与权限交付 + +The frontend SHALL integrate the three new export scenes and preserve enable_polling filters for existing card/device exports. New pages, hidden details and export-task routes MUST follow existing menu conventions. Final handoff MUST list actual incremental menu names, URLs, parents and ordinary xxx:xxx button codes, excluding superadmin-exclusive configuration and preserving previous menu codes. + +#### Scenario: 配置交付 + +- **WHEN** 实现完成 +- **THEN** 前端 MUST 同步 docs/权限.md 和最终回复中的增量菜单与按钮清单 +- **AND** MUST 区分平台身份限制、普通按钮权限、真实后端验证与本地 mock,以及本仓库外的 C 端同步事项。 diff --git a/openspec/changes/update-carrier-polling-scheduler-integration/tasks.md b/openspec/changes/update-carrier-polling-scheduler-integration/tasks.md new file mode 100644 index 0000000..3820631 --- /dev/null +++ b/openspec/changes/update-carrier-polling-scheduler-integration/tasks.md @@ -0,0 +1,38 @@ +## 1. 契约与基线 + +- [x] 1.1 用户批准提案,记录当前 dirty worktree 和前两轮回归基线。 +- [x] 1.2 建立文档接口到现有/新增方法、页面、权限及测试的覆盖矩阵,核对 46 新增与 16 改动的实际差异。 +- [x] 1.3 新增账户调度、场景、告警、队列、单卡状态、批量开关、导入/清单、演练及 impact 类型和服务。 + +## 2. 既有页面升级 + +- [x] 2.1 升级轮询配置持久化 DTO、表单、列表、clear_fields、warnings、三态间隔、保存前预览和优先级拖拽。 +- [x] 2.2 清除本仓库保护期输入/展示与请求字段,保留后端废弃响应兼容;记录外部 C 端同步项。 +- [x] 2.3 增加 terminal auto_disable_polling,升级错误分类列表/表单/测试。 +- [x] 2.4 依完整 DTO 改造网关汇总、趋势和 observe/enforce 模式展示。 +- [x] 2.5 增加 polling 系统模块与 JSON 编辑校验、旧并发页说明;运营商实名管控改用服务端影响说明二次确认。 + +## 3. 调度、排查与演练 + +- [x] 3.1 完成账户调度参数列表/详情/超级管理员维护和容量边界。 +- [x] 3.2 完成请求检查场景列表及超级管理员维护、严格递增确认延迟校验。 +- [x] 3.3 完成账户队列和停复机指令监控、初始化状态、真实指标及异步生命周期。 +- [x] 3.4 在单卡及设备绑定卡接入轮询状态与停复机命令,校验真实卡 ID 和账号权限。 +- [x] 3.5 完成失败告警普通/汇总/停机被拒视图、详情、处理、记录联动、平台批量关闭。 +- [x] 3.6 完成平台规则演练、假设管控条件、汇总/分页明细、只读保障与导出。 + +## 4. 开关、清单与文件任务 + +- [x] 4.1 卡/设备列表新增轮询筛选和列,导出保留 false 筛选,接入批量开关原因、影响确认和逐项结果。 +- [x] 4.2 单资产开关使用 impact 文案,处理风险/已销户拒绝,刷新后端实际状态。 +- [x] 4.3 平台不轮询清单支持全部筛选、历史/系统操作者、批量开启及导出。 +- [x] 4.4 平台 CSV 导入支持预签名、purpose、file_key、行数校验、创建和任务列表/逐行详情。 +- [x] 4.5 接入三个导出场景及任务列表、详情、下载权限,维持历史文件兼容。 + +## 5. 验证与交付 + +- [x] 5.1 注册新可见/隐藏路由和权限,保留前两轮 URL、时间/账户搜索/展开标准。 +- [x] 5.2 完成接口覆盖矩阵测试、权限与部分成功/视图切换/上传/轮询清理回归。 +- [x] 5.3 验证类型、构建、全量测试、lint、格式、编码和本地 mock 浏览器交互/响应式。 +- [x] 5.4 同步旧文档和 `docs/权限.md`,交付新增菜单名称/URL/父菜单、普通按钮编码;排除仅超级管理员专属项。 +- [x] 5.5 明确真实联调范围、尚缺后端契约和外部 C 端同步项,不将 mock 结果当作线上验证。 diff --git a/scripts/tests/carrierRealnameControl.test.mjs b/scripts/tests/carrierRealnameControl.test.mjs index 315589f..bbf2816 100644 --- a/scripts/tests/carrierRealnameControl.test.mjs +++ b/scripts/tests/carrierRealnameControl.test.mjs @@ -56,6 +56,8 @@ const carrier = (enabled) => ({ function harness(userType, responseCarrier = carrier(1)) { const writes = [] + const attempts = [] + let list const confirmations = [] const user = vue.reactive({ info: { user_type: userType }, permissions: [], buttons: [] }) const permission = evaluate(read('src/composables/usePermission.ts'), { @@ -66,7 +68,17 @@ function harness(userType, responseCarrier = carrier(1)) { getCarriers: async () => ({ code: 0, data: { items: [responseCarrier], total: 1 } }), getCarrierDetail: async () => ({ code: 0, data: responseCarrier }), createCarrier: async (data) => writes.push({ method: 'create', data: { ...data } }), - updateCarrier: async (id, data) => writes.push({ method: 'update', id, data: { ...data } }) + updateCarrier: async (id, data) => { + attempts.push({ id, data: { ...data } }) + if ( + list?.originalRealnameControlEnabled.value === 0 && + data.realname_control_enabled === 1 && + !data.realname_control_confirm + ) + throw new Error('开启实名管控将停机 12 张卡,请确认后重试') + writes.push({ method: 'update', id, data: { ...data } }) + return { code: 0, data: responseCarrier } + } } const runtimeVue = { ...vue, onMounted: () => {} } const imports = { @@ -75,7 +87,7 @@ function harness(userType, responseCarrier = carrier(1)) { '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }, '@/api/modules': { CarrierService: service }, 'element-plus': { - ElMessage: { success() {}, warning() {} }, + ElMessage: { success() {}, warning() {}, error() {} }, ElMessageBox: { confirm: async (...args) => { confirmations.push(args) @@ -102,8 +114,10 @@ function harness(userType, responseCarrier = carrier(1)) { } const setup = (name) => evaluate(components[name].script, imports, runtimeVue).default.setup({}, { expose() {} }) + list = setup('index') return { - list: setup('index'), + list, + attempts, detail: setup('detail'), writes, confirmations, @@ -189,8 +203,8 @@ test('editing then creating resets control state; default creation sends numeric }) for (const userType of [1, 2]) { - test(`user_type=${userType}: enabling control requires confirmation before PUT and sends true`, async () => { - const { list, writes, confirmations, messageBox } = harness(userType) + test(`user_type=${userType}: initial PUT obtains actual impact before confirmed retry`, async () => { + const { list, writes, confirmations, messageBox, attempts } = harness(userType) list.showDialog('edit', carrier(0)) list.form.realname_control_enabled = 1 let confirm @@ -201,8 +215,11 @@ for (const userType of [1, 2]) { }) } const pending = list.handleSubmit(validForm) + await new Promise((resolve) => setImmediate(resolve)) assert.equal(confirmations.length, 1) - assert.match(confirmations[0][0], /未实名的开机卡会在下一次评估时被停机/) + assert.equal(confirmations[0][0], '开启实名管控将停机 12 张卡,请确认后重试') + assert.equal(attempts.length, 1) + assert.equal(attempts[0].data.realname_control_confirm, undefined) assert.deepEqual(writes, []) assert.equal(list.submitLoading.value, true) await list.handleSubmit(validForm) @@ -275,7 +292,7 @@ test('opening another carrier resets the original control state after a cancelle assert.equal(Object.hasOwn(writes[0].data, 'realname_control_confirm'), false) }) -test('a failed PUT requires a fresh confirmation on retry', async (t) => { +test('an unrelated PUT error never triggers confirmation or automatic retry', async (t) => { const { list, writes, confirmations, service } = harness(2) t.mock.method(console, 'error', () => {}) list.showDialog('edit', carrier(0)) @@ -285,13 +302,13 @@ test('a failed PUT requires a fresh confirmation on retry', async (t) => { throw new Error('PUT failed') } await list.handleSubmit(validForm) - assert.equal(confirmations.length, 1) + assert.equal(confirmations.length, 0) assert.deepEqual(writes, []) assert.equal(list.dialogVisible.value, true) assert.equal(list.submitLoading.value, false) service.updateCarrier = updateCarrier await list.handleSubmit(validForm) - assert.equal(confirmations.length, 2) + assert.equal(confirmations.length, 1) assert.equal(writes[0].data.realname_control_confirm, true) }) @@ -353,6 +370,9 @@ test('carrier endpoints preserve the field in list/detail responses and create/u calls.push({ method: 'PUT', url, data }) return Promise.resolve({ code: 0, data }) } + static put(url, data) { + return this.update(url, data) + } } const { CarrierService } = evaluate(read('src/api/modules/carrier.ts'), { '../BaseService': { BaseService } diff --git a/scripts/tests/fixtures/polling-status-import.csv b/scripts/tests/fixtures/polling-status-import.csv new file mode 100644 index 0000000..a8f56c7 --- /dev/null +++ b/scripts/tests/fixtures/polling-status-import.csv @@ -0,0 +1,5 @@ +iccid,note +89860426012345678901,local mock +89860426012345678902,skip +89860426012345678903,fail +89860426012345678904,denied diff --git a/scripts/tests/fixtures/pollingSchedulerEndpoints.json b/scripts/tests/fixtures/pollingSchedulerEndpoints.json new file mode 100644 index 0000000..efdd6f0 --- /dev/null +++ b/scripts/tests/fixtures/pollingSchedulerEndpoints.json @@ -0,0 +1,48 @@ +[ + "DELETE /gateway-error-classifications/{id}", + "DELETE /polling-carrier-params/{carrier_id}", + "DELETE /polling-rate-limit-groups/{id}", + "GET /card-polling-pauses", + "GET /card-stop-resume-records", + "GET /gateway-error-classifications", + "GET /iot-cards/{id}/polling-state", + "GET /iot-cards/{id}/stop-resume-records", + "GET /polling-carrier-params", + "GET /polling-carrier-params/{carrier_id}", + "GET /polling-check-scenes", + "GET /polling-configs/match-preview", + "GET /polling-configs/recalculate-due/{task_id}", + "GET /polling-failure-alerts", + "GET /polling-failure-alerts/summary", + "GET /polling-failure-alerts/{id}", + "GET /polling-options", + "GET /polling-rate-limit-groups", + "GET /polling-rate-limit-groups/{id}", + "GET /polling-stats/carrier-queues", + "GET /polling-stats/carriers", + "GET /polling-stats/carriers/{carrier_id}/trend", + "GET /polling-stats/pre-reset", + "GET /polling-stats/tiers", + "GET /polling-status-imports", + "GET /polling-status-imports/{id}", + "GET /polling-status/impact", + "GET /stop-resume-rule/dry-run", + "POST /devices/batch-update-polling-status", + "POST /gateway-error-classifications", + "POST /gateway-error-classifications/test", + "POST /iot-cards/batch-update-polling-status", + "POST /polling-configs/match-preview", + "POST /polling-configs/recalculate-due", + "POST /polling-failure-alerts/close-polling", + "POST /polling-rate-limit-groups", + "POST /polling-status-imports", + "POST /stop-resume-rule/dry-run/export", + "PUT /gateway-error-classifications/{id}", + "PUT /gateway-error-classifications/{id}/status", + "PUT /polling-carrier-params/{carrier_id}", + "PUT /polling-check-scenes/{scene}", + "PUT /polling-configs/priorities", + "PUT /polling-failure-alerts/{id}/status", + "PUT /polling-rate-limit-groups/{id}", + "PUT /polling-rate-limit-groups/{id}/status" +] diff --git a/scripts/tests/gateway.test.mjs b/scripts/tests/gateway.test.mjs index 8cd3ba4..f5ff8c6 100644 --- a/scripts/tests/gateway.test.mjs +++ b/scripts/tests/gateway.test.mjs @@ -325,6 +325,7 @@ function mountComponent( '@/components/business/gateway/GatewayOptionsNotice.vue': { default: {} }, '@/components/business/gateway/GatewayRuleFields.vue': { default: {} }, '@/components/business/gateway/GatewayDynamicData.vue': { default: {} }, + '@/components/business/gateway/GatewayOperationTable.vue': { default: {} }, './GatewayOptionsNotice.vue': { default: {} } } imports['@/composables/useGateway'] = evaluate(source('src/composables/useGateway.ts'), imports) diff --git a/scripts/tests/pollingConfigBrowserFixtures.mjs b/scripts/tests/pollingConfigBrowserFixtures.mjs new file mode 100644 index 0000000..0e79ffc --- /dev/null +++ b/scripts/tests/pollingConfigBrowserFixtures.mjs @@ -0,0 +1,277 @@ +import { mockApiPlugin as gatewayFixtures } from './gatewayBrowserFixtures.mjs' + +const time = '2026-10-26T10:00:00+08:00' +const configs = [1, 2, 3].map((id) => ({ + id, + config_name: `模拟配置 ${id}`, + priority: id, + description: '本地模拟,不代表真实配置', + status: id === 3 ? 0 : 1, + carrier_id: 8, + carrier_name: '模拟运营商账户', + card_category: 'normal', + card_condition: 'activated', + asset_status: 2, + package_state: 'active', + realname_check_interval: null, + carddata_check_interval: 300, + package_check_interval: 600, + card_status_check_interval: 0, + pre_reset_window: 3600, + pre_reset_carddata_interval: 60, + created_at: time, + updated_at: time +})) +const tasks = new Map() +const page = (items, url) => { + const current = Number(url.searchParams.get('page') || 1) + const size = Number(url.searchParams.get('page_size') || 20) + return { + items: items.slice((current - 1) * size, current * size), + page: current, + size, + page_size: size, + total: items.length, + total_pages: Math.ceil(items.length / size) + } +} + +// 本地测试专用:所有 /api 都被当前或 gateway fixture 消费,无真实后端代理。 +export const mockApiPlugin = { + name: 'polling-config-local-mock-api', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url, 'http://127.0.0.1') + const path = url.pathname + const auth = ['/api/auth/me', '/api/auth/login', '/api/auth/logout'].includes(path) + if ( + !auth && + !path.startsWith('/api/admin/polling-configs') && + !['/api/admin/polling-stats/pre-reset', '/api/admin/polling-stats/tiers'].includes(path) + ) + return next() + let body = '' + for await (const chunk of req) body += chunk + const input = body ? JSON.parse(body) : {} + const viewer = req.headers.authorization?.includes('local-polling-viewer') + const denied = req.headers.authorization?.includes('local-polling-denied') + const admin = !viewer && !denied + let data = {} + let code = 0 + let msg = 'local mock only' + if (auth) { + const menus = [ + { url: '/polling-management/config', name: '轮询配置' }, + { + url: '/polling-management/config-stats', + name: '轮询配置统计', + perm_code: 'polling_config_stats:page' + } + ] + data = { + access_token: viewer + ? 'local-polling-viewer' + : denied + ? 'local-polling-denied' + : 'local-polling-admin', + refresh_token: '', + expires_in: 3600, + user: { + id: admin ? 1 : 2, + user_type: admin ? 1 : 2, + username: admin ? '本地模拟管理员' : '本地普通账号', + roles: [admin ? 'R_SUPER' : 'R_ADMIN'] + }, + permissions: denied ? [] : ['polling_config_stats:page'], + buttons: denied + ? [] + : [ + 'polling_config:match_preview', + 'polling_config:recalculate_progress', + 'polling_config_stats:pre_reset', + 'polling_config_stats:tiers' + ], + menus: [{ url: '/polling-management', name: '轮询管理', children: menus }] + } + } else if (path === '/api/admin/polling-configs/match-preview') { + const iccid = url.searchParams.get('iccid') + if (!iccid || (req.method === 'POST' && input.iccid !== iccid)) { + code = 1001 + msg = 'Query 和 Body 的 ICCID 必须一致' + } else + data = { + card: { + iccid, + card_id: 42, + carrier_id: 8, + enable_polling: true, + asset_status: 2, + card_category: 'normal', + card_condition: 'activated', + package_state: 'active', + package_state_name: '有生效套餐' + }, + matched: + req.method === 'POST' && input.overrides?.length + ? input.overrides + .filter((item) => !item.disabled) + .map((item) => ({ + id: item.id, + config_name: item.config_name, + priority: item.priority, + draft: true + })) + : [{ id: 1, config_name: configs[0].config_name, priority: 1, draft: false }], + intervals: [ + { + config_id: 1, + config_name: configs[0].config_name, + inherited: false, + interval: 300, + mode: 'interval', + task_type: 'polling:carddata', + task_type_name: '流量' + }, + { + config_id: 1, + config_name: configs[0].config_name, + inherited: true, + interval: 0, + mode: 'disabled', + task_type: 'polling:card_status', + task_type_name: '卡状态' + }, + { + config_id: 0, + config_name: '', + inherited: false, + interval: null, + mode: 'unmatched', + task_type: 'polling:realname', + task_type_name: '实名' + } + ] + } + } else if (path === '/api/admin/polling-configs/priorities') { + if (!admin) { + code = 403 + msg = '仅管理员可操作' + } else { + input.ordered_ids.forEach((id, index) => { + const row = configs.find((item) => item.id === id) + if (row) row.priority = index + 1 + }) + data = null + } + } else if (path === '/api/admin/polling-configs/recalculate-due' && req.method === 'POST') { + if (!admin) { + code = 403 + msg = '仅管理员可操作' + } else { + const id = `local-task-${tasks.size + 1}` + data = { + task_id: id, + status: 'pending', + carrier_id: input.carrier_id == null ? '' : String(input.carrier_id), + total: 100, + processed: 0, + advanced: 0, + error: '', + created_at: time, + started_at: '', + finished_at: '' + } + tasks.set(id, { data, polls: 0 }) + } + } else if (path.startsWith('/api/admin/polling-configs/recalculate-due/')) { + const id = decodeURIComponent( + path.slice('/api/admin/polling-configs/recalculate-due/'.length) + ) + if (!tasks.has(id)) + tasks.set(id, { + data: { + task_id: id, + carrier_id: '8', + total: 100, + processed: 0, + advanced: 0, + error: '', + created_at: time, + started_at: '', + finished_at: '' + }, + polls: 0 + }) + const entry = tasks.get(id) + entry.polls++ + data = { + ...entry.data, + status: entry.polls > 2 ? 'completed' : 'running', + processed: Math.min(100, entry.polls * 40), + advanced: entry.polls * 10, + started_at: time, + finished_at: entry.polls > 2 ? time : '' + } + } else if (path === '/api/admin/polling-stats/pre-reset') { + data = { + carriers: [ + { + carrier_id: 8, + carrier_name: '模拟运营商账户', + previous: { + active_package_cards: 100, + checked_in_window: 80, + not_checked_in_window: 20, + reset_at: time, + last_offset_buckets: { le_5: 0, le_15: 20, le_30: 15, le_60: 25, gt_60: 10 } + }, + upcoming: null + } + ] + } + } else if (path === '/api/admin/polling-stats/tiers') { + data = { + by_config: [ + { config_id: 1, config_name: configs[0].config_name, cards: 95 }, + { config_id: 0, config_name: '', cards: 5 } + ], + by_package_state: { active: 80, depleted: 10, pending: 5, none: 3, unknown: 2 }, + computed_at: time, + total_cards: 100, + unmatched_in_stock: 2, + unmatched_sold_no_package: 3 + } + } else if (path === '/api/admin/polling-configs' && req.method === 'GET') { + const query = url.searchParams.get('config_name') || '' + data = page( + configs.filter((item) => item.config_name.includes(query)), + url + ) + } else if (path === '/api/admin/polling-configs' && req.method === 'POST') { + data = { + ...input, + id: configs.length + 1, + status: 1, + created_at: time, + updated_at: time, + warnings: ['本地模拟:保存提示'] + } + configs.push(data) + } else if (/^\/api\/admin\/polling-configs\/\d+$/.test(path) && req.method === 'PUT') { + const row = configs.find((item) => item.id === Number(path.split('/').at(-1))) + Object.assign(row, input) + for (const key of input.clear_fields || []) row[key] = null + delete row.clear_fields + data = { ...row, warnings: ['本地模拟:保存提示'] } + } else if (/^\/api\/admin\/polling-configs\/\d+$/.test(path) && req.method === 'GET') + data = configs.find((item) => item.id === Number(path.split('/').at(-1))) + else { + code = 400 + msg = '此本地验收接口未定义,已拦截写请求' + } + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify({ code, msg, data })) + }) + gatewayFixtures.configureServer(server) + } +} diff --git a/scripts/tests/pollingConfigExtensions.test.mjs b/scripts/tests/pollingConfigExtensions.test.mjs new file mode 100644 index 0000000..35932ea --- /dev/null +++ b/scripts/tests/pollingConfigExtensions.test.mjs @@ -0,0 +1,737 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import { compileScript, parse } from '@vue/compiler-sfc' + +const source = (path) => readFileSync(path, 'utf8') +function evaluate(text, imports = {}, globals = {}) { + const compiled = ts.transpileModule(text, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + const module = { exports: {} } + new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)( + (name) => { + assert.ok(imports[name], `Unexpected dependency: ${name}`) + return imports[name] + }, + module, + module.exports, + ...Object.values(globals) + ) + return module.exports +} +const format = evaluate(source('src/utils/business/format.ts')) +const gateway = evaluate(source('src/utils/business/gateway.ts'), { './format': format }) +const constants = evaluate(source('src/config/constants/pollingConfig.ts')) +const helpers = evaluate(source('src/utils/business/pollingConfig.ts'), { + '@/config/constants/pollingConfig': constants, + './gateway': gateway +}) +const perms = constants.POLLING_CONFIG_PERMISSIONS +const ok = (data) => ({ code: 0, data, msg: 'ok' }) +const page = (items) => ok({ items, page: 1, page_size: 20, total: items.length }) +const draft = () => ({ ...helpers.newPollingDraft(), config_name: '测试草稿' }) +const task = (id = 'task-1', status = 'running') => ({ + task_id: id, + status, + total: 100, + processed: 20, + advanced: 10, + carrier_id: '8', + error: '', + created_at: '2026-10-26T10:00:00+08:00', + started_at: '', + finished_at: '' +}) +const preview = { + card: { iccid: 'CARD', asset_status: 2, package_state: 'active' }, + matched: [], + intervals: [{ task_type: 'polling:carddata', mode: 'disabled', interval: 0 }] +} +const preReset = { + carriers: [ + { + carrier_id: 8, + carrier_name: '账户', + previous: { + active_package_cards: 0, + checked_in_window: 0, + not_checked_in_window: 0, + reset_at: '2026-10-26T00:00:00+08:00', + last_offset_buckets: { le_5: 0, le_15: 0, le_30: 0, le_60: 0, gt_60: 0 } + }, + upcoming: null + } + ] +} +const tiers = { + total_cards: 0, + by_config: [{ config_id: 0, config_name: '', cards: 0 }], + by_package_state: { active: 0, unknown: 0 }, + computed_at: '2026-10-26T00:00:00Z', + unmatched_in_stock: 0, + unmatched_sold_no_package: 0 +} +function deferred() { + let resolve + let reject + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} +async function flush() { + await new Promise((done) => setImmediate(done)) + await vue.nextTick() +} + +test('all seven new API methods use exact URLs, methods, query and body contracts', async () => { + const calls = [] + const response = ok(preview) + const http = Object.fromEntries( + ['get', 'post', 'put', 'del'].map((method) => [ + method, + (args) => { + calls.push({ method, ...args }) + return Promise.resolve(response) + } + ]) + ) + const base = evaluate(source('src/api/BaseService.ts'), { '@/utils/http': { default: http } }) + const { PollingConfigService: service } = evaluate(source('src/api/modules/pollingConfig.ts'), { + '../BaseService': base + }) + const body = { iccid: 'CARD/1', overrides: [draft()] } + const specs = [ + [ + 'previewMatch', + ['CARD/1'], + { + method: 'get', + url: '/api/admin/polling-configs/match-preview', + params: { iccid: 'CARD/1' } + } + ], + [ + 'previewDraft', + [body], + { + method: 'post', + url: '/api/admin/polling-configs/match-preview', + params: { iccid: body.iccid }, + data: body + } + ], + [ + 'updatePriorities', + [[12, 3, 8]], + { + method: 'put', + url: '/api/admin/polling-configs/priorities', + data: { ordered_ids: [12, 3, 8] } + } + ], + [ + 'recalculateDue', + [null], + { + method: 'post', + url: '/api/admin/polling-configs/recalculate-due', + data: { carrier_id: null } + } + ], + [ + 'getRecalculateTask', + ['task/a b'], + { + method: 'get', + url: '/api/admin/polling-configs/recalculate-due/task%2Fa%20b', + params: undefined + } + ], + [ + 'getPreResetStats', + [], + { method: 'get', url: '/api/admin/polling-stats/pre-reset', params: undefined } + ], + [ + 'getTierStats', + [], + { method: 'get', url: '/api/admin/polling-stats/tiers', params: undefined } + ] + ] + for (const [name, args, expected] of specs) { + assert.equal(await service[name](...args), response) + assert.deepEqual(calls.at(-1), expected) + } +}) + +test('drafts preserve null inheritance, zero disable, numeric intervals and body whitelist', () => { + const item = { + ...draft(), + carrier_id: 0, + realname_check_interval: null, + carddata_check_interval: 0, + package_check_interval: 60, + status: 0, + key: 'ui', + created_at: 'unexpected' + } + const [body] = helpers.pollingDraftBody([item]) + assert.equal(body.carrier_id, 0) + assert.equal(body.realname_check_interval, null) + assert.equal(body.carddata_check_interval, 0) + assert.equal(body.package_check_interval, 60) + for (const key of ['status', 'key', 'created_at']) assert.equal(key in body, false) + assert.equal(item.status, 0) + assert.equal(helpers.pollingDraftBody([draft(), draft()]).length, 2) + assert.throws(() => + helpers.pollingDraftBody([ + { ...draft(), id: 1 }, + { ...draft(), id: 1 } + ]) + ) +}) + +test('draft validation enforces documented interval minima, priority and paired windows', () => { + for (const patch of [ + { config_name: ' ' }, + { config_name: 'a'.repeat(101) }, + { description: 'x'.repeat(501) }, + { priority: 0 }, + { priority: 1001 }, + { priority: 1.2 }, + { asset_status: 5 }, + { carrier_id: -1 }, + { realname_check_interval: 29 }, + { carddata_check_interval: 59 }, + { package_check_interval: -1 }, + { card_status_check_interval: 0.1 }, + { pre_reset_window: 60 }, + { pre_reset_window: 59, pre_reset_carddata_interval: 60 } + ]) + assert.throws(() => helpers.pollingDraftBody([{ ...draft(), ...patch }])) + assert.doesNotThrow(() => + helpers.pollingDraftBody([ + { + ...draft(), + protect_check_interval: 30, + pre_reset_window: 60, + pre_reset_carddata_interval: 60 + } + ]) + ) + assert.equal(helpers.pollingIccid(' CARD '), 'CARD') + assert.throws(() => helpers.pollingIccid('')) + assert.throws(() => helpers.pollingIccid('x'.repeat(31))) + for (const ids of [[], [1, 1], [-1], [0], [1.5]]) + assert.throws(() => helpers.validatePollingOrder(ids)) + assert.doesNotThrow(() => helpers.validatePollingOrder([3, 2, 1])) +}) + +function mount( + t, + path, + { superAdmin = false, permissions = [], overrides = {}, props = {}, globals = {} } = {} +) { + const calls = [] + const events = [] + const user = vue.reactive({ isSuperAdmin: superAdmin, permissions }) + const config = { + id: 1, + config_name: '默认配置', + priority: 1, + status: 1, + carrier_id: 8, + realname_check_interval: null + } + const defaults = { + getPollingConfigs: () => page([config]), + getPollingConfigById: () => ok(config), + previewMatch: () => ok(preview), + previewDraft: () => ok(preview), + updatePriorities: () => undefined, + recalculateDue: () => ok(task()), + getRecalculateTask: (id) => ok(task(id)), + getPreResetStats: () => ok(preReset), + getTierStats: () => ok(tiers) + } + const service = Object.fromEntries( + Object.entries(defaults).map(([name, handler]) => [ + name, + async (...args) => { + calls.push({ name, args }) + return (overrides[name] || handler)(...args) + } + ]) + ) + const hasAuth = (value) => user.isSuperAdmin || user.permissions.includes(value) + const imports = { + vue, + 'element-plus': { + ElMessageBox: { confirm: overrides.confirm || (async () => {}) }, + ElMessage: { success() {} } + }, + '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }, + '@/api/modules/pollingConfig': { PollingConfigService: service }, + '@/store/modules/user': { useUserStore: () => user }, + '@/composables/useAuth': { useAuth: () => ({ hasAuth }) }, + '@/config/constants/pollingConfig': constants, + '@/utils/business/pollingConfig': helpers, + '@/utils/business/gateway': gateway, + '@/utils/business/format': format, + '@/composables/useGateway': { + useGatewayCarriers: () => ({ + options: vue.ref([]), + loading: vue.ref(false), + error: vue.ref(''), + search() {}, + select() {}, + label: String + }), + useGatewayColumns: (definitions) => ({ + columns: vue.computed(() => definitions), + columnChecks: vue.ref(definitions) + }) + }, + './PollingDraftFields.vue': { default: {} } + } + imports['@/composables/usePollingConfigExtensions'] = evaluate( + source('src/composables/usePollingConfigExtensions.ts'), + imports, + globals + ) + let component + if (path === 'progress') + component = { + setup: () => + imports['@/composables/usePollingConfigExtensions'].usePollingRecalculateProgress(() => + hasAuth(perms.progress) + ) + } + else { + const { descriptor } = parse(source(path)) + component = evaluate( + compileScript(descriptor, { id: 'polling-config-test' }).content, + imports, + globals + ).default + } + let state + const renderer = vue.createRenderer({ + createComment: () => ({}), + insert() {}, + remove() {}, + parentNode: () => null, + nextSibling: () => null + }) + const reactiveProps = vue.reactive(props) + const app = renderer.createApp({ + setup() { + state = component.setup(reactiveProps, { expose() {}, emit: (...args) => events.push(args) }) + return () => null + } + }) + app.mount({}) + t.after(() => app.unmount()) + return { state, calls, events, user, unmount: () => app.unmount() } +} +const previewPath = 'src/views/polling-management/config/components/PollingPreviewDialog.vue' +const priorityPath = 'src/views/polling-management/config/components/PollingPriorityDialog.vue' +const recalculatePath = + 'src/views/polling-management/config/components/PollingRecalculateDialog.vue' +const statsPath = 'src/views/polling-management/config-stats/index.vue' +const writes = (calls) => + calls.filter((call) => + ['updatePriorities', 'recalculateDue', 'createPollingConfig', 'updatePollingConfig'].includes( + call.name + ) + ) + +test('preview permissions and ICCID validation block unauthorized or invalid requests', async (t) => { + const { state, calls, user } = mount(t, previewPath) + await flush() + state.iccid.value = 'CARD' + await state.runPreview() + assert.match(state.error.value, /无权/) + user.permissions.push(perms.preview) + state.iccid.value = 'x'.repeat(31) + await state.runPreview() + assert.match(state.error.value, /ICCID/) + assert.equal( + calls.some((call) => call.name === 'previewMatch'), + false + ) +}) + +test('GET and multi-override POST previews never save configurations', async (t) => { + const { state, calls } = mount(t, previewPath, { permissions: [perms.preview] }) + state.iccid.value = 'CARD' + await state.runPreview() + assert.equal(state.result.value.intervals[0].interval, 0) + state.useDraft.value = true + state.addDraft(draft()) + state.addDraft({ ...draft(), id: 2, disabled: true }) + await state.runPreview() + const body = calls.find((call) => call.name === 'previewDraft').args[0] + assert.equal(body.iccid, 'CARD') + assert.equal(body.overrides.length, 2) + assert.equal(body.overrides[1].disabled, true) + assert.equal(writes(calls).length, 0) +}) + +test('a stale preview cannot overwrite a changed card or unmounted dialog', async (t) => { + const pending = deferred() + const { state, unmount } = mount(t, previewPath, { + permissions: [perms.preview], + overrides: { previewMatch: () => pending.promise } + }) + state.iccid.value = 'OLD' + const running = state.runPreview() + state.iccid.value = 'NEW' + pending.resolve(ok(preview)) + await running + assert.equal(state.result.value, null) + unmount() +}) + +test('ordinary users cannot submit priority updates or start recalculation', async (t) => { + for (const [path, action] of [ + [priorityPath, 'save'], + [recalculatePath, 'start'] + ]) { + const { state, calls } = mount(t, path, { permissions: [perms.progress] }) + await flush() + await state[action]() + assert.equal(writes(calls).length, 0) + } +}) + +test('priority updates load all pages, preserve chosen order, confirm and accept empty success DTO', async (t) => { + const { state, calls, events } = mount(t, priorityPath, { + superAdmin: true, + overrides: { + getPollingConfigs: ({ page: current }) => + ok({ items: [{ id: current, config_name: `配置${current}`, priority: current }], total: 2 }) + } + }) + await flush() + assert.equal(state.rows.value.length, 2) + state.move(1, -1) + await state.save() + assert.deepEqual(calls.find((call) => call.name === 'updatePriorities').args[0], [2, 1]) + assert.ok(events.some((event) => event[0] === 'saved')) +}) + +test('priority cancellation or role revocation after confirmation sends no write', async (t) => { + for (const cancel of [true, false]) { + const confirmation = deferred() + const { state, calls, user } = mount(t, priorityPath, { + superAdmin: true, + overrides: { confirm: () => confirmation.promise } + }) + await flush() + const saving = state.save() + if (cancel) confirmation.reject('cancel') + else { + user.isSuperAdmin = false + confirmation.resolve('ok') + } + await saving + assert.equal(writes(calls).length, 0) + } +}) + +test('recalculation uses numeric/null carrier IDs and only reports actual task status', async (t) => { + const { state, calls } = mount(t, recalculatePath, { superAdmin: true }) + await state.start() + assert.equal(calls.find((call) => call.name === 'recalculateDue').args[0], null) + assert.equal(state.task.value.status, 'running') + state.allCarriers.value = false + state.carrierId.value = 8 + await state.start() + assert.equal(calls.filter((call) => call.name === 'recalculateDue').at(-1).args[0], 8) +}) + +function fakeTimers() { + let key = 0 + const timers = new Map() + return { + timers, + globals: { + setTimeout: (fn) => { + timers.set(++key, fn) + return key + }, + clearTimeout: (id) => timers.delete(id) + }, + async tick() { + const next = timers.entries().next().value + if (next) { + timers.delete(next[0]) + next[1]() + await flush() + } + } + } +} + +test('progress polling stops at terminal state and cleanup removes pending timers', async (t) => { + const clock = fakeTimers() + let count = 0 + const { state, unmount } = mount(t, 'progress', { + permissions: [perms.progress], + globals: clock.globals, + overrides: { getRecalculateTask: (id) => ok(task(id, ++count === 1 ? 'running' : 'completed')) } + }) + state.taskId.value = 'task-1' + state.autoRefresh.value = true + await state.query() + assert.equal(clock.timers.size, 1) + await clock.tick() + assert.equal(state.task.value.status, 'completed') + assert.equal(clock.timers.size, 0) + state.adopt(task()) + assert.equal(clock.timers.size, 1) + unmount() + assert.equal(clock.timers.size, 0) +}) + +test('changing task ID, closing, errors or loss of permission discard old progress', async (t) => { + const clock = fakeTimers() + const pending = deferred() + const { state, user } = mount(t, 'progress', { + permissions: [perms.progress], + globals: clock.globals, + overrides: { getRecalculateTask: () => pending.promise } + }) + state.taskId.value = 'OLD' + const request = state.query() + state.taskId.value = 'NEW' + pending.resolve(ok(task('OLD'))) + await request + assert.equal(state.task.value, null) + state.adopt(task('NEW')) + state.autoRefresh.value = true + user.permissions = [] + await flush() + assert.equal(clock.timers.size, 0) + assert.equal(state.task.value, null) +}) + +test('statistics retain null periods, zero counts, raw bucket values and unknown package states', async (t) => { + const { state } = mount(t, statsPath, { + permissions: [perms.statsPage, perms.preReset, perms.tiers] + }) + await flush() + assert.equal(state.preRows.value.length, 2) + assert.equal(state.preValue(state.preRows.value[0], 'checked_in_window'), 0) + assert.equal(state.preValue(state.preRows.value[0], 'le_5'), 0) + assert.equal(state.preValue(state.preRows.value[1], 'reset_at'), '无周期数据') + assert.equal(state.preValue(state.preRows.value[1], 'checked_in_window'), '-') + assert.equal(state.tiers.value.by_config[0].config_id, 0) + assert.equal(state.packageRows.value.find((row) => row.state === 'unknown').cards, 0) +}) + +test('statistics page and separate area permissions are both required', async (t) => { + for (const permissions of [[], [perms.preReset, perms.tiers], [perms.statsPage]]) { + const { state, calls } = mount(t, statsPath, { permissions }) + await state.loadPreReset() + await state.loadTiers() + assert.equal( + calls.filter((call) => ['getPreResetStats', 'getTierStats'].includes(call.name)).length, + 0 + ) + } +}) + +test('new fields persist through whitelisted CRUD and menu/button contracts use distinct codes', () => { + const pageSource = source('src/views/polling-management/config/index.vue') + const body = helpers.pollingSaveBody( + { + ...draft(), + asset_status: 2, + package_state: 'active', + pre_reset_window: 60, + pre_reset_carddata_interval: 60 + }, + false + ) + for (const field of [ + 'asset_status', + 'package_state', + 'pre_reset_window', + 'pre_reset_carddata_interval' + ]) + assert.ok(Object.hasOwn(body, field)) + assert.match(pageSource, /pollingSaveBody\(formData/) + assert.match(pageSource, /:show-expand="searchFormItems.length >= 4"/) + assert.match( + source('src/router/routesAlias.ts'), + /PollingConfigStats = '\/polling-management\/config-stats'/ + ) + assert.equal(new Set(Object.values(perms)).size, 5) + for (const code of Object.values(perms)) assert.match(code, /^[a-z_]+:[a-z_]+$/) +}) + +test('interval field UI emits distinct null, zero and positive values', async (t) => { + const { descriptor } = parse( + source('src/views/polling-management/config/components/PollingIntervalField.vue') + ) + const definition = evaluate(compileScript(descriptor, { id: 'polling-interval-test' }).content, { + vue + }).default + const value = vue.ref(null) + let state + const component = { + ...definition, + setup(props, context) { + state = definition.setup(props, context) + return () => null + } + } + const renderer = vue.createRenderer({ + createComment: () => ({}), + insert() {}, + remove() {}, + parentNode: () => null, + nextSibling: () => null + }) + const app = renderer.createApp({ + render: () => + vue.h(component, { + min: 30, + modelValue: value.value, + 'onUpdate:modelValue': (next) => { + value.value = next + } + }) + }) + app.mount({}) + t.after(() => app.unmount()) + assert.equal(state.mode.value, 'inherit') + state.mode.value = 'disabled' + await vue.nextTick() + assert.equal(value.value, 0) + state.mode.value = 'interval' + await vue.nextTick() + assert.equal(value.value, 30) + state.mode.value = 'inherit' + await vue.nextTick() + assert.equal(value.value, null) +}) + +test('progress errors stop automatic polling and do not create a new task', async (t) => { + const clock = fakeTimers() + const { state, calls } = mount(t, 'progress', { + permissions: [perms.progress], + globals: clock.globals, + overrides: { + getRecalculateTask: () => { + throw new Error('任务查询失败') + } + } + }) + state.taskId.value = 'task-1' + state.autoRefresh.value = true + await state.query() + assert.match(state.error.value, /任务查询失败/) + assert.equal(clock.timers.size, 0) + assert.equal(writes(calls).length, 0) +}) + +test('partial or duplicate configuration pages cannot be submitted as a complete priority order', async (t) => { + const { state, calls } = mount(t, priorityPath, { + superAdmin: true, + overrides: { + getPollingConfigs: () => + ok({ items: [{ id: 1, config_name: '重复配置', priority: 1 }], total: 2 }) + } + }) + await flush() + assert.equal(state.loaded.value, false) + assert.match(state.error.value, /不完整或存在重复/) + await state.save() + assert.equal(writes(calls).length, 0) +}) + +test('pre-reset draft controls require an explicit enable and preserve null when disabled', async (t) => { + const { descriptor } = parse( + source('src/views/polling-management/config/components/PollingDraftFields.vue') + ) + const definition = evaluate(compileScript(descriptor, { id: 'polling-window-test' }).content, { + vue, + '@/config/constants/pollingConfig': constants, + '@/composables/useGateway': { + useGatewayCarriers: () => ({ + options: vue.ref([]), + loading: vue.ref(false), + error: vue.ref(''), + search() {}, + remember() {}, + select() {} + }) + }, + './PollingIntervalField.vue': { default: {} } + }).default + const value = vue.ref(draft()) + let state + const component = { + ...definition, + setup(props, context) { + state = definition.setup(props, context) + return () => null + } + } + const renderer = vue.createRenderer({ + createComment: () => ({}), + insert() {}, + remove() {}, + parentNode: () => null, + nextSibling: () => null + }) + const app = renderer.createApp({ + render: () => + vue.h(component, { + modelValue: value.value, + 'onUpdate:modelValue': (next) => { + value.value = next + } + }) + }) + app.mount({}) + t.after(() => app.unmount()) + assert.equal(state.preResetEnabled.value, false) + assert.equal(value.value.pre_reset_window, null) + state.preResetEnabled.value = true + await vue.nextTick() + assert.equal(value.value.pre_reset_window, 60) + assert.equal(value.value.pre_reset_carddata_interval, 60) + state.preResetEnabled.value = false + await vue.nextTick() + assert.equal(value.value.pre_reset_window, null) + assert.equal(value.value.pre_reset_carddata_interval, null) +}) + +test('closing a preview or progress query while pending discards its response', async (t) => { + for (const kind of ['preview', 'progress']) { + const pending = deferred() + const { state, unmount } = mount(t, kind === 'preview' ? previewPath : 'progress', { + permissions: [perms.preview, perms.progress], + overrides: + kind === 'preview' + ? { previewMatch: () => pending.promise } + : { getRecalculateTask: () => pending.promise } + }) + if (kind === 'preview') state.iccid.value = 'CARD' + else state.taskId.value = 'task-1' + const query = kind === 'preview' ? state.runPreview() : state.query() + unmount() + pending.resolve(ok(kind === 'preview' ? preview : task())) + await query + assert.equal(kind === 'preview' ? state.result.value : state.task.value, null) + } +}) diff --git a/scripts/tests/pollingScheduler.test.mjs b/scripts/tests/pollingScheduler.test.mjs new file mode 100644 index 0000000..989c4d7 --- /dev/null +++ b/scripts/tests/pollingScheduler.test.mjs @@ -0,0 +1,556 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc' + +const source = (path) => readFileSync(path, 'utf8') +function evaluate(text, imports = {}, globals = {}) { + const compiled = ts.transpileModule(text, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + const module = { exports: {} } + new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)( + (name) => { + assert.ok(imports[name], `Unexpected dependency: ${name}`) + return imports[name] + }, + module, + module.exports, + ...Object.values(globals) + ) + return module.exports +} +const gateway = evaluate(source('src/utils/business/gateway.ts'), { + './format': evaluate(source('src/utils/business/format.ts')) +}) +const helpers = evaluate(source('src/utils/business/pollingScheduler.ts'), { './gateway': gateway }) +const constants = evaluate(source('src/config/constants/pollingScheduler.ts')) +const configHelpers = evaluate(source('src/utils/business/pollingConfig.ts'), { + './gateway': gateway, + '@/config/constants/pollingConfig': evaluate(source('src/config/constants/pollingConfig.ts')) +}) +const ok = (data) => ({ code: 0, data }) +const flush = async () => { + await new Promise((done) => setImmediate(done)) + await vue.nextTick() +} +const batchPath = 'src/components/business/polling/PollingBatchDialog.vue' + +test('gateway metrics flatten into row arrays without old raw-data attribute overriding the table', () => { + const path = 'src/components/business/gateway/GatewayOperationTable.vue' + const { descriptor } = parse(source(path)) + const definition = evaluate(compileScript(descriptor, { id: 'metric-table' }).content, { + vue, + '@/components/business/polling/SchedulerTable.vue': { default: {} }, + '@/utils/business/gateway': gateway + }).default + const props = { + values: [ + { + carrier_id: 8, + carrier_name: 'carrier', + operations: [ + { operation: 'stop', operation_name: 'stop', success_rate: 0.8, p95_duration_ms: -1 } + ] + } + ] + } + const state = definition.setup(props, { expose() {}, emit() {} }) + assert.equal(state.rows.value.length, 1) + assert.equal(state.format(state.rows.value[0], 'success_rate'), '80.00%') + assert.equal(state.format(state.rows.value[0], 'p95_duration_ms'), '超过最大分桶') + assert.equal(definition.inheritAttrs, false) + assert.doesNotMatch( + source('src/views/polling-management/gateway-stats/index.vue'), + /]*:data=/s + ) +}) + +test('every one of the 46 documented added HTTP operations has a service method', async () => { + const calls = [] + class BaseService {} + for (const [method, verb] of [ + ['get', 'GET'], + ['post', 'POST'], + ['put', 'PUT'], + ['delete', 'DELETE'] + ]) + BaseService[method] = (url) => { + calls.push(`${verb} ${url}`) + return ok({}) + } + for (const [file, name] of [ + ['gateway', 'GatewayService'], + ['pollingConfig', 'PollingConfigService'], + ['pollingScheduler', 'PollingSchedulerService'] + ]) { + const service = evaluate(source(`src/api/modules/${file}.ts`), { + '../BaseService': { BaseService } + })[name] + for (const key of Object.getOwnPropertyNames(service)) + if (typeof service[key] === 'function' && !['length', 'name', 'prototype'].includes(key)) + await service[key](1, {}, '') + } + const normalize = (value) => + value + .replace('/api/admin', '') + .replace(/\{[^}]+\}/g, '{}') + .replace(/\/1(?=\/|$)/g, '/{}') + // 原始后端文档在本仓库被忽略,固定路径快照保证全新检出也可运行;本地有原文时额外校对。 + const snapshot = JSON.parse(source('scripts/tests/fixtures/pollingSchedulerEndpoints.json')) + const document = 'docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md' + if (existsSync(document)) + assert.deepEqual( + [...source(document).matchAll(/^#### `(GET|POST|PUT|DELETE) ([^`]+)`/gm)].map( + (match) => `${match[1]} ${match[2]}` + ), + snapshot + ) + const expected = snapshot.map(normalize) + assert.equal(expected.length, 46) + const actual = new Set(calls.map(normalize)) + for (const endpoint of expected) assert.ok(actual.has(endpoint), endpoint) +}) + +test('scheduler request rejects stale responses after reload, permission change, scope switch and unmount', async () => { + const user = vue.reactive({ info: { id: 1, user_type: 2 }, isLogin: true }), + permission = vue.ref(true), + stops = [], + deactivations = [], + activations = [] + const runtime = { + ...vue, + onBeforeUnmount: (fn) => stops.push(fn), + onDeactivated: (fn) => deactivations.push(fn), + onActivated: (fn) => activations.push(fn) + } + const { useSchedulerRequest } = evaluate(source('src/composables/useSchedulerRequest.ts'), { + vue: runtime, + '@/store/modules/user': { useUserStore: () => user }, + '@/utils/business/gateway': gateway, + '@/utils/business/pollingScheduler': helpers + }) + const pending = [] + const scope = vue.effectScope() + const state = scope.run(() => + useSchedulerRequest( + () => new Promise((resolve) => pending.push(resolve)), + () => permission.value + ) + ) + const a = state.load(), + b = state.load() + pending[1]('new') + await b + pending[0]('old') + await a + assert.equal(state.data.value, 'new') + const c = state.load() + user.info.id = 2 + pending[2]('old account') + await c + assert.equal(state.data.value, null) + permission.value = false + await state.load() + assert.equal(pending.length, 3) + permission.value = true + const d = state.load() + stops.forEach((fn) => fn()) + pending[3]('unmounted') + await d + assert.equal(state.data.value, null) + scope.stop() +}) +function mount( + path, + { + userType = 2, + permissions = ['test'], + props = {}, + services = {}, + storage = {}, + confirm = async () => 'confirm' + } = {} +) { + const user = vue.reactive({ info: { id: 1, user_type: userType }, isLogin: true }) + const events = [], + calls = [], + beforeUnmount = [], + deactivated = [] + const api = new Proxy(services, { + get(target, key) { + return async (...args) => { + calls.push({ name: key, args }) + return key in target + ? target[key](...args) + : ok({ enable: ['Enable impact from server'], disable: ['Disable impact from server'] }) + } + } + }) + const runtime = { + ...vue, + onMounted: () => {}, + onBeforeUnmount: (fn) => beforeUnmount.push(fn), + onDeactivated: (fn) => deactivated.push(fn) + } + const { descriptor } = parse(source(path)) + const script = compileScript(descriptor, { id: 'scheduler-test' }).content + const imports = { + vue: runtime, + 'element-plus': { ElMessageBox: { confirm } }, + '@/api/modules/pollingScheduler': { PollingSchedulerService: api }, + '@/api/modules/storage': { StorageService: storage }, + '@/store/modules/user': { useUserStore: () => user }, + '@/composables/useAuth': { + useAuth: () => ({ hasAuth: (permission) => permissions.includes(permission) }) + }, + '@/utils/business/gateway': gateway, + '@/utils/business/pollingScheduler': helpers, + '@/config/constants/pollingScheduler': constants, + '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) } + } + const state = evaluate(script, imports, runtime).default.setup(props, { + expose() {}, + emit: (...args) => events.push(args) + }) + return { + state, + calls, + user, + events, + dispose: () => beforeUnmount.forEach((fn) => fn()), + deactivate: () => deactivated.forEach((fn) => fn()) + } +} + +test('22 scheduler methods preserve exact HTTP paths, bodies, null and false', async () => { + const calls = [] + class BaseService {} + for (const method of ['get', 'put', 'post', 'delete']) + BaseService[method] = (url, data) => { + calls.push({ method, url, data }) + return ok(data) + } + const { PollingSchedulerService: service } = evaluate( + source('src/api/modules/pollingScheduler.ts'), + { '../BaseService': { BaseService } } + ) + const toggle = { asset_ids: [1, 2], enable_polling: false, reason: 'test' }, + input = { inflight_limit: null }, + query = { page: 1, page_size: 20, status: 1 } + const specs = [ + ['getCarrierParams', [], 'get', '/polling-carrier-params'], + ['getCarrierParam', [8], 'get', '/polling-carrier-params/8'], + ['saveCarrierParam', [8, input], 'put', '/polling-carrier-params/8', input], + ['resetCarrierParam', [8], 'delete', '/polling-carrier-params/8'], + ['getScenes', [], 'get', '/polling-check-scenes'], + ['saveScene', ['scene/a', input], 'put', '/polling-check-scenes/scene%2Fa', input], + ['getAlerts', [query], 'get', '/polling-failure-alerts', query], + ['getAlertSummary', [], 'get', '/polling-failure-alerts/summary'], + ['getAlert', [1], 'get', '/polling-failure-alerts/1'], + [ + 'handleAlert', + [1, 'note'], + 'put', + '/polling-failure-alerts/1/status', + { status: 4, remark: 'note' } + ], + [ + 'closeAlertPolling', + [[1, 2], 'note'], + 'post', + '/polling-failure-alerts/close-polling', + { alert_ids: [1, 2], reason: 'note' } + ], + ['getQueues', [], 'get', '/polling-stats/carrier-queues'], + ['getCardState', [42], 'get', '/iot-cards/42/polling-state'], + ['getImpact', [], 'get', '/polling-status/impact'], + ['toggleCards', [toggle], 'post', '/iot-cards/batch-update-polling-status', toggle], + ['toggleDevices', [toggle], 'post', '/devices/batch-update-polling-status', toggle], + ['getPauses', [query], 'get', '/card-polling-pauses', query], + ['getImports', [query], 'get', '/polling-status-imports', query], + ['getImport', [3], 'get', '/polling-status-imports/3'], + ['createImport', [toggle], 'post', '/polling-status-imports', toggle], + ['dryRun', [query], 'get', '/stop-resume-rule/dry-run', query], + [ + 'exportDryRun', + [{ carrier_id: 8, format: 'csv' }], + 'post', + '/stop-resume-rule/dry-run/export', + { carrier_id: 8, format: 'csv' } + ] + ] + for (const [name, args, method, path, data] of specs) { + await service[name](...args) + assert.deepEqual(calls.at(-1), { method, url: `/api/admin${path}`, data }) + } + assert.equal(specs.length, 22) +}) + +test('role boundaries and unique 1..500 ids are enforced', () => { + for (const type of [1, 2, 3, 4, 0, undefined]) { + assert.equal(helpers.isPollingPlatform(type), [1, 2].includes(type)) + assert.equal(helpers.canPollingBatch(type), [1, 2, 3].includes(type)) + } + for (const ids of [[], [1, 1], [0], [-1], [1.5], Array.from({ length: 501 }, (_, i) => i + 1)]) + assert.throws(() => helpers.validatePollingIds(ids)) + assert.doesNotThrow(() => + helpers.validatePollingIds(Array.from({ length: 500 }, (_, i) => i + 1)) + ) + assert.throws(() => helpers.pollingReason(' ')) + assert.equal(helpers.pollingReason(' reason '), 'reason') +}) +test('special alert view only sends documented query filters; zero status is preserved', () => { + const query = { + page: 1, + page_size: 20, + carrier_id: 8, + status: 0, + view: 'stop_refused', + check_type: 'stop_resume', + error_category: 'terminal', + alert_level: 'card', + iccid: 'x' + } + assert.deepEqual(helpers.failureQuery(query), { + page: 1, + page_size: 20, + carrier_id: 8, + status: 0, + iccid: 'x', + view: 'stop_refused' + }) + assert.equal(helpers.failureQuery({ ...query, view: undefined }).check_type, 'stop_resume') + assert.deepEqual( + helpers.schedulerTime(['2026-10-26T00:00:00+08:00', '2026-10-27T00:00:00+08:00']), + { start_time: '2026-10-25T16:00:00Z', end_time: '2026-10-26T16:00:00Z' } + ) + assert.throws(() => helpers.schedulerTime(['bad', 'date'])) +}) +test('parameter, capacity, nullable cooldown and strictly increasing delays validate', () => { + assert.doesNotThrow(() => helpers.validateCarrierParams({ inflight_limit: null }, 0)) + assert.throws(() => helpers.validateCarrierParams({ inflight_limit: 11 }, 10)) + assert.throws(() => helpers.validateCarrierParams({ priority_max_ratio: 0 }, 10)) + for (const delays of [[30, 30], [60, 30], [0]]) + assert.throws(() => + helpers.validateScene({ check_types: ['carddata'], confirm_delays: delays, cooldown: null }) + ) + assert.doesNotThrow(() => + helpers.validateScene({ check_types: ['realname'], confirm_delays: [30, 60], cooldown: 0 }) + ) + assert.throws(() => + helpers.validateScene({ check_types: ['package'], confirm_delays: [], cooldown: null }) + ) + assert.throws(() => helpers.validateDryAssumptions([1, 2], [2])) + assert.doesNotThrow(() => helpers.validateDryAssumptions([1], [2])) +}) +test('CSV header, quoted newlines, escaped quotes, empty lines, 50000 limit and string ids', () => { + const id = '89860426012345678901' + assert.equal( + helpers.validatePollingCsv( + 'file.CSV', + `\uFEFFiccid,note\r\n${id},"line\nline"\r\n\r\n${id},"a""b"` + ), + 2 + ) + assert.equal(id, '89860426012345678901') + for (const [name, text] of [ + ['f.txt', 'h\nx'], + ['f.csv', 'header'], + ['f.csv', 'h\n"x'], + ['f.csv', `h\n${'x\n'.repeat(50001)}`] + ]) + assert.throws(() => helpers.validatePollingCsv(name, text)) + assert.equal(helpers.validatePollingCsv('f.csv', `h\n${'x\n'.repeat(50000)}`), 50000) +}) +test('config save persists new fields, removes protection, uses clear_fields without losing zero', () => { + const draft = { + ...configHelpers.newPollingDraft(), + config_name: 'x', + asset_status: 2, + package_state: 'active', + carddata_check_interval: 0, + protect_check_interval: 30 + } + const body = configHelpers.pollingSaveBody(draft, true) + assert.equal(body.asset_status, 2) + assert.equal(body.package_state, 'active') + assert.equal(body.carddata_check_interval, 0) + assert.ok(body.clear_fields.includes('realname_check_interval')) + assert.equal('realname_check_interval' in body, false) + assert.equal('protect_check_interval' in body, false) + assert.equal('id' in body, false) + assert.equal('disabled' in body, false) + assert.deepEqual( + configHelpers.pollingDraftBody([{ id: 3, disabled: true, priority: -1, config_name: '' }]), + [{ id: 3, disabled: true }] + ) + assert.throws(() => + gateway.validateGatewayRule({ + category: 'business', + auto_disable_polling: true, + keyword: 'a', + match_type: 'contains' + }) + ) +}) + +for (const [target, type, allowed] of [ + ['iot_card', 3, true], + ['device', 4, false], + ['alert', 3, false], + ['alert', 2, true] +]) + test(`batch ${target} type${type} validates identity and shows partial results`, async () => { + const result = { + success_count: 1, + skip_count: 1, + fail_count: 2, + impact: ['impact'], + items: [1, 2, 3, 4].map((status) => ({ + asset_id: status, + identifier: String(status), + status, + status_name: String(status), + reason: 'reason' + })) + } + const h = mount(batchPath, { + userType: type, + props: { target, ids: [1, 2, 3, 4], permission: 'test' }, + services: { + toggleCards: () => ok(result), + toggleDevices: () => ok(result), + closeAlertPolling: () => ok(result) + } + }) + await h.state.loadImpact() + h.state.reason.value = ' reason ' + await h.state.submit() + assert.equal( + h.calls.some((call) => + ['toggleCards', 'toggleDevices', 'closeAlertPolling'].includes(call.name) + ), + allowed + ) + if (allowed) { + assert.deepEqual(h.state.result.value, result) + assert.equal(h.state.resultRows.value.length, 4) + await h.state.submit() + assert.equal(h.calls.length, 2) + } + h.dispose() + }) +test('batch missing impact, cancel, duplicate ids, unmount, and changed scope never write', async () => { + for (const variant of ['impact', 'cancel', 'duplicate', 'unmount', 'scope']) { + const props = { + target: 'iot_card', + ids: variant === 'duplicate' ? [1, 1] : [1], + permission: 'test' + } + let h + h = mount(batchPath, { + props, + services: + variant === 'impact' + ? { + getImpact: () => { + throw Error('offline') + } + } + : {}, + confirm: async () => { + if (variant === 'cancel') throw 'cancel' + if (variant === 'unmount') h.dispose() + if (variant === 'scope') h.user.info.id = 2 + } + }) + await h.state.loadImpact() + h.state.reason.value = 'reason' + await h.state.submit() + assert.equal( + h.calls.some((call) => call.name === 'toggleCards'), + false, + variant + ) + h.dispose() + } +}) +test('import validates purpose/prefix and never creates after upload failure', async () => { + for (const fail of [false, true]) { + const uploads = [] + const h = mount('src/components/business/polling/PollingImportDialog.vue', { + permissions: [constants.SCHEDULER_PERMISSIONS.imports.create], + storage: { + getUploadUrl: async (data) => { + uploads.push(data) + return ok({ upload_url: 'local-upload', file_key: 'polling-status-imports/test.csv' }) + }, + uploadFile: async () => { + if (fail) throw Error('upload failed') + } + }, + services: { createImport: () => ok({ id: 3 }) } + }) + h.state.file.value = { name: 'file.csv', text: async () => 'iccid\n89860426012345678901' } + h.state.reason.value = 'reason' + await h.state.submit() + assert.equal(uploads[0].purpose, 'polling_status_import') + assert.equal( + h.calls.some((call) => call.name === 'createImport'), + !fail + ) + h.dispose() + } +}) +test('all new Vue templates compile, including multi-statement event handlers', () => { + const dirs = [ + 'src/components/business/polling', + 'src/views/polling-management/carrier-params', + 'src/views/polling-management/check-scenes', + 'src/views/polling-management/carrier-queues', + 'src/views/polling-management/failure-alerts', + 'src/views/polling-management/polling-pauses', + 'src/views/polling-management/polling-status-imports', + 'src/views/polling-management/stop-resume-dry-run' + ] + for (const dir of dirs) + for (const file of readdirSync(dir).filter((file) => file.endsWith('.vue'))) { + const path = `${dir}/${file}`, + { descriptor, errors } = parse(source(path)) + assert.deepEqual(errors, [], path) + const result = compileTemplate({ + source: descriptor.template.content, + filename: path, + id: path, + compilerOptions: { expressionPlugins: ['typescript'] } + }) + assert.deepEqual(result.errors, [], path) + } +}) +test('protection is absent from admin forms and polling pause/alert/dry-run export scenes are registered', () => { + for (const path of [ + 'src/views/polling-management/config/index.vue', + 'src/views/polling-management/config/components/PollingDraftFields.vue', + 'src/views/asset-management/asset-information/components/BasicInfoCard.vue' + ]) + assert.equal(/protect_check_interval|device_protect_status/.test(source(path)), false, path) + for (const scene of ['polling_pause', 'polling_stop_refused_alert', 'stop_resume_rule_dry_run']) + assert.ok(source('src/config/constants/exportTask.ts').includes(`scene: '${scene}'`)) + for (const kind of [ + 'carrier-params', + 'check-scenes', + 'failure-alerts', + 'carrier-queues', + 'stop-resume-dry-run', + 'polling-pauses', + 'polling-status-imports' + ]) + assert.ok(source('src/router/routes/asyncRoutes.ts').includes(`path: '${kind}'`)) + assert.match( + source('src/views/polling-management/stop-resume-dry-run/index.vue'), + /assume_control_off/ + ) +}) diff --git a/scripts/tests/pollingSchedulerBrowserFixtures.mjs b/scripts/tests/pollingSchedulerBrowserFixtures.mjs new file mode 100644 index 0000000..dd90fc2 --- /dev/null +++ b/scripts/tests/pollingSchedulerBrowserFixtures.mjs @@ -0,0 +1,414 @@ +// 本地验收专用。所有 API 由此模块或下游 mock 拦截,禁止代理真实服务。 +import { mockApiPlugin as polling } from './pollingConfigBrowserFixtures.mjs' +const time = '2026-10-26T10:00:00+08:00' +const menus = [ + ['运营商账户调度参数', 'carrier-params', 'polling_carrier_params:page'], + ['请求检查场景', 'check-scenes', 'polling_check_scene:page'], + ['失败告警', 'failure-alerts', 'polling_failure_alert:page'], + ['账户队列监控', 'carrier-queues', 'polling_carrier_queue:page'], + ['停复机规则演练', 'stop-resume-dry-run', 'stop_resume_dry_run:page'], + ['不轮询清单', 'polling-pauses', 'polling_pause:page'], + ['轮询开关导入', 'polling-status-imports', 'polling_status_import:page'], + ['轮询配置', 'config'], + ['网关调用统计', 'gateway-stats', 'gateway_call_stats:page'] +] +const permissions = [ + 'polling_config_create', + 'polling_carrier_params:detail', + 'polling_carrier_params:detail_page', + 'polling_failure_alert:detail', + 'polling_failure_alert:detail_page', + 'polling_failure_alert:summary', + 'polling_failure_alert:handle', + 'polling_failure_alert:close_polling', + 'polling_failure_alert:export', + 'polling_pause:restore', + 'polling_pause:export', + 'polling_status_import:create', + 'polling_status_import:detail', + 'polling_status_import:detail_page', + 'stop_resume_dry_run:export', + 'iot_info:polling_state', + 'device_info:polling_state', + 'iot_card:batch_polling', + 'device:batch_polling', + 'gateway_call_stats:trend', + 'iot_info:stop_resume_records', + 'polling_config:match_preview' +] +const params = { + carrier_id: 8, + carrier_name: '模拟运营商账户', + is_default: true, + card_max_in_flight: 10, + inflight_limit: 20, + configured_card_max_in_flight: null, + configured_inflight_limit: null, + consume_capacity: 30, + priority_max_ratio: null, + sr_inflight_limit: null, + stop_resume_max_in_flight: null, + stop_resume_min_interval: null, + stop_resume_confirm_delay: null +} +const alert = { + id: 1, + alert_level: 'card', + card_id: 42, + carrier_id: 8, + carrier_name: params.carrier_name, + check_type: 'stop_resume', + check_type_name: '停复机', + created_at: time, + iccid: '89860426012345678901', + status: 1, + status_name: '未处理', + error_category: 'terminal', + fail_count: 3, + first_failed_at: time, + last_failed_at: time, + last_error: '模拟停机被拒', + handled_by: 0, + handle_remark: '', + handled_at: '', + recovered_at: '', + network_status: 1, + stop_reason: 'manual', + enable_polling: true, + has_package: false +} +const pause = { + id: 1, + asset_id: 42, + asset_type: 'iot_card', + identifier: alert.iccid, + carrier_id: 8, + carrier_name: params.carrier_name, + enable_polling: false, + gateway_extend: '', + status: 1, + status_name: '已关闭', + close_source: 'batch', + close_source_name: '勾选批量', + close_reason: '模拟关闭', + closed_by: 0, + closed_by_name: '', + closed_at: time, + restore_source: '', + restore_reason: '', + restored_by: 0, + restored_at: '', + import_task_id: 0 +} +const task = { + id: 1, + task_no: 'MOCK-IMPORT-1', + asset_type: 'iot_card', + enable_polling: false, + file_name: 'mock.csv', + reason: '模拟导入', + status: 3, + status_name: '已完成', + creator_name: '模拟平台', + total_count: 4, + success_count: 1, + skip_count: 1, + fail_count: 2, + error_message: '', + created_at: time, + started_at: time, + completed_at: time, + items: [1, 2, 3, 4].map((status, index) => ({ + line: index + 2, + identifier: `8986042601234567890${index}`, + asset_id: index + 42, + status, + status_name: ['', '成功', '跳过', '失败', '无权限或不存在'][status], + reason: status > 1 ? '模拟逐项原因' : '' + })) +} +const operation = { + operation: 'stop', + operation_name: '停机', + attempts: 10, + success: 8, + success_rate: 0.8, + avg_qps: 0.2, + peak_minute_qps: 0.3, + avg_duration_ms: 230, + p95_duration_ms: -1, + rate_limited: 0, + would_limit: 2, + failures: { 'upstream:terminal': 2 }, + exempt: { manual_click: 1 } +} +const log = [] +const page = (items, url) => ({ + items, + total: items.length, + page: Number(url.searchParams.get('page') || 1), + size: Number(url.searchParams.get('page_size') || 20) +}) +export const mockApiPlugin = { + name: 'polling-scheduler-local-mock-api', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url, 'http://127.0.0.1'), + path = url.pathname + if (path === '/__scheduler-upload') { + for await (const _ of req) { + } + res.end('') + return + } + if (path === '/__scheduler-log') { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(log)) + return + } + const handled = + /^\/api\/admin\/(polling-carrier-params|polling-check-scenes|polling-failure-alerts|card-polling-pauses|polling-status-imports|polling-status\/impact|stop-resume-rule\/dry-run|polling-stats\/carrier|storage\/upload-url)/.test( + path + ) || + /\/polling-state$|\/batch-update-polling-status$/.test(path) || + ['/api/auth/me', '/api/auth/login'].includes(path) + if (!handled) return next() + let raw = '' + for await (const chunk of req) raw += chunk + const input = raw ? JSON.parse(raw) : {}, + token = String(req.headers.authorization || ''), + type = token.includes('agent') + ? 3 + : token.includes('enterprise') + ? 4 + : token.includes('admin') + ? 1 + : 2 + log.push({ + path, + method: req.method, + query: Object.fromEntries(url.searchParams), + body: input + }) + let data = {}, + code = 0 + if (path.startsWith('/api/auth/')) + data = { + access_token: `local-scheduler-${type === 1 ? 'admin' : type === 3 ? 'agent' : type === 4 ? 'enterprise' : 'platform'}`, + refresh_token: '', + expires_in: 3600, + user: { + id: type, + user_type: type, + username: '本地模拟账号', + roles: [type === 1 ? 'R_SUPER' : 'R_ADMIN'] + }, + permissions: menus.map((row) => row[2]).filter(Boolean), + buttons: permissions, + menus: [ + { + url: '/polling-management', + name: '轮询管理', + children: [ + ...menus, + [ + '运营商账户调度参数详情', + 'carrier-params/detail/:carrier_id', + 'polling_carrier_params:detail_page' + ], + ['失败告警详情', 'failure-alerts/detail/:id', 'polling_failure_alert:detail_page'], + [ + '导入任务详情', + 'polling-status-imports/detail/:id', + 'polling_status_import:detail_page' + ] + ].map(([name, path, perm_code]) => ({ + name, + url: `/polling-management/${path}`, + perm_code + })) + } + ] + } + else if (path === '/api/admin/polling-carrier-params') + data = { + default_card_max_in_flight: 10, + default_inflight_limit: 20, + subscriber_processes: 3, + items: [params] + } + else if (path.startsWith('/api/admin/polling-carrier-params/')) data = { ...params, ...input } + else if (path.startsWith('/api/admin/polling-check-scenes')) + data = [ + { + scene: 'order_paid', + scene_name: '订单支付', + check_types: ['carddata', 'card_status'], + confirm_delays: [30, 60], + cooldown: null, + updated_at: time + } + ] + else if (path === '/api/admin/polling-stats/carrier-queues') + data = { + scheduler_mode: 'carrier', + init_state: 'completed', + items: [ + { + carrier_id: 8, + carrier_name: params.carrier_name, + queue_length: 10, + priority_length: 1, + due_count: 2, + running: 1, + inflight_limit: 20, + qps_last_minute: 0.2, + rate_limit_waits: 1, + rate_limited: 0, + expired_claims_1h: 0, + open_alerts: 2, + open_alerts_by_category: { terminal: 2 }, + stop_resume_queue_length: 5, + stop_resume_due: 1, + stop_resume_running: 1, + stop_resume_pending: 2, + stop_resume_success_1h: 10, + stop_resume_failed_1h: 2, + stop_resume_retry_1h: 3, + stop_resume_unknown_1h: 2 + } + ] + } + else if (path === '/api/admin/polling-stats/carriers') + data = { + minutes: 15, + mode: 'observe', + carriers: [{ carrier_id: 8, carrier_name: params.carrier_name, operations: [operation] }] + } + else if (path.endsWith('/trend')) + data = { carrier_id: 8, points: [{ time: '2026-10-26 10:00', operations: [operation] }] } + else if (path === '/api/admin/polling-failure-alerts/summary') + data = [ + { + carrier_id: 8, + carrier_name: params.carrier_name, + error_category: 'terminal', + open_count: 2 + } + ] + else if (path === '/api/admin/polling-failure-alerts') + data = page( + [ + alert, + { ...alert, id: 2, alert_level: 'carrier', card_id: 0, iccid: '', check_type: 'all' } + ], + url + ) + else if (path.endsWith('/close-polling') || path.endsWith('/batch-update-polling-status')) + data = { + success_count: 1, + skip_count: 1, + fail_count: 2, + impact: ['本地模拟影响'], + items: task.items + } + else if (path.startsWith('/api/admin/polling-failure-alerts/')) data = alert + else if (path === '/api/admin/card-polling-pauses') data = page([pause], url) + else if (path === '/api/admin/polling-status/impact') + data = { + enable: ['本地模拟:按配置查询并自动停复机。'], + disable: ['本地模拟:关闭查询及部分自动停复机;部分业务仍生效。'] + } + else if (path === '/api/admin/polling-status-imports') + data = + req.method === 'POST' ? { ...task, status: 1, status_name: '待处理' } : page([task], url) + else if (path.startsWith('/api/admin/polling-status-imports/')) data = task + else if (path === '/api/admin/storage/upload-url') + data = { + upload_url: 'http://127.0.0.1:5190/__scheduler-upload', + file_key: 'polling-status-imports/mock.csv' + } + else if (path.endsWith('/polling-state')) + data = { + card_id: 42, + carrier_id: 8, + iccid: alert.iccid, + enable_polling: true, + scheduler_mode: 'carrier', + queue: 'priority', + queue_score_at: time, + running: false, + priority_mark: false, + claim_expire_at: '', + stop_resume_queue_enabled: true, + last_stop_resume_call_at: time, + stop_resume_command: { + action: 'stop', + reason: 'manual', + scenes: ['order_paid'], + source: 'order', + version: 1, + fail_count: 0, + last_error: '', + pending: true, + unknown: 1, + written_at: time, + next_at: time + }, + types: [ + { + check_type: 'carddata', + check_type_name: '流量', + next_due_at: time, + last_success_at: time, + fail_count: 0, + first_failed_at: '', + last_error: '', + pending_confirm: [30, 60] + } + ] + } + else if (path === '/api/admin/stop-resume-rule/dry-run') + data = { + ...page( + [ + { + card_id: 42, + carrier_id: 8, + carrier_name: params.carrier_name, + iccid: alert.iccid, + device_id: 0, + network_status: 1, + card_category: 'normal', + realname_ok: false, + stop_reason: 'none', + stop_reason_name: '无', + result: 'will_stop', + result_name: '将停机', + reason: 'not_realname', + reason_name: '未实名' + } + ], + url + ), + totals: { total: 100, will_stop: 20, will_resume: 10, no_action: 70 }, + summary: [ + { + carrier_id: 8, + carrier_name: params.carrier_name, + stop_reason_name: '无', + result_name: '将停机', + reason_name: '未实名', + count: 20 + } + ] + } + else if (path.endsWith('/export')) + data = { task_id: 1, task_no: 'MOCK-EXPORT-1', status: 1, status_name: '待处理' } + else code = 400 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify({ code, msg: 'local mock only', data })) + }) + polling.configureServer(server) + } +} diff --git a/scripts/tests/verifyBuild.mjs b/scripts/tests/verifyBuild.mjs index 010b283..4854a1b 100644 --- a/scripts/tests/verifyBuild.mjs +++ b/scripts/tests/verifyBuild.mjs @@ -41,9 +41,13 @@ plugins.push( ) if (serve) { const { mockApiPlugin } = await import( - process.argv.includes('--gateway') - ? './gatewayBrowserFixtures.mjs' - : './octoberBrowserFixtures.mjs' + process.argv.includes('--scheduler') + ? './pollingSchedulerBrowserFixtures.mjs' + : process.argv.includes('--polling') + ? './pollingConfigBrowserFixtures.mjs' + : process.argv.includes('--gateway') + ? './gatewayBrowserFixtures.mjs' + : './octoberBrowserFixtures.mjs' ) const server = await createServer({ ...loaded.config, diff --git a/src/api/modules/carrier.ts b/src/api/modules/carrier.ts index 29331d6..6a08788 100644 --- a/src/api/modules/carrier.ts +++ b/src/api/modules/carrier.ts @@ -39,7 +39,9 @@ export class CarrierService extends BaseService { * @param data 运营商数据 */ static updateCarrier(id: number, data: UpdateCarrierParams): Promise> { - return this.update(`/api/admin/carriers/${id}`, data) + return this.put(`/api/admin/carriers/${id}`, data, { + requestOptions: { errorMessageMode: 'none' } + }) } /** diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index 4b9c9b8..27e346e 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -61,3 +61,4 @@ export { AssetWalletService } from './assetWallet' export { OperationsReportsService } from './operationsReports' export { GatewayService } from './gateway' +export { PollingSchedulerService } from './pollingScheduler' diff --git a/src/api/modules/pollingConfig.ts b/src/api/modules/pollingConfig.ts index c150b0f..a6510dc 100644 --- a/src/api/modules/pollingConfig.ts +++ b/src/api/modules/pollingConfig.ts @@ -3,6 +3,13 @@ */ import { BaseService } from '../BaseService' import type { BaseResponse } from '@/types/api' +import type { + PollingMatchPreview, + PollingPreviewRequest, + PollingRecalculateTask, + PollingPreResetStats, + PollingTierStats +} from '@/types/api/pollingConfigExtensions' import type { PollingConfig, PollingConfigQueryParams, @@ -16,6 +23,37 @@ import type { * 轮询配置管理服务 */ export class PollingConfigService extends BaseService { + static previewMatch(iccid: string): Promise> { + return this.get('/api/admin/polling-configs/match-preview', { iccid }) + } + + static previewDraft(data: PollingPreviewRequest): Promise> { + return this.post('/api/admin/polling-configs/match-preview', data, { + params: { iccid: data.iccid } + }) + } + + // 文档未定义成功 data,不假定返回新的配置列表。 + static updatePriorities(orderedIds: number[]): Promise | void> { + return this.put('/api/admin/polling-configs/priorities', { ordered_ids: orderedIds }) + } + + static recalculateDue(carrierId: number | null): Promise> { + return this.post('/api/admin/polling-configs/recalculate-due', { carrier_id: carrierId }) + } + + static getRecalculateTask(taskId: string): Promise> { + return this.get(`/api/admin/polling-configs/recalculate-due/${encodeURIComponent(taskId)}`) + } + + static getPreResetStats(): Promise> { + return this.get('/api/admin/polling-stats/pre-reset') + } + + static getTierStats(): Promise> { + return this.get('/api/admin/polling-stats/tiers') + } + /** * 获取轮询配置列表 * @param params 查询参数 diff --git a/src/api/modules/pollingScheduler.ts b/src/api/modules/pollingScheduler.ts new file mode 100644 index 0000000..6efe3af --- /dev/null +++ b/src/api/modules/pollingScheduler.ts @@ -0,0 +1,109 @@ +import { BaseService } from '../BaseService' +import type { BaseResponse, PaginationParams } from '@/types/api/common' +import type { + CarrierParamsInput, + CarrierParamsList, + CarrierPollingParams, + PollingCheckScene, + CheckSceneInput, + PollingFailureQuery, + PollingFailureAlert, + PollingFailureSummary, + SchedulerPage, + CarrierQueues, + CardPollingState, + PollingImpact, + PollingBatchResult, + PollingToggleInput, + PollingPauseQuery, + PollingPause, + PollingImportInput, + PollingImportTask, + StopResumeDryQuery, + StopResumeDryRun +} from '@/types/api/pollingScheduler' +import type { CreateExportTaskResponse } from '@/types/api/exportTask' + +export class PollingSchedulerService extends BaseService { + static getCarrierParams(): Promise> { + return this.get('/api/admin/polling-carrier-params') + } + static getCarrierParam(id: number): Promise> { + return this.get(`/api/admin/polling-carrier-params/${id}`) + } + static saveCarrierParam( + id: number, + data: CarrierParamsInput + ): Promise> { + return this.put(`/api/admin/polling-carrier-params/${id}`, data) + } + static resetCarrierParam(id: number): Promise> { + return this.delete(`/api/admin/polling-carrier-params/${id}`) + } + static getScenes(): Promise> { + return this.get('/api/admin/polling-check-scenes') + } + static saveScene(scene: string, data: CheckSceneInput): Promise> { + return this.put(`/api/admin/polling-check-scenes/${encodeURIComponent(scene)}`, data) + } + static getAlerts( + params: PollingFailureQuery + ): Promise>> { + return this.get('/api/admin/polling-failure-alerts', params) + } + static getAlertSummary(): Promise> { + return this.get('/api/admin/polling-failure-alerts/summary') + } + static getAlert(id: number): Promise> { + return this.get(`/api/admin/polling-failure-alerts/${id}`) + } + static handleAlert(id: number, remark: string): Promise> { + return this.put(`/api/admin/polling-failure-alerts/${id}/status`, { status: 4, remark }) + } + static closeAlertPolling( + alertIds: number[], + reason: string + ): Promise> { + return this.post('/api/admin/polling-failure-alerts/close-polling', { + alert_ids: alertIds, + reason + }) + } + static getQueues(): Promise> { + return this.get('/api/admin/polling-stats/carrier-queues') + } + static getCardState(id: number): Promise> { + return this.get(`/api/admin/iot-cards/${id}/polling-state`) + } + static getImpact(): Promise> { + return this.get('/api/admin/polling-status/impact') + } + static toggleCards(data: PollingToggleInput): Promise> { + return this.post('/api/admin/iot-cards/batch-update-polling-status', data) + } + static toggleDevices(data: PollingToggleInput): Promise> { + return this.post('/api/admin/devices/batch-update-polling-status', data) + } + static getPauses(params: PollingPauseQuery): Promise>> { + return this.get('/api/admin/card-polling-pauses', params) + } + static getImports( + params: PaginationParams & { status?: number } + ): Promise>> { + return this.get('/api/admin/polling-status-imports', params) + } + static getImport(id: number): Promise> { + return this.get(`/api/admin/polling-status-imports/${id}`) + } + static createImport(data: PollingImportInput): Promise> { + return this.post('/api/admin/polling-status-imports', data) + } + static dryRun(params: StopResumeDryQuery): Promise> { + return this.get('/api/admin/stop-resume-rule/dry-run', params) + } + static exportDryRun( + data: Omit & { format: 'csv' | 'xlsx' } + ): Promise> { + return this.post('/api/admin/stop-resume-rule/dry-run/export', data) + } +} diff --git a/src/api/modules/storage.ts b/src/api/modules/storage.ts index 42cd525..94cc413 100644 --- a/src/api/modules/storage.ts +++ b/src/api/modules/storage.ts @@ -24,6 +24,7 @@ const triggerBrowserDownload = (downloadUrl: string, fileName: string) => { * 文件用途枚举 */ export type FilePurpose = + | 'polling_status_import' | 'iot_import' | 'batch_purchase' | 'device_batch_allocation' diff --git a/src/components/business/ExportTaskCreateDialog.vue b/src/components/business/ExportTaskCreateDialog.vue index 3ddae8c..b15fa82 100644 --- a/src/components/business/ExportTaskCreateDialog.vue +++ b/src/components/business/ExportTaskCreateDialog.vue @@ -38,6 +38,9 @@ import { ExportTaskService } from '@/api/modules' import { useAuth } from '@/composables/useAuth' import { getExportTaskSceneName } from '@/config/constants' + import { isPollingPlatformExport } from '@/config/constants/exportTask' + import { useUserStore } from '@/store/modules/user' + import { isPollingPlatform } from '@/utils/business/pollingScheduler' import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api' const props = withDefaults( @@ -64,6 +67,7 @@ }>() const { hasAuth } = useAuth() + const user = useUserStore() const submitting = ref(false) const form = reactive({ format: props.defaultFormat @@ -107,6 +111,10 @@ } const submit = async () => { + if (isPollingPlatformExport(props.scene) && !isPollingPlatform(user.info.user_type)) { + ElMessage.warning('仅平台账号可创建该导出任务') + return + } if (props.confirmPermission && !hasAuth(props.confirmPermission)) { ElMessage.warning('您没有创建导出任务的权限') return diff --git a/src/components/business/gateway/GatewayOperationTable.vue b/src/components/business/gateway/GatewayOperationTable.vue new file mode 100644 index 0000000..3a147a9 --- /dev/null +++ b/src/components/business/gateway/GatewayOperationTable.vue @@ -0,0 +1,58 @@ + + diff --git a/src/components/business/gateway/GatewayRuleFields.vue b/src/components/business/gateway/GatewayRuleFields.vue index ea6d4dd..8ef135d 100644 --- a/src/components/business/gateway/GatewayRuleFields.vue +++ b/src/components/business/gateway/GatewayRuleFields.vue @@ -19,6 +19,9 @@ /> + 仅终态类别生效 数值越小越先匹配 diff --git a/src/components/business/polling/NullableNumber.vue b/src/components/business/polling/NullableNumber.vue new file mode 100644 index 0000000..139fdb1 --- /dev/null +++ b/src/components/business/polling/NullableNumber.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/business/polling/PollingBatchDialog.vue b/src/components/business/polling/PollingBatchDialog.vue new file mode 100644 index 0000000..c44616a --- /dev/null +++ b/src/components/business/polling/PollingBatchDialog.vue @@ -0,0 +1,174 @@ + + diff --git a/src/components/business/polling/PollingImportDialog.vue b/src/components/business/polling/PollingImportDialog.vue new file mode 100644 index 0000000..5fce914 --- /dev/null +++ b/src/components/business/polling/PollingImportDialog.vue @@ -0,0 +1,125 @@ + + diff --git a/src/components/business/polling/PollingStateDialog.vue b/src/components/business/polling/PollingStateDialog.vue new file mode 100644 index 0000000..b7227dd --- /dev/null +++ b/src/components/business/polling/PollingStateDialog.vue @@ -0,0 +1,147 @@ + + + diff --git a/src/components/business/polling/SchedulerDetail.vue b/src/components/business/polling/SchedulerDetail.vue new file mode 100644 index 0000000..9ad7a8c --- /dev/null +++ b/src/components/business/polling/SchedulerDetail.vue @@ -0,0 +1,196 @@ + + + diff --git a/src/components/business/polling/SchedulerTable.vue b/src/components/business/polling/SchedulerTable.vue new file mode 100644 index 0000000..8f11397 --- /dev/null +++ b/src/components/business/polling/SchedulerTable.vue @@ -0,0 +1,57 @@ + + diff --git a/src/composables/usePollingConfigExtensions.ts b/src/composables/usePollingConfigExtensions.ts new file mode 100644 index 0000000..88eafef --- /dev/null +++ b/src/composables/usePollingConfigExtensions.ts @@ -0,0 +1,125 @@ +import { ref, onBeforeUnmount, onDeactivated, onMounted, watch } from 'vue' +import { PollingConfigService } from '@/api/modules/pollingConfig' +import type { PollingConfig } from '@/types/api/pollingConfig' +import type { PollingRecalculateTask } from '@/types/api/pollingConfigExtensions' +import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway' +import { pollingTaskActive } from '@/utils/business/pollingConfig' + +export function usePollingConfigCandidates() { + const candidates = ref([]) + const loading = ref(false) + const error = ref('') + let sequence = 0 + async function search(query = '') { + const current = ++sequence + loading.value = true + error.value = '' + try { + const data = gatewayData( + await PollingConfigService.getPollingConfigs({ + page: 1, + page_size: 20, + config_name: query.trim() + }) + ) + if (current === sequence) candidates.value = data.items || [] + } catch (cause) { + if (current === sequence) { + error.value = gatewayError(cause) + candidates.value = [] + } + } finally { + if (current === sequence) loading.value = false + } + } + onMounted(() => search()) + onBeforeUnmount(() => { + ++sequence + }) + return { candidates, loading, error, search } +} + +/** 单一任务、非重叠查询;失败、终态、关闭、切换任务和卸载都撤销旧请求。 */ +export function usePollingRecalculateProgress(allowed: () => boolean) { + const taskId = ref('') + const task = ref(null) + const loading = ref(false) + const error = ref('') + const autoRefresh = ref(false) + let sequence = 0 + let timer: ReturnType | undefined + let disposed = false + function stop() { + ++sequence + if (timer) clearTimeout(timer) + timer = undefined + loading.value = false + } + async function query() { + if (disposed || loading.value) return + stop() + const current = sequence + const id = taskId.value.trim() + error.value = '' + loading.value = true + try { + requireGatewayAccess(allowed()) + if (!id) throw new Error('请输入任务 ID') + const data = gatewayData(await PollingConfigService.getRecalculateTask(id)) + if (current !== sequence || disposed) return + requireGatewayAccess(allowed()) + if (data.task_id !== id) throw new Error('返回的任务 ID 与查询任务不一致') + task.value = data + if (pollingTaskActive(data.status) && autoRefresh.value) + timer = setTimeout(() => { + void query() + }, 3000) + } catch (cause) { + if (current === sequence) error.value = gatewayError(cause) + } finally { + if (current === sequence) loading.value = false + } + } + function adopt(data: PollingRecalculateTask) { + stop() + taskId.value = data.task_id + task.value = data + error.value = '' + if (allowed() && autoRefresh.value && pollingTaskActive(data.status)) + timer = setTimeout(() => { + void query() + }, 3000) + } + // 同步 watch:修改输入的这一刻即使旧网络请求未结束也不再允许其回填。 + watch( + taskId, + () => { + stop() + task.value = null + error.value = '' + }, + { flush: 'sync' } + ) + watch( + autoRefresh, + (enabled) => { + if (!enabled) stop() + else if (!loading.value && taskId.value.trim() && pollingTaskActive(task.value?.status)) + void query() + }, + { flush: 'sync' } + ) + watch(allowed, (enabled) => { + if (!enabled) { + stop() + task.value = null + error.value = '' + } + }) + onDeactivated(stop) + onBeforeUnmount(() => { + disposed = true + stop() + }) + return { taskId, task, loading, error, autoRefresh, query, stop, adopt } +} diff --git a/src/composables/useSchedulerRequest.ts b/src/composables/useSchedulerRequest.ts new file mode 100644 index 0000000..36e9083 --- /dev/null +++ b/src/composables/useSchedulerRequest.ts @@ -0,0 +1,56 @@ +import { ref, shallowRef, onBeforeUnmount, onDeactivated, onActivated, watch } from 'vue' +import { useUserStore } from '@/store/modules/user' +import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway' +import { pollingScope } from '@/utils/business/pollingScheduler' + +export function useSchedulerRequest(request: () => Promise, allowed: () => boolean) { + const user = useUserStore() + const data = shallowRef(null), + loading = ref(false), + error = ref('') + let sequence = 0, + active = true + function clear() { + ++sequence + data.value = null + loading.value = false + error.value = '' + } + async function load() { + if (!active) return + const current = ++sequence, + scope = pollingScope(user) + loading.value = true + error.value = '' + data.value = null + try { + requireGatewayAccess(allowed()) + const result = await request() + if (active && current === sequence && scope === pollingScope(user) && allowed()) + data.value = result + } catch (cause) { + if (active && current === sequence) { + data.value = null + error.value = gatewayError(cause) + } + } finally { + if (current === sequence) loading.value = false + } + } + watch(() => [pollingScope(user), allowed()], clear, { flush: 'sync' }) + onDeactivated(() => { + active = false + clear() + }) + onActivated(() => { + if (!active) { + active = true + void load() + } + }) + onBeforeUnmount(() => { + active = false + clear() + }) + return { data, loading, error, load } +} diff --git a/src/config/constants/exportTask.ts b/src/config/constants/exportTask.ts index 7e08282..e5ebd9d 100644 --- a/src/config/constants/exportTask.ts +++ b/src/config/constants/exportTask.ts @@ -7,6 +7,9 @@ export interface ExportTaskScenePermissions { } export const EXPORT_TASK_DETAIL_PAGE_PERMISSION = 'export_task:detail_page' +export const isPollingPlatformExport = (scene?: ExportTaskScene) => + !!scene && + ['polling_pause', 'polling_stop_refused_alert', 'stop_resume_rule_dry_run'].includes(scene) export interface ExportTaskSceneConfig { scene: ExportTaskScene @@ -16,6 +19,36 @@ export interface ExportTaskSceneConfig { } export const EXPORT_TASK_SCENE_CONFIG: Record = { + polling_pause: { + scene: 'polling_pause', + sceneName: '不轮询清单', + pageTitle: '导出不轮询清单', + permissions: { + page: 'export_task:polling_pause_page', + detail: 'export_task:polling_pause_detail', + download: 'export_task:polling_pause_download' + } + }, + polling_stop_refused_alert: { + scene: 'polling_stop_refused_alert', + sceneName: '停机被拒告警', + pageTitle: '导出停机被拒告警', + permissions: { + page: 'export_task:polling_stop_refused_alert_page', + detail: 'export_task:polling_stop_refused_alert_detail', + download: 'export_task:polling_stop_refused_alert_download' + } + }, + stop_resume_rule_dry_run: { + scene: 'stop_resume_rule_dry_run', + sceneName: '停复机规则演练', + pageTitle: '导出停复机规则演练', + permissions: { + page: 'export_task:stop_resume_rule_dry_run_page', + detail: 'export_task:stop_resume_rule_dry_run_detail', + download: 'export_task:stop_resume_rule_dry_run_download' + } + }, device: { scene: 'device', sceneName: '设备管理', diff --git a/src/config/constants/pollingConfig.ts b/src/config/constants/pollingConfig.ts new file mode 100644 index 0000000..a71ea17 --- /dev/null +++ b/src/config/constants/pollingConfig.ts @@ -0,0 +1,43 @@ +export const POLLING_CONFIG_PERMISSIONS = { + preview: 'polling_config:match_preview', + progress: 'polling_config:recalculate_progress', + statsPage: 'polling_config_stats:page', + preReset: 'polling_config_stats:pre_reset', + tiers: 'polling_config_stats:tiers' +} as const + +export const POLLING_ASSET_STATUSES = [ + { label: '在库', value: 1 }, + { label: '已销售', value: 2 }, + { label: '已换货', value: 3 }, + { label: '已停用', value: 4 } +] +export const POLLING_PACKAGE_STATES = [ + { label: '有生效套餐', value: 'active' }, + { label: '套餐已用完', value: 'depleted' }, + { label: '只有待生效套餐', value: 'pending' }, + { label: '无套餐', value: 'none' } +] +export const POLLING_CARD_CATEGORIES = [ + { label: '普通卡', value: 'normal' }, + { label: '行业卡', value: 'industry' } +] +export const POLLING_CARD_CONDITIONS = [ + { label: '未实名', value: 'not_real_name' }, + { label: '已实名', value: 'real_name' }, + { label: '已激活', value: 'activated' }, + { label: '已停用', value: 'suspended' } +] +export const POLLING_DRAFT_INTERVALS = [ + { field: 'realname_check_interval', label: '实名检查', min: 30 }, + { field: 'carddata_check_interval', label: '流量检查', min: 60 }, + { field: 'package_check_interval', label: '套餐检查', min: 60 }, + { field: 'card_status_check_interval', label: '卡状态检查', min: 30 } +] as const + +export const pollingOptionLabel = ( + options: { label: string; value: string | number }[], + value: unknown +) => + options.find((option) => option.value === value)?.label || + (value == null || value === '' ? '-' : String(value)) diff --git a/src/config/constants/pollingScheduler.ts b/src/config/constants/pollingScheduler.ts new file mode 100644 index 0000000..f348acd --- /dev/null +++ b/src/config/constants/pollingScheduler.ts @@ -0,0 +1,53 @@ +export const SCHEDULER_PERMISSIONS = { + params: { + page: 'polling_carrier_params:page', + detailPage: 'polling_carrier_params:detail_page', + detail: 'polling_carrier_params:detail' + }, + scenes: { page: 'polling_check_scene:page' }, + alerts: { + page: 'polling_failure_alert:page', + detailPage: 'polling_failure_alert:detail_page', + detail: 'polling_failure_alert:detail', + summary: 'polling_failure_alert:summary', + handle: 'polling_failure_alert:handle', + close: 'polling_failure_alert:close_polling', + export: 'polling_failure_alert:export' + }, + queues: { page: 'polling_carrier_queue:page' }, + dryRun: { page: 'stop_resume_dry_run:page', export: 'stop_resume_dry_run:export' }, + pauses: { + page: 'polling_pause:page', + restore: 'polling_pause:restore', + export: 'polling_pause:export' + }, + imports: { + page: 'polling_status_import:page', + create: 'polling_status_import:create', + detailPage: 'polling_status_import:detail_page', + detail: 'polling_status_import:detail' + }, + cardState: 'iot_info:polling_state', + deviceCardState: 'device_info:polling_state', + cardBatch: 'iot_card:batch_polling', + deviceBatch: 'device:batch_polling' +} as const +export const POLLING_CLOSE_SOURCES = [ + ['single', '单资产接口'], + ['batch', '勾选批量'], + ['import', '文件导入'], + ['risk_auto', '风险停机或已销户自动关闭'], + ['cascade', '设备级联'], + ['order', '支付或下单生效'], + ['expiry_change', '修改有效期'], + ['alert', '停机被拒告警处理'], + ['history', '历史数据'] +].map(([value, label]) => ({ value, label })) +export const POLLING_CHECK_TYPES = [ + ['card_status', '卡状态'], + ['carddata', '流量'], + ['realname', '实名'], + ['package', '套餐'], + ['stop_resume', '停复机'], + ['all', '账户级'] +].map(([value, label]) => ({ value, label })) diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index c7cea7e..648135e 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -5,6 +5,8 @@ import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' import { AUDIT_PERMISSIONS } from '@/config/constants/audit' import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration' import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway' +import { POLLING_CONFIG_PERMISSIONS } from '@/config/constants/pollingConfig' +import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler' import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools' import { EXPORT_TASK_DETAIL_PAGE_PERMISSION, @@ -797,6 +799,42 @@ export const asyncRoutes: AppRouteRecord[] = [ permissions: [EXPORT_TASK_SCENE_CONFIG.operations_renewal.permissions.page] } }, + { + path: 'export-polling-pause', + name: 'ExportPollingPause', + component: RoutesAlias.ExportPollingPause, + meta: { + title: '导出不轮询清单', + exportTaskScene: 'polling_pause', + keepAlive: true, + allowedUserTypes: [1, 2], + permissions: [EXPORT_TASK_SCENE_CONFIG.polling_pause.permissions.page] + } + }, + { + path: 'export-polling-stop-refused-alert', + name: 'ExportPollingStopRefusedAlert', + component: RoutesAlias.ExportPollingStopRefusedAlert, + meta: { + title: '导出停机被拒告警', + exportTaskScene: 'polling_stop_refused_alert', + keepAlive: true, + allowedUserTypes: [1, 2], + permissions: [EXPORT_TASK_SCENE_CONFIG.polling_stop_refused_alert.permissions.page] + } + }, + { + path: 'export-stop-resume-rule-dry-run', + name: 'ExportStopResumeDryRun', + component: RoutesAlias.ExportStopResumeDryRun, + meta: { + title: '导出停复机规则演练', + exportTaskScene: 'stop_resume_rule_dry_run', + keepAlive: true, + allowedUserTypes: [1, 2], + permissions: [EXPORT_TASK_SCENE_CONFIG.stop_resume_rule_dry_run.permissions.page] + } + }, { path: 'export-task-detail', name: 'ExportTaskDetail', @@ -1268,6 +1306,113 @@ export const asyncRoutes: AppRouteRecord[] = [ icon: '' }, children: [ + { + path: 'carrier-params', + name: 'PollingCarrierParams', + component: RoutesAlias.PollingCarrierParams, + meta: { + title: '运营商账户调度参数', + keepAlive: true, + permissions: [SCHEDULER_PERMISSIONS.params.page] + } + }, + { + path: 'carrier-params/detail/:carrier_id', + name: 'PollingCarrierParamDetail', + component: RoutesAlias.PollingCarrierParamDetail, + meta: { + title: '运营商账户调度参数详情', + keepAlive: false, + isHide: true, + permissions: [SCHEDULER_PERMISSIONS.params.detailPage] + } + }, + { + path: 'check-scenes', + name: 'PollingCheckScenes', + component: RoutesAlias.PollingCheckScenes, + meta: { + title: '请求检查场景', + keepAlive: true, + permissions: [SCHEDULER_PERMISSIONS.scenes.page] + } + }, + { + path: 'failure-alerts', + name: 'PollingFailureAlerts', + component: RoutesAlias.PollingFailureAlerts, + meta: { + title: '失败告警', + keepAlive: true, + permissions: [SCHEDULER_PERMISSIONS.alerts.page] + } + }, + { + path: 'failure-alerts/detail/:id', + name: 'PollingFailureAlertDetail', + component: RoutesAlias.PollingFailureAlertDetail, + meta: { + title: '失败告警详情', + keepAlive: false, + isHide: true, + permissions: [SCHEDULER_PERMISSIONS.alerts.detailPage] + } + }, + { + path: 'carrier-queues', + name: 'PollingCarrierQueues', + component: RoutesAlias.PollingCarrierQueues, + meta: { + title: '账户队列监控', + keepAlive: true, + permissions: [SCHEDULER_PERMISSIONS.queues.page] + } + }, + { + path: 'stop-resume-dry-run', + name: 'StopResumeDryRun', + component: RoutesAlias.StopResumeDryRun, + meta: { + title: '停复机规则演练', + keepAlive: true, + allowedUserTypes: [1, 2], + permissions: [SCHEDULER_PERMISSIONS.dryRun.page] + } + }, + { + path: 'polling-pauses', + name: 'PollingPauses', + component: RoutesAlias.PollingPauses, + meta: { + title: '不轮询清单', + keepAlive: true, + allowedUserTypes: [1, 2], + permissions: [SCHEDULER_PERMISSIONS.pauses.page] + } + }, + { + path: 'polling-status-imports', + name: 'PollingStatusImports', + component: RoutesAlias.PollingStatusImports, + meta: { + title: '轮询开关导入', + keepAlive: true, + allowedUserTypes: [1, 2], + permissions: [SCHEDULER_PERMISSIONS.imports.page] + } + }, + { + path: 'polling-status-imports/detail/:id', + name: 'PollingStatusImportDetail', + component: RoutesAlias.PollingStatusImportDetail, + meta: { + title: '轮询开关导入详情', + keepAlive: false, + isHide: true, + allowedUserTypes: [1, 2], + permissions: [SCHEDULER_PERMISSIONS.imports.detailPage] + } + }, // 数据清理 { path: 'data-cleanup', @@ -1318,7 +1463,17 @@ export const asyncRoutes: AppRouteRecord[] = [ keepAlive: true } }, - // 手动触发 + // 轮询配置统计 + { + path: 'config-stats', + name: 'PollingConfigStats', + component: RoutesAlias.PollingConfigStats, + meta: { + title: '轮询配置统计', + keepAlive: true, + permissions: [POLLING_CONFIG_PERMISSIONS.statsPage] + } + }, { path: 'manual-trigger', name: 'ManualTrigger', diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index a4bdb39..03ce2ae 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -141,6 +141,20 @@ export enum RoutesAlias { AlertHistory = '/polling-management/alert-history', // 告警历史 PollingConcurrency = '/polling-management/concurrency', // 并发控制 PollingConfig = '/polling-management/config', // 轮询配置 + PollingConfigStats = '/polling-management/config-stats', // 轮询配置统计 + PollingCarrierParams = '/polling-management/carrier-params', + PollingCarrierParamDetail = '/polling-management/carrier-params/detail', + PollingCheckScenes = '/polling-management/check-scenes', + PollingFailureAlerts = '/polling-management/failure-alerts', + PollingFailureAlertDetail = '/polling-management/failure-alerts/detail', + PollingCarrierQueues = '/polling-management/carrier-queues', + StopResumeDryRun = '/polling-management/stop-resume-dry-run', + PollingPauses = '/polling-management/polling-pauses', + PollingStatusImports = '/polling-management/polling-status-imports', + PollingStatusImportDetail = '/polling-management/polling-status-imports/detail', + ExportPollingPause = '/asset-management/export-task-management/export-polling-pause', + ExportPollingStopRefusedAlert = '/asset-management/export-task-management/export-polling-stop-refused-alert', + ExportStopResumeDryRun = '/asset-management/export-task-management/export-stop-resume-rule-dry-run', ManualTrigger = '/polling-management/manual-trigger', // 手动触发 PollingMonitor = '/polling-management/monitor', // 轮询监控 PollingPriorityQueue = '/polling-management/priority-queue', // 优先队列 diff --git a/src/types/api/asset.ts b/src/types/api/asset.ts index 27bec30..8eb7c89 100644 --- a/src/types/api/asset.ts +++ b/src/types/api/asset.ts @@ -189,7 +189,8 @@ export interface AssetResolveResponse { expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称 is_expiring?: boolean // 是否临期,由后端判断 reduction_pct: number // 展示增幅比例(小数,如 0.428571) - device_protect_status?: DeviceProtectStatus // 保护期状态:none / stop / start(仅 device) + /** @deprecated 保护期已下线,固定返回 none;不用于界面或业务判断。 */ + device_protect_status?: DeviceProtectStatus activated_at: string // 激活时间 created_at: string // 创建时间 updated_at: string // 更新时间 @@ -300,6 +301,7 @@ export interface DeviceGatewayInfo { * 对应接口:GET /api/admin/assets/:asset_type/:id/realtime-status */ export interface AssetRealtimeStatusResponse { + enable_polling?: boolean asset_type: AssetType // 资产类型 asset_id: number // 资产 ID @@ -316,7 +318,8 @@ export interface AssetRealtimeStatusResponse { last_card_status_check_at?: string | null // 卡状态同步时间 // ===== 设备专属字段 ===== - device_protect_status?: DeviceProtectStatus // 保护期(仅 device) + /** @deprecated 固定返回 none,仅兼容旧响应。 */ + device_protect_status?: DeviceProtectStatus cards?: AssetBoundCard[] // 所有绑定卡的状态(仅 device) online_status?: number // 在线状态(仅 device) last_online_time?: string | null // 最后在线时间(仅 device) diff --git a/src/types/api/card.ts b/src/types/api/card.ts index 2dda939..685fca9 100644 --- a/src/types/api/card.ts +++ b/src/types/api/card.ts @@ -355,6 +355,7 @@ export enum StandaloneCardStatus { // 单卡查询参数 export interface StandaloneCardQueryParams extends PaginationParams { + enable_polling?: boolean keyword?: string // 通用关键字搜索 has_active_package?: boolean // has active package filter real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名 diff --git a/src/types/api/device.ts b/src/types/api/device.ts index 6b8f963..a9f98c6 100644 --- a/src/types/api/device.ts +++ b/src/types/api/device.ts @@ -37,6 +37,7 @@ export type SwitchMode = '0' | '1' // 0=自动, 1=手动 // 设备信息 export interface Device { + enable_polling?: boolean id: number // 设备ID virtual_no: string // 虚拟号(原 device_no) associated_phones?: string[] // 当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组) @@ -83,6 +84,7 @@ export interface Device { // 设备查询参数 export interface DeviceQueryParams extends PaginationParams { + enable_polling?: boolean has_active_package?: boolean // has active package filter real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名 virtual_no?: string // 虚拟号(模糊查询,原 device_no) diff --git a/src/types/api/exportTask.ts b/src/types/api/exportTask.ts index 356d6f4..c18d096 100644 --- a/src/types/api/exportTask.ts +++ b/src/types/api/exportTask.ts @@ -14,6 +14,9 @@ export type ExportTaskScene = | 'expiring_asset' | 'operations_activation' | 'operations_renewal' + | 'polling_pause' + | 'polling_stop_refused_alert' + | 'stop_resume_rule_dry_run' export type ExportTaskFormat = 'xlsx' | 'csv' diff --git a/src/types/api/gateway.ts b/src/types/api/gateway.ts index 2391cec..c0b8bbb 100644 --- a/src/types/api/gateway.ts +++ b/src/types/api/gateway.ts @@ -53,6 +53,7 @@ export interface StopResumeQuery extends CardStopResumeQuery { } export interface GatewayRuleInput { + auto_disable_polling?: boolean category: string keyword: string match_type: string @@ -83,6 +84,7 @@ export interface GatewayRuleTestInput { } export interface GatewayRuleTestResult { + auto_disable_polling?: boolean matched: boolean rule_id: number category: string @@ -116,14 +118,28 @@ export interface GatewayGroupQuery extends PaginationParams { status?: number } -// 文档没有定义数组内部字段,保留真实结构,不假定指标名称。 +export interface GatewayOperationStats { + operation: string + operation_name: string + attempts: number + success: number + success_rate: number + avg_duration_ms: number + p95_duration_ms: number + avg_qps: number + peak_minute_qps: number + rate_limited: number + would_limit: number + exempt: Record + failures: Record +} export interface GatewayCarrierStats { minutes: number mode: string - carriers: unknown[] + carriers: { carrier_id: number; carrier_name: string; operations: GatewayOperationStats[] }[] } export interface GatewayCarrierTrend { carrier_id: number - points: unknown[] + points: { time: string; operations: GatewayOperationStats[] }[] } diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 8461387..fe12263 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -110,6 +110,7 @@ export type { UpdatePollingConfigRequest, UpdatePollingConfigStatusRequest } from './pollingConfig' +export * from './pollingConfigExtensions' // 手动触发相关 export * from './manualTrigger' @@ -159,3 +160,4 @@ export * from './assetWallet' // 运营报表相关 export * from './operationsReport' export * from './gateway' +export * from './pollingScheduler' diff --git a/src/types/api/pollingConfig.ts b/src/types/api/pollingConfig.ts index a622d1d..6a33d94 100644 --- a/src/types/api/pollingConfig.ts +++ b/src/types/api/pollingConfig.ts @@ -1,149 +1,51 @@ -/** - * 轮询配置管理相关类型定义 - */ +import type { PollingConfigOverride } from './pollingConfigExtensions' -/** - * 卡业务类型 - */ export type CardCategory = 'normal' | 'industry' - -/** - * 卡状态条件 - */ export type CardCondition = 'not_real_name' | 'real_name' | 'activated' | 'suspended' - -/** - * 轮询配置对象 - */ -export interface PollingConfig { - /** 配置ID */ +export type PollingClearField = + | 'carrier_id' + | 'asset_status' + | 'package_state' + | 'card_category' + | 'card_condition' + | 'realname_check_interval' + | 'package_check_interval' + | 'carddata_check_interval' + | 'card_status_check_interval' + | 'pre_reset_window' + | 'pre_reset_carddata_interval' +export interface PollingConfig extends Omit { id: number - /** 配置名称 */ - config_name: string - /** 配置说明 */ description: string - /** 卡业务类型 */ - card_category: CardCategory - /** 卡状态条件 */ - card_condition: CardCondition - /** 运营商ID */ - carrier_id: number - /** 优先级(数字越小优先级越高) */ - priority: number - /** 状态(1:启用,0:禁用) */ status: number - /** 实名检查间隔(秒,NULL表示不检查) */ - realname_check_interval: number | null - /** 套餐检查间隔(秒,NULL表示不检查) */ - package_check_interval: number | null - /** 流量检查间隔(秒,NULL表示不检查) */ - carddata_check_interval: number | null - /** 保护期一致性检查间隔(秒,NULL表示不检查) */ - protect_check_interval: number | null - /** 卡开停机状态检查间隔(秒,NULL表示不检查) */ - card_status_check_interval: number | null - /** 创建时间 */ + carrier_name?: string created_at: string - /** 更新时间 */ updated_at: string + warnings?: unknown } - -/** - * 轮询配置查询参数 - */ export interface PollingConfigQueryParams { - /** 页码(默认1) */ page?: number - /** 每页数量(1-100) */ page_size?: number - /** 状态(1:启用,0:禁用) */ status?: number - /** 卡状态条件 */ card_condition?: CardCondition - /** 卡业务类型 */ card_category?: CardCategory - /** 运营商ID */ carrier_id?: number - /** 配置名称(模糊搜索) */ config_name?: string + asset_status?: number + package_state?: string } - -/** - * 轮询配置列表响应 - */ export interface PollingConfigListResponse { - /** 配置列表 */ items: PollingConfig[] - /** 当前页 */ page: number - /** 每页数量 */ + size?: number page_size: number - /** 总数 */ total: number - /** 总页数 */ total_pages: number } - -/** - * 创建轮询配置请求参数 - */ -export interface CreatePollingConfigRequest { - /** 配置名称(1-100字符) */ - config_name: string - /** 优先级(1-1000,数字越小优先级越高) */ - priority: number - /** 配置说明(最多500字符) */ - description?: string - /** 卡业务类型 */ - card_category?: CardCategory - /** 卡状态条件 */ - card_condition?: CardCondition - /** 运营商ID */ - carrier_id?: number - /** 实名检查间隔(秒,最小30,NULL表示不检查) */ - realname_check_interval?: number | null - /** 套餐检查间隔(秒,最小60,NULL表示不检查) */ - package_check_interval?: number | null - /** 流量检查间隔(秒,最小60,NULL表示不检查) */ - carddata_check_interval?: number | null - /** 保护期一致性检查间隔(秒,最小30,NULL表示不检查) */ - protect_check_interval?: number | null - /** 卡开停机状态检查间隔(秒,最小30,NULL表示不检查) */ - card_status_check_interval?: number | null +export type CreatePollingConfigRequest = Omit +export type UpdatePollingConfigRequest = Partial & { + clear_fields?: PollingClearField[] } - -/** - * 更新轮询配置请求参数 - */ -export interface UpdatePollingConfigRequest { - /** 配置名称(1-100字符) */ - config_name?: string - /** 优先级(1-1000,数字越小优先级越高) */ - priority?: number - /** 配置说明(最多500字符) */ - description?: string - /** 卡业务类型 */ - card_category?: CardCategory - /** 卡状态条件 */ - card_condition?: CardCondition - /** 运营商ID */ - carrier_id?: number - /** 实名检查间隔(秒,最小30,NULL表示不检查) */ - realname_check_interval?: number | null - /** 套餐检查间隔(秒,最小60,NULL表示不检查) */ - package_check_interval?: number | null - /** 流量检查间隔(秒,最小60,NULL表示不检查) */ - carddata_check_interval?: number | null - /** 保护期一致性检查间隔(秒,最小30,NULL表示不检查) */ - protect_check_interval?: number | null - /** 卡开停机状态检查间隔(秒,最小30,NULL表示不检查) */ - card_status_check_interval?: number | null -} - -/** - * 更新轮询配置状态请求参数 - */ export interface UpdatePollingConfigStatusRequest { - /** 状态(1:启用,0:禁用) */ status: number } diff --git a/src/types/api/pollingConfigExtensions.ts b/src/types/api/pollingConfigExtensions.ts new file mode 100644 index 0000000..4f20d17 --- /dev/null +++ b/src/types/api/pollingConfigExtensions.ts @@ -0,0 +1,87 @@ +/** 草稿预览与已升级持久化字段共用的配置契约。 */ +export interface PollingConfigOverride { + id: number + config_name: string + priority: number + description?: string + carrier_id?: number | null + asset_status?: number | null + card_category?: string + card_condition?: string + package_state?: string | null + realname_check_interval?: number | null + carddata_check_interval?: number | null + package_check_interval?: number | null + card_status_check_interval?: number | null + pre_reset_window?: number | null + pre_reset_carddata_interval?: number | null + disabled?: boolean +} + +export interface PollingMatchPreview { + card: { + asset_status: number + card_category: string + card_condition: string + card_id: number + carrier_id: number + enable_polling: boolean + iccid: string + package_state: string | null + package_state_name: string + } + matched: { config_name: string; draft: boolean; id: number; priority: number }[] + intervals: { + config_id: number + config_name: string + inherited: boolean + interval: number | null + mode: string + task_type: string + task_type_name: string + }[] +} + +export interface PollingPreviewRequest { + iccid: string + overrides?: PollingConfigOverride[] +} + +export interface PollingRecalculateTask { + task_id: string + status: string + total: number + processed: number + advanced: number + carrier_id: string | null + error: string + created_at: string + started_at: string + finished_at: string +} + +export interface PollingPreResetPeriod { + active_package_cards: number + checked_in_window: number + not_checked_in_window: number + last_offset_buckets: { le_5: number; le_15: number; le_30: number; le_60: number; gt_60: number } + reset_at: string +} + +export interface PollingPreResetStats { + carriers: { + carrier_id: number + carrier_name: string + previous: PollingPreResetPeriod | null + upcoming: PollingPreResetPeriod | null + }[] +} + +export interface PollingTierStats { + by_config: { cards: number; config_id: number; config_name: string }[] + by_package_state: Record + computed_at: string + total_cards: number + unmatched_in_stock: number + unmatched_sold_no_package: number +} diff --git a/src/types/api/pollingScheduler.ts b/src/types/api/pollingScheduler.ts new file mode 100644 index 0000000..8f7d8ff --- /dev/null +++ b/src/types/api/pollingScheduler.ts @@ -0,0 +1,284 @@ +import type { PaginationParams } from './common' + +export interface SchedulerPage { + items: T[] + total: number + page: number + size: number +} +export interface CarrierPollingParams { + carrier_id: number + carrier_name: string + is_default: boolean + configured_card_max_in_flight: number | null + configured_inflight_limit: number | null + card_max_in_flight: number + inflight_limit: number + consume_capacity: number + priority_max_ratio: number | null + sr_inflight_limit: number | null + stop_resume_confirm_delay: number | null + stop_resume_max_in_flight: number | null + stop_resume_min_interval: number | null +} +export interface CarrierParamsInput { + card_max_in_flight: number | null + inflight_limit: number | null + priority_max_ratio: number | null + sr_inflight_limit: number | null + stop_resume_confirm_delay: number | null + stop_resume_max_in_flight: number | null + stop_resume_min_interval: number | null +} +export interface CarrierParamsList { + default_card_max_in_flight: number + default_inflight_limit: number + subscriber_processes: number + items: CarrierPollingParams[] +} +export interface PollingCheckScene { + scene: string + scene_name: string + check_types: string[] + confirm_delays: number[] + cooldown: number | null + updated_at: string +} +export type CheckSceneInput = Pick +export interface PollingFailureQuery extends PaginationParams { + carrier_id?: number + check_type?: string + view?: string + error_category?: string + status?: number + alert_level?: string + iccid?: string + start_time?: string + end_time?: string +} +export interface PollingFailureAlert { + id: number + alert_level: string + card_id: number + carrier_id: number + carrier_name: string + check_type: string + check_type_name: string + created_at: string + device_id?: number + enable_polling?: boolean + error_category: string + fail_count: number + first_failed_at: string + handle_remark: string + handled_at: string + handled_by: number + handled_by_name?: string + has_package?: boolean + iccid: string + last_error: string + last_failed_at: string + network_status?: number + recovered_at: string + status: number + status_name: string + stop_reason?: string +} +export interface PollingFailureSummary { + carrier_id: number + carrier_name: string + error_category: string + open_count: number +} +export interface CarrierQueue { + carrier_id: number + carrier_name: string + due_count: number + expired_claims_1h: number + inflight_limit: number + open_alerts: number + open_alerts_by_category: Record + priority_length: number + qps_last_minute: number + queue_length: number + rate_limit_waits: number + rate_limited: number + running: number + stop_resume_due: number + stop_resume_failed_1h: number + stop_resume_pending: number + stop_resume_queue_length: number + stop_resume_retry_1h: number + stop_resume_running: number + stop_resume_success_1h: number + stop_resume_unknown_1h: number +} +export interface CarrierQueues { + init_state: string + scheduler_mode: string + items: CarrierQueue[] +} +export interface CardPollingState { + card_id: number + carrier_id: number + claim_expire_at: string + enable_polling: boolean + iccid: string + last_stop_resume_call_at: string + priority_mark: boolean + queue: string + queue_score_at: string + running: boolean + scheduler_mode: string + stop_resume_queue_enabled: boolean + stop_resume_command: null | { + action: string + fail_count: number + last_error: string + next_at: string + pending: boolean + reason: string + scenes: string[] + source: string + unknown: number + version: number + written_at: string + } + types: { + check_type: string + check_type_name: string + fail_count: number + first_failed_at: string + last_error: string + last_success_at: string + next_due_at: string + pending_confirm: number[] + }[] +} +export interface PollingImpact { + disable: string[] + enable: string[] +} +export interface PollingBatchResult { + success_count: number + skip_count: number + fail_count: number + impact: string[] + items: { + asset_id: number + identifier: string + reason: string + status: number + status_name: string + }[] +} +export interface PollingToggleInput { + asset_ids: number[] + enable_polling: boolean + reason: string +} +export interface PollingPauseQuery extends PaginationParams { + asset_type?: string + carrier_id?: number + source?: string + status?: number + identifier?: string + operator_id?: number + start_time?: string + end_time?: string +} +export interface PollingPause { + id: number + asset_id: number + asset_type: string + carrier_id: number + carrier_name: string + close_reason: string + close_source: string + close_source_name: string + closed_at: string + closed_by: number + closed_by_name: string + enable_polling: boolean + gateway_extend: string + identifier: string + import_task_id: number + restore_reason: string + restore_source: string + restore_source_name: string + restored_at: string + restored_by: number + restored_by_name: string + status: number + status_name: string +} +export interface PollingImportInput { + file_key: string + asset_type: 'iot_card' | 'device' + enable_polling: boolean + reason: string +} +export interface PollingImportTask { + id: number + asset_type: string + completed_at: string + created_at: string + creator_name: string + enable_polling: boolean + error_message: string + fail_count: number + file_name: string + reason: string + skip_count: number + started_at: string + status: number + status_name: string + success_count: number + task_no: string + total_count: number + items?: { + asset_id: number + identifier: string + line: number + reason: string + status: number + status_name: string + }[] +} +export interface StopResumeDryQuery extends PaginationParams { + carrier_id?: number + stop_reason?: string + assume_control_on?: string + assume_control_off?: string + result?: string +} +export interface StopResumeDryRow { + card_category: string + card_id: number + carrier_id: number + carrier_name: string + device_id: number + iccid: string + network_status: number + realname_ok: boolean + reason: string + reason_name: string + result: string + result_name: string + stop_reason: string + stop_reason_name: string +} +export interface StopResumeDryRun extends SchedulerPage { + summary: { + carrier_id: number + carrier_name: string + count: number + reason: string + reason_name: string + result: string + result_name: string + stop_reason: string + stop_reason_name: string + }[] + totals: { no_action: number; total: number; will_resume: number; will_stop: number } +} diff --git a/src/types/api/systemConfig.ts b/src/types/api/systemConfig.ts index 6fcf653..4d5af7c 100644 --- a/src/types/api/systemConfig.ts +++ b/src/types/api/systemConfig.ts @@ -1,6 +1,6 @@ import type { PaginationParams } from './common' -export type SystemConfigModule = 'carrier_callback' | 'c2b.payment' +export type SystemConfigModule = 'carrier_callback' | 'c2b.payment' | 'polling' export type AllowedPaymentMethod = 'wallet' | 'wechat' | 'alipay' export const PAYMENT_CONFIG_KEYS = { diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 98a8a1e..bfe24b8 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -149,6 +149,7 @@ declare module 'vue' { ElWatermark: typeof import('element-plus/es')['ElWatermark'] ExportTaskCreateDialog: typeof import('./../components/business/ExportTaskCreateDialog.vue')['default'] GatewayDynamicData: typeof import('./../components/business/gateway/GatewayDynamicData.vue')['default'] + GatewayOperationTable: typeof import('./../components/business/gateway/GatewayOperationTable.vue')['default'] GatewayOptionsNotice: typeof import('./../components/business/gateway/GatewayOptionsNotice.vue')['default'] GatewayRuleFields: typeof import('./../components/business/gateway/GatewayRuleFields.vue')['default'] HorizontalSubmenu: typeof import('./../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default'] @@ -156,13 +157,19 @@ declare module 'vue' { LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default'] MenuLayoutSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue')['default'] MenuStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue')['default'] + NullableNumber: typeof import('./../components/business/polling/NullableNumber.vue')['default'] OperatorSelect: typeof import('./../components/business/OperatorSelect.vue')['default'] PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default'] PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default'] + PollingBatchDialog: typeof import('./../components/business/polling/PollingBatchDialog.vue')['default'] + PollingImportDialog: typeof import('./../components/business/polling/PollingImportDialog.vue')['default'] + PollingStateDialog: typeof import('./../components/business/polling/PollingStateDialog.vue')['default'] RealnamePolicyDialog: typeof import('./../components/device/RealnamePolicyDialog.vue')['default'] RefundApprovalDialog: typeof import('./../components/business/RefundApprovalDialog.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + SchedulerDetail: typeof import('./../components/business/polling/SchedulerDetail.vue')['default'] + SchedulerTable: typeof import('./../components/business/polling/SchedulerTable.vue')['default'] SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default'] SeriesGrantPackageDialog: typeof import('./../components/business/SeriesGrantPackageDialog.vue')['default'] SeriesGrantPackageTable: typeof import('./../components/business/SeriesGrantPackageTable.vue')['default'] diff --git a/src/utils/business/gateway.ts b/src/utils/business/gateway.ts index 264347e..96e0f52 100644 --- a/src/utils/business/gateway.ts +++ b/src/utils/business/gateway.ts @@ -8,6 +8,9 @@ export function gatewayData(response: BaseResponse): T { } export function gatewayError(error: unknown): string { + const response = (error as { response?: { data?: { msg?: string; message?: string } } })?.response + ?.data + if (response?.msg || response?.message) return response.msg || response.message! return error instanceof Error ? error.message : '请求失败,请重试' } @@ -60,6 +63,8 @@ function requiredText(value: string, max: number, label: string) { } export function validateGatewayRule(data: GatewayRuleInput) { + if (data.auto_disable_polling && data.category !== 'terminal') + throw new Error('自动关闭轮询仅终态类别可开启') requiredText(data.category, 100, '错误分类') requiredText(data.keyword, 200, '关键字或正则') requiredText(data.match_type, 100, '匹配方式') @@ -103,6 +108,7 @@ export function gatewayRows(values: unknown[]): Record[] { /** 与店铺列表统一:展示 YYYY-MM-DD HH:mm:ss,原始响应与请求不改写。 */ export function gatewayDisplayValue(value: unknown, key = ''): string { + if (typeof value === 'boolean') return value ? '是' : '否' if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value)) { return formatDateTime(value) } diff --git a/src/utils/business/pollingConfig.ts b/src/utils/business/pollingConfig.ts new file mode 100644 index 0000000..2da53eb --- /dev/null +++ b/src/utils/business/pollingConfig.ts @@ -0,0 +1,146 @@ +import type { + PollingConfigOverride, + PollingPreResetStats +} from '@/types/api/pollingConfigExtensions' +import { POLLING_DRAFT_INTERVALS } from '@/config/constants/pollingConfig' +import { validateGatewayInteger } from './gateway' +import type { + CreatePollingConfigRequest, + PollingClearField, + UpdatePollingConfigRequest +} from '@/types/api/pollingConfig' + +export function pollingIccid(value: string): string { + const iccid = value.trim() + if (!iccid || iccid.length > 30) throw new Error('请输入 ICCID,最多 30 字符') + return iccid +} + +export function newPollingDraft(): PollingConfigOverride { + return { + id: 0, + config_name: '', + priority: 1, + description: '', + carrier_id: null, + asset_status: null, + card_category: '', + card_condition: '', + package_state: null, + realname_check_interval: null, + carddata_check_interval: null, + package_check_interval: null, + card_status_check_interval: null, + pre_reset_window: null, + pre_reset_carddata_interval: null, + disabled: false + } +} + +export function pollingDraftBody(drafts: PollingConfigOverride[]): PollingConfigOverride[] { + const ids = new Set() + return drafts.map((draft) => { + validateGatewayInteger(draft.id, 0, Number.MAX_SAFE_INTEGER, '草稿配置 ID') + if (draft.id > 0 && ids.has(draft.id)) throw new Error('同一配置不能重复覆盖') + if (draft.id > 0) ids.add(draft.id) + if (draft.disabled) return { id: draft.id, disabled: true } as PollingConfigOverride + if (!draft.config_name?.trim() || draft.config_name.length > 100) + throw new Error('配置名称必填,最多 100 字符') + validateGatewayInteger(draft.priority, 1, 1000, '优先级') + if ((draft.description?.length || 0) > 500) throw new Error('配置说明最多 500 字符') + if (draft.carrier_id != null) + validateGatewayInteger(draft.carrier_id, 0, Number.MAX_SAFE_INTEGER, '运营商账户') + if (draft.asset_status != null) validateGatewayInteger(draft.asset_status, 1, 4, '资产状态') + if ( + draft.package_state && + !['active', 'depleted', 'pending', 'none'].includes(draft.package_state) + ) + throw new Error('套餐状态无效') + for (const item of POLLING_DRAFT_INTERVALS) { + const value = draft[item.field] + if (value != null && value !== 0) + validateGatewayInteger(value, item.min, Number.MAX_SAFE_INTEGER, item.label) + } + const hasWindow = draft.pre_reset_window != null + const hasInterval = draft.pre_reset_carddata_interval != null + if (hasWindow !== hasInterval) + throw new Error('重置日前窗口长度与窗口内流量间隔须同时配置或同时留空') + if (hasWindow) { + validateGatewayInteger( + draft.pre_reset_window, + 60, + Number.MAX_SAFE_INTEGER, + '重置日前窗口长度' + ) + validateGatewayInteger( + draft.pre_reset_carddata_interval, + 60, + Number.MAX_SAFE_INTEGER, + '窗口内流量间隔' + ) + } + // 白名单构造,不能泄漏 UI key、status、创建时间或其他持久化字段。 + const body = newPollingDraft() + for (const key of Object.keys(body) as (keyof PollingConfigOverride)[]) { + if (draft[key] !== undefined) Object.assign(body, { [key]: draft[key] }) + } + return body + }) +} + +export function validatePollingOrder(ids: number[]) { + if (!ids.length) throw new Error('至少选择一条配置') + if (new Set(ids).size !== ids.length) throw new Error('配置 ID 不得重复') + ids.forEach((id) => validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '配置 ID')) +} + +export function pollingTaskActive(status?: string) { + return status === 'pending' || status === 'running' +} + +export function pollingSaveBody( + value: PollingConfigOverride, + editing: boolean +): CreatePollingConfigRequest | UpdatePollingConfigRequest { + const draft = pollingDraftBody([{ ...value, disabled: false }])[0] + const data: CreatePollingConfigRequest = { + config_name: draft.config_name, + priority: draft.priority + } + for (const key of Object.keys(draft)) + if (key !== 'id' && key !== 'disabled') + Object.assign(data, { [key]: draft[key as keyof PollingConfigOverride] }) + const clear_fields: PollingClearField[] = [] + if (editing) + for (const field of [ + 'carrier_id', + 'asset_status', + 'package_state', + 'card_category', + 'card_condition', + 'realname_check_interval', + 'package_check_interval', + 'carddata_check_interval', + 'card_status_check_interval', + 'pre_reset_window', + 'pre_reset_carddata_interval' + ] as const) { + if (data[field] == null || data[field] === '') { + clear_fields.push(field) + delete data[field] + } + } + return editing ? { ...data, clear_fields } : data +} + +export function pollingPreResetRows(data: PollingPreResetStats) { + return data.carriers.flatMap((carrier) => + (['previous', 'upcoming'] as const).map((period) => ({ + key: `${carrier.carrier_id}:${period}`, + carrier_id: carrier.carrier_id, + carrier_name: carrier.carrier_name, + period_name: period === 'previous' ? '前一个周期' : '即将到来周期', + period: carrier[period] + })) + ) +} diff --git a/src/utils/business/pollingScheduler.ts b/src/utils/business/pollingScheduler.ts new file mode 100644 index 0000000..42eaf71 --- /dev/null +++ b/src/utils/business/pollingScheduler.ts @@ -0,0 +1,133 @@ +import type { + CarrierParamsInput, + CheckSceneInput, + PollingFailureQuery +} from '@/types/api/pollingScheduler' +import { validateGatewayInteger, compactGatewayQuery, gatewayDisplayValue } from './gateway' + +const schedulerLabels: Record> = { + asset_type: { iot_card: '卡', device: '设备' }, + alert_level: { card: '卡级', carrier: '账户级' }, + scheduler_mode: { legacy: '旧分片调度', carrier: '按运营商账户调度' }, + queue: { priority: '优先队列', queue: '普通队列', none: '不在轮询中' }, + network_status: { 0: '停机', 1: '开机' }, + card_category: { normal: '普通卡', industry: '行业卡' }, + check_types: { card_status: '卡状态', carddata: '流量', realname: '实名' }, + action: { stop: '停机', start: '复机' } +} +export function schedulerDisplayValue(value: unknown, key = ''): string { + if (Array.isArray(value) && schedulerLabels[key]) + return value.map((item) => schedulerLabels[key][String(item)] || String(item)).join('、') || '-' + return schedulerLabels[key]?.[String(value)] || gatewayDisplayValue(value, key) +} + +export const isPollingPlatform = (userType: unknown) => [1, 2].includes(Number(userType)) +export const canPollingBatch = (userType: unknown) => [1, 2, 3].includes(Number(userType)) +export const pollingScope = (user: { + info: { id?: unknown; user_type?: unknown; shop_id?: unknown; enterprise_id?: unknown } + isLogin?: boolean +}) => + JSON.stringify([ + user.info.id, + user.info.user_type, + user.info.shop_id, + user.info.enterprise_id, + user.isLogin + ]) +export function validatePollingIds(ids: number[]) { + if (!ids.length || ids.length > 500 || new Set(ids).size !== ids.length) + throw new Error('每次选择 1–500 项且不能重复') + ids.forEach((id) => validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '资产/告警 ID')) +} +export function pollingReason(value: string) { + if (!value.trim()) throw new Error('请填写操作原因') + return value.trim() +} +export function schedulerTime(range: unknown) { + if (!Array.isArray(range) || !range.length) return {} + if (range.length !== 2 || range.some((value) => !value)) throw new Error('请选择完整时间范围') + const dates = range.map((value) => new Date(value)) + if (dates.some((value) => !Number.isFinite(value.getTime())) || dates[1] < dates[0]) + throw new Error('时间范围无效') + return { + start_time: dates[0].toISOString().replace(/\.\d{3}Z$/, 'Z'), + end_time: dates[1].toISOString().replace(/\.\d{3}Z$/, 'Z') + } +} +export function failureQuery(query: PollingFailureQuery): PollingFailureQuery { + if (query.view !== 'stop_refused') return compactGatewayQuery(query) + const { page, page_size, carrier_id, status, iccid, start_time, end_time } = query + return compactGatewayQuery({ + page, + page_size, + carrier_id, + status, + iccid, + start_time, + end_time, + view: 'stop_refused' + }) +} +export function validateCarrierParams(data: CarrierParamsInput, capacity: number) { + for (const field of [ + 'card_max_in_flight', + 'inflight_limit', + 'sr_inflight_limit', + 'stop_resume_max_in_flight' + ] as const) { + if (data[field] != null) validateGatewayInteger(data[field], 1, Number.MAX_SAFE_INTEGER, field) + } + for (const field of ['stop_resume_confirm_delay', 'stop_resume_min_interval'] as const) { + if (data[field] != null) validateGatewayInteger(data[field], 0, Number.MAX_SAFE_INTEGER, field) + } + if ( + data.priority_max_ratio != null && + (!(data.priority_max_ratio > 0) || data.priority_max_ratio > 1) + ) + throw new Error('优先队列占比须大于 0 且不超过 1') + if (data.inflight_limit != null && data.inflight_limit > capacity) + throw new Error('执行上限不能大于订阅进程容量之和') +} +export function validateScene(data: CheckSceneInput) { + if ( + !data.check_types.length || + data.check_types.some((value) => !['card_status', 'carddata', 'realname'].includes(value)) + ) + throw new Error('请选择有效检查类型') + data.confirm_delays.forEach((value, index) => { + validateGatewayInteger(value, 1, Number.MAX_SAFE_INTEGER, '确认延迟') + if (index && value <= data.confirm_delays[index - 1]) throw new Error('确认延迟必须严格递增') + }) + if (data.cooldown != null) + validateGatewayInteger(data.cooldown, 0, Number.MAX_SAFE_INTEGER, '冷却时长') +} +export function validateDryAssumptions(on: number[], off: number[]) { + if (on.some((id) => off.includes(id))) throw new Error('同一账户不能同时假设开启和关闭实名管控') +} +export function validatePollingCsv(fileName: string, text: string) { + if (!/\.csv$/i.test(fileName)) throw new Error('只支持 CSV 文件') + // 识别引号内换行与转义双引号,按 CSV 记录计数;标识符始终为文本。 + const lines: string[] = [] + let quoted = false, + line = '' + const content = text.replace(/^\uFEFF/, '') + for (let i = 0; i < content.length; i++) { + const char = content[i] + if (char === '"') { + if (quoted && content[i + 1] === '"') { + line += '"' + i++ + } else quoted = !quoted + } else if ((char === '\n' || char === '\r') && !quoted) { + if (line.trim()) lines.push(line) + line = '' + if (char === '\r' && content[i + 1] === '\n') i++ + } else line += char + } + if (quoted) throw new Error('CSV 引号未闭合') + if (line.trim()) lines.push(line) + if (lines.length < 2) throw new Error('CSV 须有表头和数据行') + if (lines.length - 1 > 50000) throw new Error('CSV 最多 50000 行数据') + // 这里只计行数,不把 ICCID、IMEI 等标识符转换成数字。 + return lines.length - 1 +} diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index 299910c..04d0d5d 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -9,6 +9,22 @@
+ 轮询状态 + 轮询状态 ({ ...card })), - device_protect_status: data.device_protect_status, online_status: data.online_status, last_online_time: data.last_online_time, software_version: data.software_version, @@ -436,6 +435,8 @@ export function useAssetInfo() { ) { return } + if (typeof data.enable_polling === 'boolean') + cardInfo.value.enable_polling = data.enable_polling // 更新实时状态数据 if (assetType === 'card') { // 卡的实时状态 @@ -472,9 +473,6 @@ export function useAssetInfo() { } } else if (assetType === 'device') { // 设备的实时状态 - if (data.device_protect_status !== undefined) { - cardInfo.value.device_protect_status = data.device_protect_status - } if (data.cards && data.cards.length > 0) { // 实时 cards 可能缺少部分卡或静态字段,不能替换详情里的完整绑定列表。 cardInfo.value.cards = mergeAssetBoundCards(cardInfo.value.cards || [], data.cards) diff --git a/src/views/asset-management/asset-information/composables/useAssetOperations.ts b/src/views/asset-management/asset-information/composables/useAssetOperations.ts index 2c9b967..a5ad107 100644 --- a/src/views/asset-management/asset-information/composables/useAssetOperations.ts +++ b/src/views/asset-management/asset-information/composables/useAssetOperations.ts @@ -2,6 +2,8 @@ import { ref } from 'vue' import type { Ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { AssetService, DeviceService } from '@/api/modules' +import { PollingSchedulerService } from '@/api/modules/pollingScheduler' +import { gatewayData, gatewayError } from '@/utils/business/gateway' import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit' /** @@ -248,6 +250,7 @@ export function useAssetOperations(cardInfo: Ref, refreshAssetFn?: () => Pr * Update polling status */ const updatePollingStatus = async (enabled: boolean) => { + if (pollingLoading.value) return false if (!cardInfo.value) { ElMessage.warning('无法更新轮询状态:缺少资产信息') return false @@ -257,7 +260,12 @@ export function useAssetOperations(cardInfo: Ref, refreshAssetFn?: () => Pr pollingLoading.value = true const identifier = cardInfo.value.asset_type === 'card' ? cardInfo.value.iccid : cardInfo.value.virtual_no - + const target = cardInfo.value + const impact = gatewayData(await PollingSchedulerService.getImpact()) + const lines = impact[enabled ? 'enable' : 'disable'] + if (!Array.isArray(lines) || !lines.length) throw new Error('未获取到有效操作影响说明') + await ElMessageBox.confirm(lines.join('\n'), '确认轮询开关影响', { type: 'warning' }) + if (cardInfo.value !== target) return false const response = await AssetService.updatePollingStatus(identifier, { enable_polling: enabled }) @@ -265,17 +273,17 @@ export function useAssetOperations(cardInfo: Ref, refreshAssetFn?: () => Pr if (response.code === 0) { ElMessage.success(enabled ? '已开启自动轮询' : '已关闭自动轮询') // Update local state - if (cardInfo.value) { + if (cardInfo.value === target) { cardInfo.value.enable_polling = enabled } + if (refreshAssetFn && cardInfo.value === target) await refreshAssetFn() return true } else { ElMessage.error(response.msg || '更新轮询状态失败') return false } } catch (error: any) { - console.error('更新轮询状态失败:', error) - console.log(error?.message || '更新轮询状态失败') + if (error !== 'cancel' && error !== 'close') ElMessage.error(gatewayError(error)) return false } finally { pollingLoading.value = false diff --git a/src/views/asset-management/asset-information/index.vue b/src/views/asset-management/asset-information/index.vue index 00c83f2..629ead0 100644 --- a/src/views/asset-management/asset-information/index.vue +++ b/src/views/asset-management/asset-information/index.vue @@ -41,6 +41,7 @@ @show-realname-policy="showRealnamePolicyDialog" @show-speed-limit="speedLimitDialogVisible = true" @show-stop-resume-records="showStopResumeRecords" + @show-polling-state="showPollingState" @show-update-realname-status="showUpdateRealnameStatusDialog" @enable-binding-card="handleEnableBindingCard" @disable-binding-card="handleDisableBindingCard" @@ -187,6 +188,13 @@ @confirm="handleSpeedTierConfirm" />
+ diff --git a/src/views/asset-management/export-task-management/export-polling-stop-refused-alert/index.vue b/src/views/asset-management/export-task-management/export-polling-stop-refused-alert/index.vue new file mode 100644 index 0000000..f66834c --- /dev/null +++ b/src/views/asset-management/export-task-management/export-polling-stop-refused-alert/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/asset-management/export-task-management/export-stop-resume-rule-dry-run/index.vue b/src/views/asset-management/export-task-management/export-stop-resume-rule-dry-run/index.vue new file mode 100644 index 0000000..1b4456c --- /dev/null +++ b/src/views/asset-management/export-task-management/export-stop-resume-rule-dry-run/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/asset-management/export-task-management/export-task-detail/index.vue b/src/views/asset-management/export-task-management/export-task-detail/index.vue index 6c18ca5..92ca07e 100644 --- a/src/views/asset-management/export-task-management/export-task-detail/index.vue +++ b/src/views/asset-management/export-task-management/export-task-detail/index.vue @@ -41,7 +41,13 @@ import type { DetailSection } from '@/components/common/DetailPage.vue' import { ExportTaskService } from '@/api/modules' import { useAuth } from '@/composables/useAuth' - import { getExportTaskSceneConfig, getExportTaskSceneName } from '@/config/constants' + import { + getExportTaskSceneConfig, + getExportTaskSceneName, + isPollingPlatformExport + } from '@/config/constants' + import { useUserStore } from '@/store/modules/user' + import { isPollingPlatform } from '@/utils/business/pollingScheduler' import { formatDateTime } from '@/utils/business/format' import type { ExportTaskDetail } from '@/types/api' import { ExportTaskStatus } from '@/types/api' @@ -52,6 +58,7 @@ const route = useRoute() const router = useRouter() const { hasAuth } = useAuth() + const user = useUserStore() const polling = useAsyncTaskPolling({ storageKey: `export-task-detail:${String(route.query.id || '')}`, autoRestore: false, @@ -63,6 +70,8 @@ throw error } if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败') + if (isPollingPlatformExport(res.data.scene) && !isPollingPlatform(user.info.user_type)) + throw Object.assign(new Error('仅平台账号可查看该导出任务'), { status: 403 }) return res.data }, isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403 @@ -73,7 +82,10 @@ () => getExportTaskSceneConfig(taskDetail.value?.scene)?.permissions ) const canViewDetail = computed( - () => !!taskPermissions.value && hasAuth(taskPermissions.value.detail) + () => + !!taskPermissions.value && + hasAuth(taskPermissions.value.detail) && + (!isPollingPlatformExport(taskDetail.value?.scene) || isPollingPlatform(user.info.user_type)) ) const canDownloadTask = computed( () => diff --git a/src/views/asset-management/export-task-management/export-task-list/index.vue b/src/views/asset-management/export-task-management/export-task-list/index.vue index f90b95f..dbe3ed7 100644 --- a/src/views/asset-management/export-task-management/export-task-list/index.vue +++ b/src/views/asset-management/export-task-management/export-task-list/index.vue @@ -46,7 +46,9 @@ import { useCheckedColumns } from '@/composables/useCheckedColumns' import { formatDateTime } from '@/utils/business/format' import { RoutesAlias } from '@/router/routesAlias' - import { getExportTaskSceneConfig } from '@/config/constants' + import { getExportTaskSceneConfig, isPollingPlatformExport } from '@/config/constants' + import { useUserStore } from '@/store/modules/user' + import { isPollingPlatform } from '@/utils/business/pollingScheduler' import type { SearchFormItem } from '@/types' import type { ExportTaskDetail, ExportTaskItem, ExportTaskScene } from '@/types/api' import { ExportTaskStatus } from '@/types/api' @@ -57,6 +59,9 @@ const route = useRoute() const router = useRouter() const { hasAuth } = useAuth() + const user = useUserStore() + const allowedScene = (scene: ExportTaskScene) => + !isPollingPlatformExport(scene) || isPollingPlatform(user.info.user_type) const loading = ref(false) const taskList = ref([]) const defaultSceneConfig = getExportTaskSceneConfig('device')! @@ -237,6 +242,10 @@ } const getTableData = async () => { + if (!allowedScene(currentScene.value) || !hasAuth(currentPermissions.value.page)) { + taskList.value = [] + return + } loading.value = true try { syncDateRange() @@ -321,7 +330,7 @@ } const goDetail = (row: ExportTaskItem) => { - if (!hasAuth(getRowPermissions(row).detail)) { + if (!allowedScene(row.scene) || !hasAuth(getRowPermissions(row).detail)) { ElMessage.warning('您没有查看导出任务详情的权限') return } @@ -333,7 +342,7 @@ } const downloadTask = async (row: ExportTaskItem) => { - if (!hasAuth(getRowPermissions(row).download)) { + if (!allowedScene(row.scene) || !hasAuth(getRowPermissions(row).download)) { ElMessage.warning('您没有下载导出任务的权限') return } diff --git a/src/views/asset-management/iot-card-management/index.vue b/src/views/asset-management/iot-card-management/index.vue index 9fe1f38..a02e3fd 100644 --- a/src/views/asset-management/iot-card-management/index.vue +++ b/src/views/asset-management/iot-card-management/index.vue @@ -3,8 +3,10 @@
diff --git a/src/views/polling-management/carrier-params/index.vue b/src/views/polling-management/carrier-params/index.vue new file mode 100644 index 0000000..a51efc4 --- /dev/null +++ b/src/views/polling-management/carrier-params/index.vue @@ -0,0 +1,223 @@ + + + diff --git a/src/views/polling-management/carrier-queues/index.vue b/src/views/polling-management/carrier-queues/index.vue new file mode 100644 index 0000000..a6ccc20 --- /dev/null +++ b/src/views/polling-management/carrier-queues/index.vue @@ -0,0 +1,61 @@ + + diff --git a/src/views/polling-management/check-scenes/index.vue b/src/views/polling-management/check-scenes/index.vue new file mode 100644 index 0000000..a0807bb --- /dev/null +++ b/src/views/polling-management/check-scenes/index.vue @@ -0,0 +1,114 @@ + + diff --git a/src/views/polling-management/concurrency/index.vue b/src/views/polling-management/concurrency/index.vue index 8646503..e81a247 100644 --- a/src/views/polling-management/concurrency/index.vue +++ b/src/views/polling-management/concurrency/index.vue @@ -1,5 +1,12 @@ diff --git a/src/views/polling-management/failure-alerts/index.vue b/src/views/polling-management/failure-alerts/index.vue new file mode 100644 index 0000000..034cf13 --- /dev/null +++ b/src/views/polling-management/failure-alerts/index.vue @@ -0,0 +1,299 @@ + + + diff --git a/src/views/polling-management/gateway-stats/index.vue b/src/views/polling-management/gateway-stats/index.vue index beee8e0..bc84069 100644 --- a/src/views/polling-management/gateway-stats/index.vue +++ b/src/views/polling-management/gateway-stats/index.vue @@ -10,7 +10,7 @@ type="info" show-icon :closable="false" - title="统计接口文档未定义汇总和趋势的内部字段,以下按接口真实字段展示,不推导指标或绘制假定趋势。" + title="observe 为只统计,enforce 为实际拦截;成功率按百分比展示,p95 为分桶近似上界,-1 表示超过最大桶。" /> 实际统计窗口:{{ summary.minutes }} 分钟;统计模式:{{ summary.mode || '-' }}

- +
@@ -57,12 +52,7 @@ trend.carrier_id === 0 ? '无法解析账户的卡' : carrierLabel(trend.carrier_id) }}

- + @@ -83,7 +73,7 @@ requireGatewayAccess, validateGatewayInteger } from '@/utils/business/gateway' - import GatewayDynamicData from '@/components/business/gateway/GatewayDynamicData.vue' + import GatewayOperationTable from '@/components/business/gateway/GatewayOperationTable.vue' import GatewayOptionsNotice from '@/components/business/gateway/GatewayOptionsNotice.vue' defineOptions({ name: 'GatewayCallStats' }) diff --git a/src/views/polling-management/polling-pauses/index.vue b/src/views/polling-management/polling-pauses/index.vue new file mode 100644 index 0000000..60b3b98 --- /dev/null +++ b/src/views/polling-management/polling-pauses/index.vue @@ -0,0 +1,238 @@ + + diff --git a/src/views/polling-management/polling-status-imports/detail.vue b/src/views/polling-management/polling-status-imports/detail.vue new file mode 100644 index 0000000..d14f6be --- /dev/null +++ b/src/views/polling-management/polling-status-imports/detail.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/polling-management/polling-status-imports/index.vue b/src/views/polling-management/polling-status-imports/index.vue new file mode 100644 index 0000000..9b9c34c --- /dev/null +++ b/src/views/polling-management/polling-status-imports/index.vue @@ -0,0 +1,126 @@ + + diff --git a/src/views/polling-management/rate-limit-groups/index.vue b/src/views/polling-management/rate-limit-groups/index.vue index ecc2708..d30d233 100644 --- a/src/views/polling-management/rate-limit-groups/index.vue +++ b/src/views/polling-management/rate-limit-groups/index.vue @@ -1,6 +1,11 @@