From 68d8f769d1e7ce9597b01c1fe4b7f33926dd1b03 Mon Sep 17 00:00:00 2001 From: luo Date: Thu, 17 Sep 2026 18:37:20 +0800 Subject: [PATCH] fix: some --- .../proposal.md | 39 ++ .../specs/asset-wallet-auto-renewal/spec.md | 94 ++++ .../tasks.md | 27 + .../add-polling-priority-queue/proposal.md | 53 ++ .../specs/polling-priority-queue/spec.md | 79 +++ .../add-polling-priority-queue/tasks.md | 30 ++ src/api/modules/assetWallet.ts | 27 + src/api/modules/index.ts | 4 + src/api/modules/pollingPriorityQueue.ts | 53 ++ src/config/constants/index.ts | 3 + src/config/constants/pollingPriorityQueue.ts | 71 +++ src/router/routes/asyncRoutes.ts | 10 + src/router/routesAlias.ts | 1 + src/types/api/assetWallet.ts | 46 ++ src/types/api/index.ts | 6 + src/types/api/pollingPriorityQueue.ts | 171 ++++++ .../phone-asset-association/index.vue | 1 - .../unbind-import-tasks/index.vue | 1 - .../my-commission/index.vue | 156 +++--- .../components/ApplicationFormDialog.vue | 2 +- .../priority-queue/index.vue | 492 ++++++++++++++++++ 21 files changed, 1281 insertions(+), 85 deletions(-) create mode 100644 openspec/changes/add-asset-wallet-auto-renewal-config/proposal.md create mode 100644 openspec/changes/add-asset-wallet-auto-renewal-config/specs/asset-wallet-auto-renewal/spec.md create mode 100644 openspec/changes/add-asset-wallet-auto-renewal-config/tasks.md create mode 100644 openspec/changes/add-polling-priority-queue/proposal.md create mode 100644 openspec/changes/add-polling-priority-queue/specs/polling-priority-queue/spec.md create mode 100644 openspec/changes/add-polling-priority-queue/tasks.md create mode 100644 src/api/modules/assetWallet.ts create mode 100644 src/api/modules/pollingPriorityQueue.ts create mode 100644 src/config/constants/pollingPriorityQueue.ts create mode 100644 src/types/api/assetWallet.ts create mode 100644 src/types/api/pollingPriorityQueue.ts create mode 100644 src/views/polling-management/priority-queue/index.vue diff --git a/openspec/changes/add-asset-wallet-auto-renewal-config/proposal.md b/openspec/changes/add-asset-wallet-auto-renewal-config/proposal.md new file mode 100644 index 0000000..7c664f4 --- /dev/null +++ b/openspec/changes/add-asset-wallet-auto-renewal-config/proposal.md @@ -0,0 +1,39 @@ +# Change: 新增资产钱包自动续费配置(后台查询/保存 + 自动续费失败通知适配) + +## Why + +根据 `docs/产品迭代8月份/资产钱包.md`。后端测试环境(`https://cmp-api.boss160.cn`,`Authorization: Bearer `)已提供资产钱包自动续费全局配置能力,仅有 2 个后台接口,H5 侧零新增: + +- 查询资产钱包自动续费配置:`GET /api/admin/asset-auto-renewal-config` +- 保存资产钱包自动续费配置:`PUT /api/admin/asset-auto-renewal-config` + +后台需要提供配置页面承载总开关、适用范围、指定主套餐、到期前天数等配置;同时新增通知类型 `asset.auto_renewal.failed` 会复用既有通知接口产生新数据,需要适配展示与跳转。 + +## What Changes + +- 新增能力 `asset-wallet-auto-renewal`: + - 配置读取:`GET /api/admin/asset-auto-renewal-config`,返回唯一一份全局配置的 `enabled`+`enabled_name`、`scope`+`scope_name`、`package_ids`、`days_before_expiry`、`config_version`、`updater`、`updated_at`。 + - 配置保存:`PUT /api/admin/asset-auto-renewal-config`: + - 请求体 `{ enabled(0/1,必填), scope(all|specified,必填), package_ids(uint[],仅 specified 时非空且只能选当前可售主套餐), days_before_expiry(1–90,必填) }`。 + - 语义:单行配置、无新增/删除;保存即递增 `config_version`,记录操作者与前后值快照;配置变更只影响后续扫描,历史记录不重算。 + - 权限:仅超级管理员与平台账号可访问;其他身份(代理/企业/个人客户)`403`,提示「无权限操作该资源或资源不存在」,无权限与不存在不区分。 +- 通知适配(复用既有接口,不算新接口): + - 后台站内通知:新增通知类型 `asset.auto_renewal.failed`(类别 `expiry`、级别 `warning`),接收人是业务员/店铺账号。走既有 `GET /api/admin/notifications` + `GET /api/admin/notifications/{id}/target`;确认 `target_type` 的 `iot_card` / `device` 在导航白名单;`available=false` 时只显示正文不跳转;计入未读数。 + - H5 个人客户通知:同一类型已由后端加入客户可见白名单,出现在既有 `GET /api/c/v1/notifications` 与 `unread-count`,客户侧只有文案(资产标识、套餐、原因、到期日),无跳转接口、无需新页面,本仓库无需改动。该类型属 `expiry` 类别,展示期以业务到期时间为准,套餐到期后从列表消失(既有类别行为)。 +- 明确不做(避免前端空等):无自动续费记录页/异常记录页接口、无尝试记录查询接口、无「有余额就不停机」开关、无 H5 客户侧新接口。 + +## Impact + +- Affected specs: + - `asset-wallet-auto-renewal` — 新增能力 +- Affected code: + - `src/types/api/assetWallet.ts`(新增) + - `src/types/api/index.ts` + - `src/api/modules/assetWallet.ts`(新增) + - `src/api/modules/index.ts` + - `src/views/settings/asset-wallet-auto-renewal/index.vue`(新增) + - `src/router/routesAlias.ts` + - `src/router/routes/asyncRoutes.ts` + - `src/utils/business/notificationNavigation.ts`(确认/补充 `iot_card`、`device` 白名单) + - `src/components/core/layouts/art-notification/index.vue`(确认 `expiry` 分类覆盖新类型) + - `src/locales/langs/zh.json`、`src/locales/langs/en.json` diff --git a/openspec/changes/add-asset-wallet-auto-renewal-config/specs/asset-wallet-auto-renewal/spec.md b/openspec/changes/add-asset-wallet-auto-renewal-config/specs/asset-wallet-auto-renewal/spec.md new file mode 100644 index 0000000..375dab8 --- /dev/null +++ b/openspec/changes/add-asset-wallet-auto-renewal-config/specs/asset-wallet-auto-renewal/spec.md @@ -0,0 +1,94 @@ +## ADDED Requirements + +### Requirement: 自动续费配置查询 + +The admin frontend SHALL load the single global asset wallet auto-renewal configuration through `GET /api/admin/asset-auto-renewal-config`. 响应字段 SHALL 包含并展示 `enabled`+`enabled_name`、`scope`+`scope_name`、`package_ids`、`days_before_expiry`、`config_version`、`updater`、`updated_at`。 + +#### Scenario: 读取全局自动续费配置 + +- **WHEN** 用户进入资产钱包自动续费配置页面 +- **THEN** 前端 MUST 调用 `GET /api/admin/asset-auto-renewal-config` +- **AND** 页面 MUST 展示总开关及中文名称、适用范围及中文名称、指定主套餐集合、统一到期前天数、配置版本、最近保存操作者与最近保存时间 + +### Requirement: 自动续费配置保存 + +The admin frontend SHALL save the configuration through `PUT /api/admin/asset-auto-renewal-config` with request body `{ enabled, scope, package_ids, days_before_expiry }`。`enabled` SHALL 取值 0/1 且必填;`scope` SHALL 取值 `all`(全部主套餐)或 `specified`(指定主套餐)且必填;`package_ids` 仅在 `scope=specified` 时必填非空且只能选择当前可售主套餐;`days_before_expiry` SHALL 取值 1 至 90 且必填。配置为单行、无新增/删除;保存 SHALL 递增 `config_version` 并记录操作者与前后值快照;配置变更 SHALL 只影响后续扫描,历史记录不重算。 + +#### Scenario: 保存全部主套餐范围配置 + +- **WHEN** 用户设置适用范围为「全部主套餐」并提交 +- **THEN** 前端 MUST 调用 `PUT /api/admin/asset-auto-renewal-config` +- **AND** 请求体携带 `enabled`、`scope=all`、`days_before_expiry` +- **AND** `package_ids` 以空数组提交 +- **AND** 保存成功后页面 MUST 以响应中的 `config_version`、`updater`、`updated_at` 刷新展示 + +#### Scenario: 保存指定主套餐范围配置 + +- **WHEN** 用户设置适用范围为「指定主套餐」并选择若干当前可售主套餐后提交 +- **THEN** `package_ids` MUST 非空且只包含当前可售主套餐 +- **AND** 前端 MUST 携带 `scope=specified` 与非空 `package_ids` 提交 + +#### Scenario: 参数校验 + +- **WHEN** `enabled` 缺失、`scope` 非法、`scope=specified` 时 `package_ids` 为空或包含不可售套餐,或 `days_before_expiry` 超出 1–90 +- **THEN** 前端 MUST 阻止提交并给出校验提示 + +#### Scenario: 仅影响后续扫描 + +- **WHEN** 用户修改并保存配置 +- **THEN** 历史自动续费记录 MUST NOT 被重算 +- **AND** 新配置 MUST 只对保存后的扫描生效 + +### Requirement: 自动续费配置访问权限 + +Only super admin and platform accounts SHALL be allowed to query or save the asset wallet auto-renewal configuration. 代理、企业与个人客户等身份访问时 SHALL 返回 `403` 并提示「无权限操作该资源或资源不存在」;无权限与不存在的表现 MUST NOT 可区分。 + +#### Scenario: 授权访问 + +- **WHEN** 超级管理员或平台账号访问配置接口 +- **THEN** 页面 MUST 正常查询与保存配置 + +#### Scenario: 无权限访问 + +- **WHEN** 代理、企业或个人客户账号访问配置接口 +- **THEN** 页面 MUST 按 `403` 无权限处理并展示统一提示 +- **AND** 前端 MUST NOT 通过响应区分无权限与资源不存在 + +### Requirement: 自动续费失败后台通知 + +The admin notification center SHALL display the new notification type `asset.auto_renewal.failed`(类别 `expiry`、级别 `warning`)through the existing notification APIs,接收人为业务员/店铺账号。目标 `target_type` 的 `iot_card` 与 `device` SHALL 位于通知导航白名单;当目标 `available=false` 时页面 SHALL 只展示正文且不跳转;该类型通知 SHALL 计入未读数。 + +#### Scenario: 展示并跳转自动续费失败通知 + +- **WHEN** 通知列表返回 `asset.auto_renewal.failed` 类型且目标可用 +- **THEN** 通知中心 MUST 将该通知归入 `expiry` 类别展示 +- **AND** 用户点击后 MUST 通过既有目标接口按 `iot_card` / `device` 跳转到对应资产详情 + +#### Scenario: 目标不可用 + +- **WHEN** 目标接口返回 `available=false` +- **THEN** 页面 MUST 只展示通知正文且不发起跳转 + +### Requirement: H5 客户侧通知边界 + +This capability SHALL NOT 新增或修改 H5 客户侧接口与页面。`asset.auto_renewal.failed` 类型已由后端加入客户可见白名单,通过既有 `GET /api/c/v1/notifications` 与 `unread-count` 返回;客户侧仅展示文案(资产标识、套餐、原因、到期日),无跳转接口、无需新页面。该类型属 `expiry` 类别,展示期以业务到期时间为准,套餐到期后从列表消失(既有类别行为)。 + +#### Scenario: H5 复用既有接口 + +- **WHEN** 客户侧通知接口返回该类型 +- **THEN** 客户侧 MUST 仅展示文案且不提供跳转 +- **AND** 本次变更 MUST NOT 新增 H5 客户侧接口或页面 + +#### Scenario: 到期展示期 + +- **WHEN** 对应套餐业务到期 +- **THEN** 该通知按 `expiry` 类别既有行为从客户侧列表消失 + +### Requirement: 功能范围边界 + +This capability SHALL NOT 包含以下内容:自动续费记录页/异常记录页、尝试记录查询、「有余额就不停机」开关、H5 客户侧新接口。 + +#### Scenario: 明确不提供的功能 + +- **WHEN** 前端对接本次资产钱包自动续费能力 +- **THEN** 页面 MUST NOT 提供或依赖自动续费记录页、异常记录页、尝试记录查询接口与「有余额就不停机」开关 diff --git a/openspec/changes/add-asset-wallet-auto-renewal-config/tasks.md b/openspec/changes/add-asset-wallet-auto-renewal-config/tasks.md new file mode 100644 index 0000000..2f92633 --- /dev/null +++ b/openspec/changes/add-asset-wallet-auto-renewal-config/tasks.md @@ -0,0 +1,27 @@ +# Tasks: 资产钱包自动续费配置 + +## 1. 类型与 API +- [ ] 1.1 新增 `src/types/api/assetWallet.ts`:`AssetAutoRenewalConfig`(`enabled`/`enabled_name`/`scope`/`scope_name`/`package_ids`/`days_before_expiry`/`config_version`/`updater`/`updated_at`)、`UpdateAssetAutoRenewalConfigRequest`(`enabled`/`scope`/`package_ids`/`days_before_expiry`)等类型 +- [ ] 1.2 `src/types/api/index.ts` 导出新类型 +- [ ] 1.3 新增 `src/api/modules/assetWallet.ts`: + - `getAutoRenewalConfig`:`GET /api/admin/asset-auto-renewal-config` + - `saveAutoRenewalConfig`:`PUT /api/admin/asset-auto-renewal-config` +- [ ] 1.4 `src/api/modules/index.ts` 导出新服务 + +## 2. 配置页面 +- [ ] 2.1 `src/router/routesAlias.ts` 新增资产钱包自动续费配置路由别名,`src/router/routes/asyncRoutes.ts` 在设置下注册路由与菜单(仅超级管理员与平台账号可见) +- [ ] 2.2 新增 `src/views/settings/asset-wallet-auto-renewal/index.vue`:读取配置并展示总开关、适用范围、指定主套餐、统一到期前天数、配置版本、最近保存操作者与时间 +- [ ] 2.3 实现保存表单:总开关(0/1)、适用范围(all/specified)、到期前天数(1–90 校验);指定范围时主套餐多选仅支持当前可售主套餐且非空 +- [ ] 2.4 保存成功后使用响应刷新 `config_version`、`updater`、`updated_at`,不做新增/删除记录操作 + +## 3. 通知适配 +- [ ] 3.1 确认 `src/utils/business/notificationNavigation.ts` 白名单包含 `iot_card` 与 `device`,目标可用时跳转资产详情 +- [ ] 3.2 确认 `src/components/core/layouts/art-notification/index.vue` 的 `expiry` 分类可展示 `asset.auto_renewal.failed`,`available=false` 时只显示正文不跳转 +- [ ] 3.3 确认该类型通知计入未读数与分类汇总 + +## 4. Verification +- [ ] 4.1 验证 GET/PUT 字段传递与参数校验(enabled、scope、package_ids、days_before_expiry) +- [ ] 4.2 验证指定范围时 package_ids 非空且仅可售主套餐 +- [ ] 4.3 验证通知类型展示、目标跳转与不可用目标处理 +- [ ] 4.4 验证非超级管理员/平台账号访问的 403 处理 +- [ ] 4.5 运行 lint、类型检查与构建 diff --git a/openspec/changes/add-polling-priority-queue/proposal.md b/openspec/changes/add-polling-priority-queue/proposal.md new file mode 100644 index 0000000..891ecdd --- /dev/null +++ b/openspec/changes/add-polling-priority-queue/proposal.md @@ -0,0 +1,53 @@ +# Change: 新增轮询管理 - 优先队列 API 前端对接(列表 + 人工优先入队 + 单项详情) + +## Why + +根据 `docs/产品迭代8月份/优先队列.md`。后端测试环境(`https://cmp-api.boss160.cn`,`Authorization: Bearer `)已提供「轮询管理 - 优先队列」能力: + +- 查询优先轮询项列表:`GET /api/admin/polling-priority-items` +- 人工优先入队:`POST /api/admin/polling-priority-items` +- 查询优先轮询项详情:`GET /api/admin/polling-priority-items/{id}` + +该能力服务于: +1. 查询优先轮询项:分页查询卡轮询优先项,按创建时间倒序,支持卡 `card_id`、任务类型 `task_type`、状态 `status`、触发类型 `trigger_type` 筛选;数据范围按卡所属店铺快照下推:代理只能看到自身及下级店铺资产,平台卡不可见。 +2. 人工优先入队:为该卡的全部纳入轮询任务类型(实名/流量/套餐/卡状态)建立或合并优先轮询项,原因必填(最长 500 字符)。`不受` 既有人工触发的每日次数上限与 24 小时去重约束;`不修改` 调度优先级,也 `不绕过` 既有并发上限。 +3. 查询优先轮询项详情:查询单条卡轮询优先项,越权与不存在返回同一响应,不产生可枚举差异,`不提供` 优先级分级、有效期与人工重触发入口。 + +## What Changes + +- 新增能力 `polling-priority-queue`,提供: + - 优先轮询项列表: + - 分页:`GET /api/admin/polling-priority-items`,按创建时间倒序; + - 筛选:卡 `card_id`、任务类型 `task_type`、状态 `status`(`pending/processing/completed/failed`)、触发类型 `trigger_type`(含 `manual_trigger` 人工入队); + - 列表项字段:`id`/`card_id`/`iccid`/`task_type(_name)`/`status(_name)`/`trigger_type(_name)`/`trigger_types`/`trigger_count`/`attempt_count`/`result(_name)`/`failure_reason`/`shop_id_snapshot`/`source_order_id`/`source_package_usage_id`/`manual_operator_id(_name)`/`manual_reason`/`claimed_at`/`last_triggered_at`/`created_at`/`updated_at`; + - 人工优先入队:`POST /api/admin/polling-priority-items`: + - 请求 `{card_id, reason}`,`reason` 必填、最长 500 字符; + - 语义:为该卡的全部纳入轮询任务类型(实名/流量/套餐/卡状态)建立或合并优先轮询项; + - 约束:同一卡同一任务类型至多一条活动项,重复入队合并 `trigger_count` 与来源集合; + - 边界:`不受` 既有人工触发的每日次数上限与 24 小时去重约束; + - 边界:`不修改` 调度优先级,也 `不绕过` 既有并发上限; + - 响应:`{card_id, created_count, merged_count, task_types, task_type_names, items[{item_id, task_type(_name), status(_name), trigger_count, created, last_triggered_at}]` + - 优先轮询项详情:`GET /api/admin/polling-priority-items/{id}`: + - 越权与不存在返回同一响应,不产生可枚举差异; + - `不提供` 优先级分级、有效期与人工重触发入口。 +- 任务类型 `task_type`:`polling:realname` 实名检查 / `polling:carddata` 流量检查 / `polling:package` 套餐检查 / `polling:card_status` 卡状态检查。 +- 状态 `status`:`pending` 待执行 / `processing` 执行中 / `completed` 已完成 / `failed` 失败出队。 +- 触发类型 `trigger_type`:`purchase_activated` 主套餐购买后立即生效 / `renewal_activated` 续购套餐生效 / `queue_activated` 排队主套餐顺延生效 / `addon_activated` 加油包生效 / `no_valid_package` 资产无有效套餐 / `manual_trigger` 人工入队。 +- 执行结果 `result`:`success` 成功 / `failed` 失败 / 空值表示未出结果。 +- 错误响应:`400` 请求参数错误 / `401` 未认证或认证已过期 / `403` 无权访问 / `500` 服务器内部错误。 + +## Impact + +- Affected specs: + - `polling-priority-queue` — 新增能力 +- Affected code: + - `src/api/modules/pollingPriorityQueue.ts`(新增) + - `src/api/modules/index.ts` + - `src/types/api/pollingPriorityQueue.ts`(新增) + - `src/types/api/index.ts` + - `src/config/constants/augustIteration.ts` + - `src/views/polling-management/priority-queue/index.vue`(新增) + - `src/router/routesAlias.ts` + - `src/router/routes/asyncRoutes.ts` + - `src/locales/langs/zh.json` + - `src/locales/langs/en.json` \ No newline at end of file diff --git a/openspec/changes/add-polling-priority-queue/specs/polling-priority-queue/spec.md b/openspec/changes/add-polling-priority-queue/specs/polling-priority-queue/spec.md new file mode 100644 index 0000000..0203e5b --- /dev/null +++ b/openspec/changes/add-polling-priority-queue/specs/polling-priority-queue/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: 优先轮询项列表 + +The admin frontend SHALL provide a paginated list of card polling priority items through `GET /api/admin/polling-priority-items`. The query SHALL support `page`(默认 1,最小 1)、`page_size`(默认 20,最大 100)以及筛选参数 `card_id`、`task_type`、`status`、`trigger_type`,并按创建时间倒序返回。 + +#### Scenario: 分页查询优先轮询项 + +- **WHEN** 用户进入优先队列页面或提交筛选条件 +- **THEN** 前端 MUST 调用 `GET /api/admin/polling-priority-items` +- **AND** 携带 `page`、`page_size` 及所选筛选参数 +- **AND** 页面 MUST 使用响应中的 `data.items`、`data.page`、`data.size` 和 `data.total` 渲染列表 + +#### Scenario: 数据范围下推 + +- **WHEN** 当前登录账号为代理 +- **THEN** 列表 MUST 只返回自身及下级店铺资产对应的优先轮询项 +- **AND** 平台卡对应的优先轮询项 MUST NOT 出现在列表中 + +### Requirement: 人工优先入队 + +The admin frontend SHALL enqueue a card for priority polling through `POST /api/admin/polling-priority-items` with request body `{ card_id, reason }`. The `reason` SHALL be required and no longer than 500 characters. The operation SHALL create or merge priority items for all in-scope polling task types(实名/流量/套餐/卡状态)of the card. The frontend SHALL NOT apply any existing manual daily quota or 24-hour deduplication constraints, SHALL NOT modify scheduling priority, and SHALL NOT bypass existing concurrency limits. + +#### Scenario: 人工入队成功 + +- **WHEN** 用户填写卡号与原因并提交人工入队 +- **THEN** 前端 MUST 调用 `POST /api/admin/polling-priority-items` +- **AND** 请求体为 `{ "card_id": 卡ID, "reason": "加急原因" }` +- **AND** 页面 MUST 展示响应中的 `created_count`、`merged_count`、`task_types`、`task_type_names` 与 `items` + +#### Scenario: 重复入队合并 + +- **WHEN** 同一卡同一任务类型已存在活动优先轮询项且再次入队 +- **THEN** 该卡该任务类型至多保持一条活动项 +- **AND** 响应 MUST 返回合并后的项并将该次入队计入 `trigger_count` + +#### Scenario: 原因校验 + +- **WHEN** `reason` 为空或超过 500 字符 +- **THEN** 前端 MUST 阻止提交并给出校验提示 + +### Requirement: 优先轮询项详情 + +The admin frontend SHALL query a single priority polling item through `GET /api/admin/polling-priority-items/{id}`. 越权与不存在的响应 MUST 保持一致,不产生可枚举差异。该能力 SHALL NOT 提供优先级分级、有效期与人工重触发入口。 + +#### Scenario: 查询单项详情 + +- **WHEN** 用户查看某条优先轮询项详情 +- **THEN** 前端 MUST 调用 `GET /api/admin/polling-priority-items/{id}` +- **AND** 页面 MUST 展示卡、任务类型、状态、触发类型、执行结果、失败原因、操作者、触发与尝试次数及相关时间字段 + +#### Scenario: 越权与不存在不区分 + +- **WHEN** 当前账号无权访问该记录或该记录不存在 +- **THEN** 前端 MUST 按同一错误处理,不得通过响应差异区分越权与不存在 + +### Requirement: 枚举与状态映射 + +The admin frontend SHALL map and display the backend enums for task type, status, trigger type and result with their Chinese labels. + +#### Scenario: 任务类型展示 + +- **WHEN** 列表返回 `task_type` 为 `polling:realname` / `polling:carddata` / `polling:package` / `polling:card_status` +- **THEN** 页面 MUST 分别展示为 实名检查 / 流量检查 / 套餐检查 / 卡状态检查 + +#### Scenario: 状态与触发类型展示 + +- **WHEN** 列表返回 `status`(`pending`/`processing`/`completed`/`failed`)、`trigger_type`(`purchase_activated`/`renewal_activated`/`queue_activated`/`addon_activated`/`no_valid_package`/`manual_trigger`)与 `result`(`success`/`failed`/空) +- **THEN** 页面 MUST 展示对应的中文名称与枚举值 +- **AND** `result` 为空时 MUST 展示为未出结果 + +### Requirement: 错误响应处理 + +The admin frontend SHALL handle the documented error responses for the priority queue APIs: `400` 请求参数错误、`401` 未认证或认证已过期、`403` 无权访问、`500` 服务器内部错误。 + +#### Scenario: 权限与参数错误提示 + +- **WHEN** 接口返回 `401`、`403` 或参数校验错误 +- **THEN** 页面 MUST 展示对应错误提示且不产生前端异常 diff --git a/openspec/changes/add-polling-priority-queue/tasks.md b/openspec/changes/add-polling-priority-queue/tasks.md new file mode 100644 index 0000000..dbb4aa1 --- /dev/null +++ b/openspec/changes/add-polling-priority-queue/tasks.md @@ -0,0 +1,30 @@ +# Tasks: 轮询管理 - 优先队列 + +## 1. 类型 +- [ ] 1.1 新增 `src/types/api/pollingPriorityQueue.ts`:优先轮询项 `PollingPriorityItem`(`id` / `card_id` / `iccid` / `task_type` / `task_type_name` / `status` / `status_name` / `trigger_type` / `trigger_type_name` / `trigger_types` / `trigger_count` / `attempt_count` / `result` / `result_name` / `failure_reason` / `shop_id_snapshot` / `source_order_id` / `source_package_usage_id` / `manual_operator_id` / `manual_operator_name` / `manual_reason` / `claimed_at` / `last_triggered_at` / `created_at` / `updated_at`) +- [ ] 1.2 查询参数、分页响应 `PollingPriorityItemPageResult`、人工入队请求/响应、入队项 `PriorityItemResult` 类型 +- [ ] 1.3 `src/types/api/index.ts` 导出新类型 + +## 2. API +- [ ] 2.1 新增 `src/api/modules/pollingPriorityQueue.ts`: + - `getPriorityItems`:`GET /api/admin/polling-priority-items`,分页查询卡轮询优先项,按创建时间倒序,支持卡 `card_id`、任务类型 `task_type`、状态 `status`、触发类型 `trigger_type` 筛选 + - `createPriorityItems`:`POST /api/admin/polling-priority-items`,人工优先入队,请求 `{ card_id, reason }` + - `getPriorityItemDetail`:`GET /api/admin/polling-priority-items/{id}`,查询单项详情 +- [ ] 2.2 `src/api/modules/index.ts` 导出新服务 + +## 3. 页面 +- [ ] 3.1 `src/router/routesAlias.ts` 新增优先队列路由别名,`src/router/routes/asyncRoutes.ts` 在轮询管理下注册路由与菜单 +- [ ] 3.2 新增 `src/views/polling-management/priority-queue/index.vue`:筛选(卡/任务类型/状态/触发类型)、分页、表格展示列表项字段 +- [ ] 3.3 实现任务类型、状态、触发类型、执行结果枚举映射与中文展示 +- [ ] 3.4 实现人工优先入队弹窗:选择卡、原因必填校验(最长 500 字符),提交后展示 created_count / merged_count / items +- [ ] 3.5 实现单项详情查看(页面或抽屉),越权与不存在按同一错误处理 + +## 4. 常量与文案 +- [ ] 4.1 在 `src/config/constants/augustIteration.ts`(或新增常量文件)登记任务类型/状态/触发类型/结果枚举与中文名称 +- [ ] 4.2 `src/locales/langs/zh.json`、`src/locales/langs/en.json` 补充菜单与页面文案 + +## 5. Verification +- [ ] 5.1 验证筛选与分页参数正确传递、列表按创建时间倒序 +- [ ] 5.2 验证人工入队成功/合并/原因校验(空与超长) +- [ ] 5.3 验证详情接口与越权/不存在不区分 +- [ ] 5.4 运行 lint、类型检查与构建 diff --git a/src/api/modules/assetWallet.ts b/src/api/modules/assetWallet.ts new file mode 100644 index 0000000..88a5ae9 --- /dev/null +++ b/src/api/modules/assetWallet.ts @@ -0,0 +1,27 @@ +import { BaseService } from '../BaseService' +import type { + BaseResponse, + AssetAutoRenewalConfig, + UpdateAssetAutoRenewalConfigRequest +} from '@/types/api' + +/** + * 资产钱包相关服务 + */ +export class AssetWalletService extends BaseService { + /** + * 查询自动续费配置 + */ + static getAutoRenewalConfig(): Promise> { + return this.get>('/api/admin/asset-auto-renewal-config') + } + + /** + * 保存自动续费配置 + */ + static saveAutoRenewalConfig( + data: UpdateAssetAutoRenewalConfigRequest + ): Promise> { + return this.put>('/api/admin/asset-auto-renewal-config', data) + } +} diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index 4241eab..442386c 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -53,3 +53,7 @@ export { H5PopupConfigurationService } from './h5PopupConfiguration' // export { SettingService } from './setting' export { PackageTrafficAlertService } from './packageTrafficAlert' + +export { PollingPriorityQueueService } from './pollingPriorityQueue' + +export { AssetWalletService } from './assetWallet' diff --git a/src/api/modules/pollingPriorityQueue.ts b/src/api/modules/pollingPriorityQueue.ts new file mode 100644 index 0000000..76b81ea --- /dev/null +++ b/src/api/modules/pollingPriorityQueue.ts @@ -0,0 +1,53 @@ +/** + * 轮询优先队列 API + */ +import { BaseService } from '../BaseService' +import type { + BaseResponse, + PollingPriorityItem, + PollingPriorityItemPageResult, + PollingPriorityItemQueryParams, + CreatePollingPriorityItemRequest, + CreatePollingPriorityItemResponse +} from '@/types/api' + +/** + * 轮询优先队列服务 + */ +export class PollingPriorityQueueService extends BaseService { + /** + * 获取优先轮询项列表 + * GET /api/admin/polling-priority-items + * 按创建时间倒序返回,支持卡ID、任务类型、状态、触发类型筛选 + */ + static getPriorityItems( + params?: PollingPriorityItemQueryParams + ): Promise> { + return this.get>( + '/api/admin/polling-priority-items', + params + ) + } + + /** + * 人工优先入队 + * POST /api/admin/polling-priority-items + * 请求体 { card_id, reason },为卡片全部范围内的轮询任务类型创建或合并优先项 + */ + static createPriorityItems( + data: CreatePollingPriorityItemRequest + ): Promise> { + return this.post>( + '/api/admin/polling-priority-items', + data + ) + } + + /** + * 获取优先轮询项详情 + * GET /api/admin/polling-priority-items/{id} + */ + static getPriorityItemDetail(id: number): Promise> { + return this.get>(`/api/admin/polling-priority-items/${id}`) + } +} diff --git a/src/config/constants/index.ts b/src/config/constants/index.ts index 0e81fea..602a778 100644 --- a/src/config/constants/index.ts +++ b/src/config/constants/index.ts @@ -43,3 +43,6 @@ export * from './packageTrafficAlert' export * from './bulkPurchase' export * from './julyIteration' export * from './augustIteration' + +// 轮询优先队列相关 +export * from './pollingPriorityQueue' diff --git a/src/config/constants/pollingPriorityQueue.ts b/src/config/constants/pollingPriorityQueue.ts new file mode 100644 index 0000000..19a4709 --- /dev/null +++ b/src/config/constants/pollingPriorityQueue.ts @@ -0,0 +1,71 @@ +/** + * 轮询优先队列相关常量 + * 对应 docs/产品迭代8月份/优先队列.md + */ + +import type { + PollingPriorityTaskType, + PollingPriorityItemStatus, + PollingPriorityTriggerType, + PollingPriorityResult +} from '@/types/api' + +/** 任务类型选项 */ +export const POLLING_PRIORITY_TASK_TYPE_OPTIONS: Array<{ + label: string + value: PollingPriorityTaskType +}> = [ + { label: '实名检查', value: 'polling:realname' }, + { label: '流量检查', value: 'polling:carddata' }, + { label: '套餐检查', value: 'polling:package' }, + { label: '卡状态检查', value: 'polling:card_status' } +] + +export const getPollingPriorityTaskTypeName = ( + taskType?: PollingPriorityTaskType | string | null +) => + POLLING_PRIORITY_TASK_TYPE_OPTIONS.find((item) => item.value === taskType)?.label || + String(taskType || '-') + +/** 状态选项 */ +export const POLLING_PRIORITY_STATUS_OPTIONS: Array<{ + label: string + value: PollingPriorityItemStatus +}> = [ + { label: '待处理', value: 'pending' }, + { label: '处理中', value: 'processing' }, + { label: '已完成', value: 'completed' }, + { label: '失败', value: 'failed' } +] + +export const getPollingPriorityStatusName = ( + status?: PollingPriorityItemStatus | string | null +) => + POLLING_PRIORITY_STATUS_OPTIONS.find((item) => item.value === status)?.label || + String(status || '-') + +/** 触发类型选项 */ +export const POLLING_PRIORITY_TRIGGER_TYPE_OPTIONS: Array<{ + label: string + value: PollingPriorityTriggerType +}> = [ + { label: '购买激活', value: 'purchase_activated' }, + { label: '续费激活', value: 'renewal_activated' }, + { label: '队列激活', value: 'queue_activated' }, + { label: '附加套餐激活', value: 'addon_activated' }, + { label: '无有效套餐', value: 'no_valid_package' }, + { label: '人工触发', value: 'manual_trigger' } +] + +export const getPollingPriorityTriggerTypeName = ( + triggerType?: PollingPriorityTriggerType | string | null +) => + POLLING_PRIORITY_TRIGGER_TYPE_OPTIONS.find((item) => item.value === triggerType)?.label || + String(triggerType || '-') + +/** 执行结果名称(result 为空时展示为未出结果) */ +export const getPollingPriorityResultName = (result?: PollingPriorityResult | string | null) => { + if (result === 'success') return '成功' + if (result === 'failed') return '失败' + return '未出结果' +} diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index 8914dde..954cbe7 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -1216,6 +1216,16 @@ export const asyncRoutes: AppRouteRecord[] = [ title: '轮询监控', keepAlive: true } + }, + // 优先队列 + { + path: 'priority-queue', + name: 'PollingPriorityQueue', + component: RoutesAlias.PollingPriorityQueue, + meta: { + title: '优先队列', + keepAlive: true + } } ] } diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index 4dc436b..d8014c5 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -133,6 +133,7 @@ export enum RoutesAlias { PollingConfig = '/polling-management/config', // 轮询配置 ManualTrigger = '/polling-management/manual-trigger', // 手动触发 PollingMonitor = '/polling-management/monitor', // 轮询监控 + PollingPriorityQueue = '/polling-management/priority-queue', // 优先队列 // 公共组件 Success = '/result/success', // 成功 diff --git a/src/types/api/assetWallet.ts b/src/types/api/assetWallet.ts new file mode 100644 index 0000000..64da896 --- /dev/null +++ b/src/types/api/assetWallet.ts @@ -0,0 +1,46 @@ +/** + * 资产钱包相关类型定义 + */ + +/** + * 自动续费适用范围 + */ +export type AssetAutoRenewalScope = 'all' | 'specified' + +/** + * 资产钱包自动续费配置对象 + */ +export interface AssetAutoRenewalConfig { + /** 总开关(1:开启,0:关闭) */ + enabled: 0 | 1 + /** 总开关名称 */ + enabled_name: string + /** 适用范围(all:全部主套餐,specified:指定主套餐) */ + scope: AssetAutoRenewalScope + /** 适用范围名称 */ + scope_name: string + /** 指定主套餐ID列表(scope=specified 时非空) */ + package_ids: number[] + /** 统一到期前天数(1-90) */ + days_before_expiry: number + /** 配置版本 */ + config_version: number + /** 最近保存操作者 */ + updater: string + /** 最近保存时间 */ + updated_at: string +} + +/** + * 更新资产钱包自动续费配置请求参数 + */ +export interface UpdateAssetAutoRenewalConfigRequest { + /** 总开关(0/1,必填) */ + enabled: 0 | 1 + /** 适用范围(all/specified,必填) */ + scope: AssetAutoRenewalScope + /** 指定主套餐ID列表(scope=specified 时必填非空且仅当前可售主套餐) */ + package_ids: number[] + /** 统一到期前天数(1-90,必填) */ + days_before_expiry: number +} diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 286ce9a..199db90 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -147,3 +147,9 @@ export * from './h5PopupConfiguration' // 套餐真流量预警相关 export * from './packageTrafficAlert' + +// 轮询优先队列相关 +export * from './pollingPriorityQueue' + +// 资产钱包自动续费相关 +export * from './assetWallet' diff --git a/src/types/api/pollingPriorityQueue.ts b/src/types/api/pollingPriorityQueue.ts new file mode 100644 index 0000000..42e7d7c --- /dev/null +++ b/src/types/api/pollingPriorityQueue.ts @@ -0,0 +1,171 @@ +/** + * 轮询优先队列相关类型定义 + */ + +/** + * 优先轮询任务类型 + */ +export type PollingPriorityTaskType = + | 'polling:realname' + | 'polling:carddata' + | 'polling:package' + | 'polling:card_status' + +/** + * 优先轮询项状态 + */ +export type PollingPriorityItemStatus = 'pending' | 'processing' | 'completed' | 'failed' + +/** + * 优先轮询项触发类型 + */ +export type PollingPriorityTriggerType = + | 'purchase_activated' + | 'renewal_activated' + | 'queue_activated' + | 'addon_activated' + | 'no_valid_package' + | 'manual_trigger' + +/** + * 优先轮询执行结果 + */ +export type PollingPriorityResult = 'success' | 'failed' | '' + +/** + * 优先轮询项对象 + */ +export interface PollingPriorityItem { + /** 优先轮询项ID */ + id: number + /** 卡ID */ + card_id: number + /** 卡ICCID */ + iccid: string + /** 任务类型 */ + task_type: PollingPriorityTaskType + /** 任务类型名称 */ + task_type_name: string + /** 状态 */ + status: PollingPriorityItemStatus + /** 状态名称 */ + status_name: string + /** 触发类型 */ + trigger_type: PollingPriorityTriggerType + /** 触发类型名称 */ + trigger_type_name: string + /** 触发类型列表 */ + trigger_types: PollingPriorityTriggerType[] + /** 触发次数 */ + trigger_count: number + /** 尝试次数 */ + attempt_count: number + /** 执行结果 */ + result: PollingPriorityResult + /** 执行结果名称 */ + result_name: string + /** 失败原因 */ + failure_reason: string | null + /** 店铺快照ID */ + shop_id_snapshot: number | null + /** 来源订单ID */ + source_order_id: number | null + /** 来源套餐使用记录ID */ + source_package_usage_id: number | null + /** 人工操作者ID */ + manual_operator_id: number | null + /** 人工操作者名称 */ + manual_operator_name: string | null + /** 人工入队原因 */ + manual_reason: string | null + /** 认领时间 */ + claimed_at: string | null + /** 最后触发时间 */ + last_triggered_at: string | null + /** 创建时间 */ + created_at: string + /** 更新时间 */ + updated_at: string +} + +/** + * 优先轮询项查询参数 + */ +export interface PollingPriorityItemQueryParams { + /** 页码(默认1,最小1) */ + page?: number + /** 每页数量(默认20,最大100) */ + page_size?: number + /** 卡ID筛选 */ + card_id?: number + /** 任务类型筛选 */ + task_type?: PollingPriorityTaskType + /** 状态筛选 */ + status?: PollingPriorityItemStatus + /** 触发类型筛选 */ + trigger_type?: PollingPriorityTriggerType +} + +/** + * 优先轮询项分页响应 + */ +export interface PollingPriorityItemPageResult { + /** 优先轮询项列表 */ + items: PollingPriorityItem[] + /** 当前页 */ + page: number + /** 每页数量 */ + size: number + /** 总数 */ + total: number +} + +/** + * 人工优先入队请求参数 + */ +export interface CreatePollingPriorityItemRequest { + /** 卡ID */ + card_id: number + /** 入队原因(必填,最长500字符) */ + reason: string +} + +/** + * 人工入队结果项 + */ +export interface PriorityItemResult { + /** 优先轮询项ID */ + id: number + /** 卡ID */ + card_id: number + /** 卡ICCID */ + iccid: string + /** 任务类型 */ + task_type: PollingPriorityTaskType + /** 任务类型名称 */ + task_type_name: string + /** 状态 */ + status: PollingPriorityItemStatus + /** 状态名称 */ + status_name: string + /** 触发次数 */ + trigger_count: number + /** 创建时间 */ + created_at: string +} + +/** + * 人工优先入队响应 + */ +export interface CreatePollingPriorityItemResponse { + /** 新建数量 */ + created_count: number + /** 合并数量 */ + merged_count: number + /** 涉及任务类型列表 */ + task_types: PollingPriorityTaskType[] + /** 涉及任务类型名称列表 */ + task_type_names: string[] + /** 入队结果项列表 */ + items: PriorityItemResult[] +} diff --git a/src/views/asset-management/phone-asset-association/index.vue b/src/views/asset-management/phone-asset-association/index.vue index c5466e8..eb5cfe6 100644 --- a/src/views/asset-management/phone-asset-association/index.vue +++ b/src/views/asset-management/phone-asset-association/index.vue @@ -4,7 +4,6 @@ 企业 - 个人 + 个人 @@ -367,11 +367,7 @@
- + @@ -549,7 +545,7 @@ WithdrawalQualificationSubmitParams, WithdrawalRequestDetail, ResubmitWithdrawalRequestParams, - ShopCommissionRecordSource, + ShopCommissionRecordSource } from '@/types/api/commission' import { WithdrawalMethod, WithdrawalStatus } from '@/types/api/commission' import type { Order, OrderQueryParams } from '@/types/api/order' @@ -1336,7 +1332,6 @@ } }) - // ==================== 提现资料资格 ==================== const qualificationLoading = ref(false) @@ -1371,54 +1366,52 @@ return labels.length > 0 ? labels.join('、') : '-' } - const { - columnChecks: qualificationColumnChecks, - columns: qualificationColumns - } = useCheckedColumns(() => [ - { prop: 'subject_type_name', label: '主体类型', width: 90 }, - { prop: 'subject_code_masked', label: '主体证件号', minWidth: 160 }, - { prop: 'legal_person_id_card_masked', label: '法人身份证', minWidth: 170 }, - { - prop: 'status_name', - label: '状态', - width: 100, - formatter: (row: WithdrawalQualificationItem) => { - const tagType = row.status === 0 ? 'warning' : row.status === 1 ? 'success' : 'danger' - return h(ElTag, { type: tagType }, () => row.status_name || String(row.status)) + const { columnChecks: qualificationColumnChecks, columns: qualificationColumns } = + useCheckedColumns(() => [ + { prop: 'subject_type_name', label: '主体类型', width: 90 }, + { prop: 'subject_code_masked', label: '主体证件号', minWidth: 160 }, + { prop: 'legal_person_id_card_masked', label: '法人身份证', minWidth: 170 }, + { + prop: 'status_name', + label: '状态', + width: 100, + formatter: (row: WithdrawalQualificationItem) => { + const tagType = row.status === 0 ? 'warning' : row.status === 1 ? 'success' : 'danger' + return h(ElTag, { type: tagType }, () => row.status_name || String(row.status)) + } + }, + { + prop: 'approval_status_name', + label: '审批状态', + width: 100, + formatter: (row: WithdrawalQualificationItem) => row.approval_status_name || '-' + }, + { + prop: 'attachments', + label: '附件', + minWidth: 200, + formatter: (row: WithdrawalQualificationItem) => qualificationAttachmentLabels(row) + }, + { + prop: 'invalid_reason', + label: '作废原因', + minWidth: 160, + formatter: (row: WithdrawalQualificationItem) => row.invalid_reason || '-' + }, + { + prop: 'invalidated_at', + label: '作废时间', + width: 170, + formatter: (row: WithdrawalQualificationItem) => + row.invalidated_at ? formatDateTime(row.invalidated_at) : '-' + }, + { + prop: 'created_at', + label: '创建时间', + width: 170, + formatter: (row: WithdrawalQualificationItem) => formatDateTime(row.created_at) } - }, - { - prop: 'approval_status_name', - label: '审批状态', - width: 100, - formatter: (row: WithdrawalQualificationItem) => row.approval_status_name || '-' - }, - { - prop: 'attachments', - label: '附件', - minWidth: 200, - formatter: (row: WithdrawalQualificationItem) => qualificationAttachmentLabels(row) - }, - { - prop: 'invalid_reason', - label: '作废原因', - minWidth: 160, - formatter: (row: WithdrawalQualificationItem) => row.invalid_reason || '-' - }, - { - prop: 'invalidated_at', - label: '作废时间', - width: 170, - formatter: (row: WithdrawalQualificationItem) => - row.invalidated_at ? formatDateTime(row.invalidated_at) : '-' - }, - { - prop: 'created_at', - label: '创建时间', - width: 170, - formatter: (row: WithdrawalQualificationItem) => formatDateTime(row.created_at) - } - ]) + ]) const getQualificationList = async () => { if (!currentShopId.value) return @@ -1562,8 +1555,7 @@ confirmButtonText: '确认作废', cancelButtonText: '取消', inputPlaceholder: '作废原因(必填)', - inputValidator: (reason: string) => - reason && reason.trim() ? true : '作废原因必填', + inputValidator: (reason: string) => (reason && reason.trim() ? true : '作废原因必填'), type: 'warning' }) await CommissionService.voidWithdrawalQualification(row.id, { reason: value.trim() }) @@ -1571,8 +1563,8 @@ getQualificationList() } catch { // 取消或请求失败 - } - } + } + } // ==================== 提现详情与重提 ==================== @@ -1591,10 +1583,7 @@ detailLoading.value = true withdrawalDetail.value = null try { - const res = await CommissionService.getWithdrawalRequestDetail( - currentShopId.value, - row.id - ) + const res = await CommissionService.getWithdrawalRequestDetail(currentShopId.value, row.id) if (res.code === 0) { withdrawalDetail.value = res.data } @@ -1606,7 +1595,11 @@ } const getWithdrawalActions = (row: WithdrawalRequestItem) => { - const actions: Array<{ label: string; handler: (row: any) => void; type?: 'primary' | 'danger' }> = [ + const actions: Array<{ + label: string + handler: (row: any) => void + type?: 'primary' | 'danger' + }> = [ { label: '详情', handler: () => openWithdrawalDetail(row), @@ -1755,7 +1748,6 @@