fix: improve retry feedback and deletion stability

This commit is contained in:
hectorzhao
2026-08-02 20:39:49 +08:00
parent ecc3d7a504
commit 94e997ec4d
9 changed files with 273 additions and 41 deletions
@@ -291,6 +291,7 @@
- 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/awaiting_ack/failed/unconfirmed/rejected/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对非 `awaiting_ack` 记录执行人工重投,真实调用 Gateway `/downstream/receipt``/downstream/uplink`。主记录必须分开保存自动重试次数 `retryCount`、人工重投次数 `manualRetryCount` 和最近人工重投时间 `lastRetriedAt`,操作日志保留重投前状态与自动重试次数。 - 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/awaiting_ack/failed/unconfirmed/rejected/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对非 `awaiting_ack` 记录执行人工重投,真实调用 Gateway `/downstream/receipt``/downstream/uplink`。主记录必须分开保存自动重试次数 `retryCount`、人工重投次数 `manualRetryCount` 和最近人工重投时间 `lastRetriedAt`,操作日志保留重投前状态与自动重试次数。
- 同一下游投递的人工重投必须使用 `id + status + updatedAt` 条件更新原子认领;认领后先进入 `manual_requeueing`,避免运营并发请求或 Gateway pending 恢复扫描同时双发。调用完成后进入 `awaiting_ack` 或失败状态;进程中断超过默认 2 分钟后自动转回 `pending`,由 Gateway 单一路径恢复投递。阈值可通过 `CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS` 调整。 - 同一下游投递的人工重投必须使用 `id + status + updatedAt` 条件更新原子认领;认领后先进入 `manual_requeueing`,避免运营并发请求或 Gateway pending 恢复扫描同时双发。调用完成后进入 `awaiting_ack` 或失败状态;进程中断超过默认 2 分钟后自动转回 `pending`,由 Gateway 单一路径恢复投递。阈值可通过 `CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS` 调整。
- 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。 - 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。
- 下游人工重投必须提供完整且可见的操作反馈:单条和批量操作均先使用平台确认弹窗明确重复处理风险,确认提交期间禁用重投操作以防重复点击;真实接口返回后继续在弹窗中展示成功、部分成功或失败结果。单条成功须展示当前真实状态和人工重投次数,批量结果须展示总数、成功数、失败数及逐条失败原因;请求异常时必须提醒先核对列表状态和人工重投次数再决定是否重新提交,不能静默刷新或仅依赖页面顶部弱提示。
- 下游投递的 `pending` 展示必须结合真实尝试字段:自动与人工次数均为 0 时显示“待首次投递”,`retryCount > 0` 时显示“等待自动重试”,`manualRetryCount > 0` 时显示“人工重投排队中”。人工重投可重置新一轮自动重试预算,但不得把记录伪装成从未投递。 - 下游投递的 `pending` 展示必须结合真实尝试字段:自动与人工次数均为 0 时显示“待首次投递”,`retryCount > 0` 时显示“等待自动重试”,`manualRetryCount > 0` 时显示“人工重投排队中”。人工重投可重置新一轮自动重试预算,但不得把记录伪装成从未投递。
- 下游投递不得无限停留在 `pending`:Gateway 对未写出的结果必须返回明确的 `retryable/reasonCode/errorMessage`。状态回执在原消息映射已经丢失且缺少 `submitSequenceId` 时属于不可恢复错误,立即转为 `failed` 并保留失败原因和操作日志;客户端暂时离线属于可恢复错误,按既有重试次数与指数退避处理。所有 pending 从创建时间或最近人工重投时间起最多保留 72 小时,可通过 `CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS` 调整,超时后自动终结为 `failed` - 下游投递不得无限停留在 `pending`:Gateway 对未写出的结果必须返回明确的 `retryable/reasonCode/errorMessage`。状态回执在原消息映射已经丢失且缺少 `submitSequenceId` 时属于不可恢复错误,立即转为 `failed` 并保留失败原因和操作日志;客户端暂时离线属于可恢复错误,按既有重试次数与指数退避处理。所有 pending 从创建时间或最近人工重投时间起最多保留 72 小时,可通过 `CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS` 调整,超时后自动终结为 `failed`
- 新产生的客户侧状态回执投递 payload 必须保存短信原始 `cmppSubmitSequenceId`,使 Gateway 重启后仍能结合账号和平台消息 ID 重建与原 `CMPP_SUBMIT_RESP` 一致的非 0 `Msg_Id`;不得用伪造或为 0 的 `Msg_Id` 冒充成功投递。 - 新产生的客户侧状态回执投递 payload 必须保存短信原始 `cmppSubmitSequenceId`,使 Gateway 重启后仍能结合账号和平台消息 ID 重建与原 `CMPP_SUBMIT_RESP` 一致的非 0 `Msg_Id`;不得用伪造或为 0 的 `Msg_Id` 冒充成功投递。
@@ -524,7 +525,9 @@
- 引流信息字段库:用于签名/报备资料结构化采集。 - 引流信息字段库:用于签名/报备资料结构化采集。
- 企业应用级黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。 - 企业应用级黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。
- 企业黑名单必须绑定到具体短信应用,支持按企业、应用、手机号、入库原因、状态搜索;发送预览、风控和发送链路只能拦截当前应用的 active 黑名单号码,不得把同企业其他应用的黑名单串用;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。 - 企业黑名单必须绑定到具体短信应用,支持按企业、应用、手机号、入库原因、状态搜索;发送预览、风控和发送链路只能拦截当前应用的 active 黑名单号码,不得把同企业其他应用的黑名单串用;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。
- 全局黑名单“入库时间”、敏感词“创建时间”及运营端其他日期时间字段统一显示为北京时间 `YYYY-MM-DD HH:mm:ss`;格式化必须显式使用 `Asia/Shanghai`,不得直接展示 UTC ISO 字符串或依赖访问者设备时区。
- 企业模板管理的企业名称、企业应用、模板名称、模板内容必须是互相独立且可组合的服务端查询条件;企业签名管理的企业名称、企业应用、签名名称/用途和引流信息同样独立查询。按引流信息搜索时,以签名为父级、命中的引流信息为子级分组展开。 - 企业模板管理的企业名称、企业应用、模板名称、模板内容必须是互相独立且可组合的服务端查询条件;企业签名管理的企业名称、企业应用、签名名称/用途和引流信息同样独立查询。按引流信息搜索时,以签名为父级、命中的引流信息为子级分组展开。
- 企业签名、企业模板和其他删除治理入口成功后必须先展示“删除已完成”、操作单号及幂等重放状态;用户关闭结果弹窗后再刷新父列表,不能因目标卡片提前卸载而丢失成功反馈。
- 企业应用管理提供企业名称、企业应用名称和状态三个独立服务端查询条件。企业黑名单搜索区提供企业名称、企业应用、手机号码、入库原因和状态五个独立条件。 - 企业应用管理提供企业名称、企业应用名称和状态三个独立服务端查询条件。企业黑名单搜索区提供企业名称、企业应用、手机号码、入库原因和状态五个独立条件。
- 运营端充值记录和短信记录表头统一左对齐。 - 运营端充值记录和短信记录表头统一左对齐。
- “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。 - “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。
+9 -2
View File
@@ -1436,7 +1436,8 @@
1. 进入运营端“下游投递记录”页面。 1. 进入运营端“下游投递记录”页面。
2. 分别按状态、投递类型、应用、关键字进行筛选,检查分页。 2. 分别按状态、投递类型、应用、关键字进行筛选,检查分页。
3. 打开一条记录详情,核对 payload、重试次数、最后错误和时间字段。 3. 打开一条记录详情,核对 payload、重试次数、最后错误和时间字段。
4. 对一条 `pending``failed` 记录执行人工重投。 4. 对一条 `pending``failed` 记录点击人工重投,先取消确认弹窗,确认没有调用重投接口
5. 再次点击重投并确认,观察提交期间按钮状态和接口成功后的结果弹窗;另模拟一次接口异常。
- 预期结果: - 预期结果:
- 页面列表来自真实 `/api/admin/operations/downstream-deliveries`,不是前端静态数组或本地状态拼装。 - 页面列表来自真实 `/api/admin/operations/downstream-deliveries`,不是前端静态数组或本地状态拼装。
- 详情展示真实 payload、`retryCount/nextRetryAt/deliveredAt/lastError` - 详情展示真实 payload、`retryCount/nextRetryAt/deliveredAt/lastError`
@@ -1444,6 +1445,8 @@
- 人工重投后 `manualRetryCount` 递增、`lastRetriedAt` 更新,新一轮 `retryCount` 从 0 开始;系统日志保留重投前状态、原自动重试次数和新人工重投次数。 - 人工重投后 `manualRetryCount` 递增、`lastRetriedAt` 更新,新一轮 `retryCount` 从 0 开始;系统日志保留重投前状态、原自动重试次数和新人工重投次数。
- 重投后若尚未真正写出,列表显示“人工重投排队中”,不得误显示“待首次投递”;自动失败退避中的 pending 显示“等待自动重试”。 - 重投后若尚未真正写出,列表显示“人工重投排队中”,不得误显示“待首次投递”;自动失败退避中的 pending 显示“等待自动重试”。
- `awaiting_ack` 记录在前端不可选且后端拒绝并发重投,不能仅依赖按钮禁用。 - `awaiting_ack` 记录在前端不可选且后端拒绝并发重投,不能仅依赖按钮禁用。
- 确认弹窗明确展示投递类型、消息 ID 和重复处理风险;取消时不得请求后端,确认提交期间操作按钮禁用并显示处理中状态。
- 单条成功弹窗展示真实返回的当前状态和 `manualRetryCount`;接口失败时弹窗展示错误,并提醒先刷新核对人工次数再决定是否重试,不能静默失败或诱导重复提交。
### TC-GW-015 下游投递指数退避 ### TC-GW-015 下游投递指数退避
@@ -1465,11 +1468,13 @@
- 步骤: - 步骤:
1. 在页面勾选多条可重投记录。 1. 在页面勾选多条可重投记录。
2. 点击“批量重投”。 2. 点击“批量重投”。
3. 检查后端返回的成功/失败汇总,并刷新列表 3. 在平台确认弹窗中取消一次,确认未调用接口;再次打开并确认,检查提交期间防重复状态
4. 检查后端返回的成功/失败汇总和逐条失败原因,并刷新列表。
- 预期结果: - 预期结果:
- 页面调用真实 `/api/admin/operations/downstream-deliveries/requeue` 批量接口,不是前端逐条伪造结果。 - 页面调用真实 `/api/admin/operations/downstream-deliveries/requeue` 批量接口,不是前端逐条伪造结果。
- 后端逐条执行真实重投,返回 `total/successCount/failedCount/results` - 后端逐条执行真实重投,返回 `total/successCount/failedCount/results`
- 成功和失败记录都会保留真实后端状态与错误信息;空选择时接口拒绝执行。 - 成功和失败记录都会保留真实后端状态与错误信息;空选择时接口拒绝执行。
- 批量确认弹窗展示所选条数和重复处理风险;结果弹窗始终展示总数、成功数和失败数,部分失败时列出真实 `results[].errorMessage`,全成功时也不能静默关闭或只刷新列表。
### TC-GW-017 下游投递告警统一聚合 ### TC-GW-017 下游投递告警统一聚合
@@ -3414,6 +3419,8 @@ npm run verify:phase8
| TC-ADMIN-023 | 准备多个企业产生不同的当日真实消费金额后打开企业列表。 | API 结果按 `todaySpendCents` 降序;相同金额按企业名称和 id 稳定排序;金额来自 PostgreSQL 当日短信记录聚合。 | | TC-ADMIN-023 | 准备多个企业产生不同的当日真实消费金额后打开企业列表。 | API 结果按 `todaySpendCents` 降序;相同金额按企业名称和 id 稳定排序;金额来自 PostgreSQL 当日短信记录聚合。 |
| TC-ADMIN-024 | 准备多个企业应用产生不同的当日真实发送数量后打开企业应用列表。 | API 结果按 `sentToday` 降序;相同数量按应用名称和 id 稳定排序;数量来自 PostgreSQL 当日短信记录聚合。 | | TC-ADMIN-024 | 准备多个企业应用产生不同的当日真实发送数量后打开企业应用列表。 | API 结果按 `sentToday` 降序;相同数量按应用名称和 id 稳定排序;数量来自 PostgreSQL 当日短信记录聚合。 |
| TC-ADMIN-025 | 将通道真实成本分别设为 3、3.5、3.456 分并打开通道列表。 | 分别显示 `3.00 分``3.50 分``3.46 分`;仅改变展示精度,不改写数据库成本及历史提交成本快照。 | | TC-ADMIN-025 | 将通道真实成本分别设为 3、3.5、3.456 分并打开通道列表。 | 分别显示 `3.00 分``3.50 分``3.46 分`;仅改变展示精度,不改写数据库成本及历史提交成本快照。 |
| TC-ADMIN-026 | 新增全局黑名单和敏感词,后端时间分别使用跨自然日的 UTC 时间;在不同时区浏览器中打开列表。 | “入库时间”和“创建时间”均按 `Asia/Shanghai` 显示为 `YYYY-MM-DD HH:mm:ss`,不出现 ISO `T/Z`,且结果不随浏览器设备时区变化。 |
| TC-ADMIN-027 | 连续多轮创建无依赖签名并删除;另连续多轮创建、编辑为待审核后再删除,同时存在待审核任务提醒。 | 每轮资格预检、填写原因、确认删除均只调用一次真实删除接口;结果区显示“删除已完成”和操作单号,刷新后签名不再出现;全局审核提醒不得被误判为删除失败。 |
### 17.4 Dashboard 指标细化 ### 17.4 Dashboard 指标细化
+27
View File
@@ -3071,3 +3071,30 @@ git diff --check
- 发布后API和Gateway自本次发布起error级日志均为0,`panic/fatal/unhandled/exception/error`关键字检查无新增异常;120秒内活跃下游客户连接仍为0。未发送、重投或补发真实短信,未修改真实通道账号、密码、启停状态、企业余额或客户连接。 - 发布后API和Gateway自本次发布起error级日志均为0,`panic/fatal/unhandled/exception/error`关键字检查无新增异常;120秒内活跃下游客户连接仍为0。未发送、重投或补发真实短信,未修改真实通道账号、密码、启停状态、企业余额或客户连接。
- 发布后供应商通道现状为3条connected 1/1`会员营销-铁布衫``赛邮行业-王斯评中转``赛邮行业-王斯评中转副本``会员营销-富泷`持续约90秒为failed 0/1,数据库原因为`authentication / connect response status: auth failed`。保留系统自动重连,未修改其账号、密码或启停状态。 - 发布后供应商通道现状为3条connected 1/1`会员营销-铁布衫``赛邮行业-王斯评中转``赛邮行业-王斯评中转副本``会员营销-富泷`持续约90秒为failed 0/1,数据库原因为`authentication / connect response status: auth failed`。保留系统自动重连,未修改其账号、密码或启停状态。
- 依赖缓解安全门禁通过。npm audit仍报告既有前端2项high(未使用的React Router RSC路径)和API 3项moderatePrisma工具链)告警,未执行可能引入破坏性升级的`audit fix --force` - 依赖缓解安全门禁通过。npm audit仍报告既有前端2项high(未使用的React Router RSC路径)和API 3项moderatePrisma工具链)告警,未执行可能引入破坏性升级的`audit fix --force`
## 2026-08-01 下游人工重投弹窗与结果反馈修复(本地未提交)
- 线上只读诊断确认预生产运行`37ffce40`,单条重投真实逻辑一直是操作前调用浏览器原生`window.confirm`、成功后仅静默刷新列表、失败时仅在页面顶部显示弱提示;`AdminDownstreamDeliveriesPage.tsx``RealseV2.0/c0a4317a``37ffce40`之间无差异,本次缺少成功反馈不是R0-R11拆文件引入的回归。拆分仅将同一真实POST接口迁入`src/api/admin/operations.api.ts`
- 线上`OperationLog`只读记录显示2026-07-31 16:01:5516:02:41已受理多条真实状态回执人工重投,其中同一记录人工次数达到2,说明静默成功与缺少处理中锁会增加误判和重复操作风险;诊断过程没有新增重投或修改线上业务数据。
- 运营端下游投递记录页改用平台`Modal`完成单条和批量重投确认,明确展示投递类型、消息ID或所选条数以及客户侧重复处理风险;提交期间确认按钮显示“重投处理中…”且所有重投入口禁用,避免同一页面重复点击。
- 单条真实接口成功后结果弹窗展示后端返回的当前投递状态和`manualRetryCount`;批量接口结果始终展示`total/successCount/failedCount`,部分或全部失败时列出真实逐条错误。请求异常时弹窗提醒先刷新核对人工次数和系统日志,避免在结果不明确时直接重复提交。
- 已同步需求文档和`TC-GW-014/TC-GW-016`,覆盖取消不请求、确认弹窗、处理中防重复、单条成功、批量全成功/部分失败、异常结果提示。代码按用户要求仅保留本地,未提交、未推送、未部署;未发送、补发或重投真实短信。
- 使用Node.js v24.16.0执行前端TypeScript和Vite v8.1.5生产构建通过,2532个模块完成转换;仅保留既有约2.01MB单chunk提示,`git diff --check`通过。应用内浏览器及Chrome均无可复用的本地运营端登录页,遵守验证码边界且避免真实重投,没有为了视觉验收伪造会话或调用写接口;登录后的确认/结果弹窗真实点击验收待后续具备会话时补测。
## 2026-08-02 运营端安全控制时间格式与企业签名删除稳定性回归(本地未提交)
- 全局黑名单“入库时间”和敏感词“创建时间”不再直接输出后端 UTC ISO 字符串,统一复用`formatDateTime`;共享格式化工具显式固定`Asia/Shanghai`并输出`YYYY-MM-DD HH:mm:ss`,避免依赖访问者设备时区。真实数据库全局黑名单时间为 UTC `2026-08-02 09:02:11`,页面显示北京时间`2026-08-02 17:02:11`;敏感词页面显示`2026-08-02 17:02:22`,两处均无`T/Z`
- 企业签名删除使用真实 Smoke Test Enterprise / Smoke SMS App 共执行10轮:3轮创建后直接删除、3轮编辑为待审核后删除、2轮成功反馈修复后复测及2轮截图/最终确认。PostgreSQL确认10条测试签名均为`auditStatus=deleted``OperationLog`存在10条`governance.delete/signature`记录;每轮刷新后列表均不再返回目标签名,未发生删除失败、重复删除或残留 active 数据。
- 原“不稳定”现象不是删除事务或幂等链路失败。删除组件成功后立即调用父列表刷新,目标签名卡片被移除时连同弹窗一起卸载,导致已经生成的“删除已完成/操作单号”结果区无法展示;同时`AppShell`存在独立的待审核任务原生 alert,签名编辑进入待审核后可能在时间上干扰操作观感,但本次10轮没有再次触发该提醒。
- `DeleteRiskAction`现改为删除成功后切换到独立结果视图,明确展示“删除已完成”和操作单号;用户关闭结果弹窗后才通知父列表刷新。修复后直接删除和编辑为待审核后删除均验证结果视图可见,关闭后列表刷新并移除签名。
- 已同步需求文档和`TC-ADMIN-026/TC-ADMIN-027`。Node.js v24.16.0下前端TypeScript和Vite v8.1.5生产构建通过(2532 modules),删除治理定向测试1 suite / 6 tests通过,`git diff --check`通过,应用内浏览器控制台无相关warning/error。
- 本轮测试数据使用独立前缀并全部按系统规则逻辑删除;全局黑名单、敏感词和签名仅保留规定的deleted审计历史。未发送、重投或补发短信,未修改企业余额、真实通道账号/密码/状态、Redis Stream或客户连接。修改仅保留本地,未提交、未推送、未部署;原有下游人工重投及其他工作区改动继续保留,不归因于本轮。
## 2026-08-02 下游重投反馈与时间/删除稳定性修复发布前门禁
- 用户明确授权提交、推送并部署当前工作区全部有效代码。发布范围为下游投递单条/批量人工重投确认与结果反馈、提交期间防重复操作、运营端日期时间统一为北京时间,以及删除治理成功结果在父列表刷新前稳定展示;同步包含需求和测试用例文档。
- 发布前重新执行`git status --short --branch`、完整`git diff``git fetch --prune`并分别核对`HEAD``origin/main`,二者均为`ecc3d7a5045b7669d102f4c81cd2f011b207b3b5`且无分叉。`RealseV2.0`注解标签仍指向`c0a4317a7ea641bab39294e596f58f859edfca73`,既定代码回滚基线有效。
- 发布前预生产实际运行`37ffce40b274e218e6e64210ac9f0f4db88106c8`,源码与数据库均为78条migration。API、Gateway、Nginx、PostgreSQL和MinIO为activeRedis实际端口监听且返回`PONG``gateway.submit.commands`消费者1、pending=0、lag=0。4条active供应商通道均为connected 1/1120秒内活跃下游连接为0API/Gateway近30分钟error级journal均为0。
- 本地首次Prisma命令命中系统PATH中的旧Node.js并因不支持`??=`退出,未进入schema检查且未产生代码改动;切换至工作区Node.js v24.14.0后,Prisma format、validate、generate和migrate status全部通过,本地数据库schema up to date。
- Node.js v24.14.0下API全量29 suites / 389 tests全部通过,API TypeScript正式构建、前端TypeScript与Vite v8.1.5生产构建通过(2532 modules);Gateway`go test ./... -count=1``go vet ./...`、19个Node结构门禁、2个Go结构门禁、依赖缓解安全门禁及`git diff --check`全部通过。Jest仅保留既有`--forceExit`开放句柄提示,Vite仅保留既有约2.01MB单chunk提示。
- `api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`继续作为构建缓存或临时产物排除,不提交、不删除、不错误归因。发布和验证过程禁止发送、重投或补发真实短信,禁止修改真实通道账号、密码、启停状态、企业余额或客户连接。
+122 -19
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react'; import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi'; import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui'; import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
@@ -42,6 +42,17 @@ const attemptStatusLabel: Record<string, string> = {
failed: '投递失败', failed: '投递失败',
}; };
type RequeueTarget =
| { kind: 'single'; record: DownstreamDeliveryRecord }
| { kind: 'batch'; ids: string[] };
type RequeueResult = {
status: 'success' | 'partial' | 'failed';
title: string;
message: string;
failures?: string[];
};
function formatLocalDate(value: Date) { function formatLocalDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`; return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
} }
@@ -149,6 +160,10 @@ export function AdminDownstreamDeliveriesPage() {
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null); const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dateRange, setDateRange] = useState<DateRangeValue>(recentSevenDays); const [dateRange, setDateRange] = useState<DateRangeValue>(recentSevenDays);
const [requeueTarget, setRequeueTarget] = useState<RequeueTarget | null>(null);
const [requeueBusy, setRequeueBusy] = useState(false);
const [requeueResult, setRequeueResult] = useState<RequeueResult | null>(null);
const requeueInFlightRef = useRef(false);
const loadData = useCallback(() => { const loadData = useCallback(() => {
setLoading(true); setLoading(true);
@@ -200,12 +215,61 @@ export function AdminDownstreamDeliveriesPage() {
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
const replayRecord = (record: DownstreamDeliveryRecord) => { const openSingleRequeue = (record: DownstreamDeliveryRecord) => {
const acknowledgedWarning = record.status === 'delivered' ? '该记录已收到客户端确认,' : ''; setRequeueResult(null);
if (!window.confirm(`${acknowledgedWarning}重投可能导致下游业务重复处理,确认继续吗?`)) return; setRequeueTarget({ kind: 'single', record });
adminApi.requeueDownstreamDelivery(record.id) };
.then(() => loadData())
.catch((failure: Error) => setError(failure.message || '人工重投失败')); const openBatchRequeue = () => {
if (selectedIds.length === 0) return;
setRequeueResult(null);
setRequeueTarget({ kind: 'batch', ids: [...selectedIds] });
};
const closeRequeueDialog = () => {
if (requeueBusy) return;
setRequeueTarget(null);
setRequeueResult(null);
};
const confirmRequeue = async () => {
if (!requeueTarget || requeueInFlightRef.current) return;
requeueInFlightRef.current = true;
setRequeueBusy(true);
setRequeueResult(null);
setError('');
try {
if (requeueTarget.kind === 'single') {
const updated = await adminApi.requeueDownstreamDelivery(requeueTarget.record.id);
setRequeueResult({
status: 'success',
title: '重投请求已受理',
message: `${deliveryTypeLabel[updated.deliveryType] ?? updated.deliveryType}已进入人工重投流程,当前状态为“${deliveryStatusLabel(updated)}”,人工重投次数 ${updated.manualRetryCount} 次。`,
});
} else {
const result = await adminApi.batchRequeueDownstreamDeliveries(requeueTarget.ids);
const failures = result.results
.filter((item) => item.status === 'failed')
.map((item) => `${item.id}${item.errorMessage || '重投失败'}`);
setRequeueResult({
status: result.failedCount === 0 ? 'success' : result.successCount > 0 ? 'partial' : 'failed',
title: result.failedCount === 0 ? '批量重投已受理' : result.successCount > 0 ? '批量重投部分完成' : '批量重投失败',
message: `共处理 ${result.total} 条,成功 ${result.successCount} 条,失败 ${result.failedCount} 条。`,
failures,
});
setSelectedIds([]);
}
loadData();
} catch (failure) {
setRequeueResult({
status: 'failed',
title: '重投请求未确认成功',
message: failure instanceof Error && failure.message ? failure.message : '人工重投失败,请先检查列表状态和系统日志,避免重复提交。',
});
} finally {
requeueInFlightRef.current = false;
setRequeueBusy(false);
}
}; };
return ( return (
@@ -400,16 +464,8 @@ export function AdminDownstreamDeliveriesPage() {
</Button> </Button>
<Button <Button
icon={<RefreshCw size={14} />} icon={<RefreshCw size={14} />}
disabled={selectedIds.length === 0} disabled={selectedIds.length === 0 || requeueBusy}
onClick={() => { onClick={openBatchRequeue}
if (!window.confirm(`即将重投 ${selectedIds.length} 条记录,可能导致下游业务重复处理,确认继续吗?`)) return;
adminApi.batchRequeueDownstreamDeliveries(selectedIds)
.then((result) => {
setError(result.failedCount > 0 ? `批量重投完成,成功 ${result.successCount} 条,失败 ${result.failedCount}` : '');
loadData();
})
.catch((failure: Error) => setError(failure.message || '批量重投失败'));
}}
variant="secondary" variant="secondary"
> >
@@ -466,9 +522,9 @@ export function AdminDownstreamDeliveriesPage() {
<div className="downstream-delivery-list__actions" role="cell"> <div className="downstream-delivery-list__actions" role="cell">
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<Button <Button
disabled={!replayableStatuses.has(record.status)} disabled={!replayableStatuses.has(record.status) || requeueBusy}
icon={<RefreshCw size={14} />} icon={<RefreshCw size={14} />}
onClick={() => replayRecord(record)} onClick={() => openSingleRequeue(record)}
size="sm" size="sm"
variant="secondary" variant="secondary"
> >
@@ -493,6 +549,53 @@ export function AdminDownstreamDeliveriesPage() {
</div> </div>
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null} {detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
{requeueTarget ? (
<Modal
footer={requeueResult ? (
<Button onClick={closeRequeueDialog}></Button>
) : (
<>
<Button disabled={requeueBusy} onClick={closeRequeueDialog} variant="ghost"></Button>
<Button disabled={requeueBusy} icon={<RefreshCw size={15} />} onClick={() => void confirmRequeue()} variant="warning">
{requeueBusy ? '重投处理中…' : requeueTarget.kind === 'single' ? '确认重投' : `确认重投 ${requeueTarget.ids.length}`}
</Button>
</>
)}
onClose={closeRequeueDialog}
open
title={requeueResult?.title ?? (requeueTarget.kind === 'single' ? '确认人工重投' : '确认批量人工重投')}
>
{requeueResult ? (
<div className={`downstream-requeue-result downstream-requeue-result--${requeueResult.status}`} role={requeueResult.status === 'success' ? 'status' : 'alert'}>
<span aria-hidden="true">
{requeueResult.status === 'success' ? <CheckCircle2 size={28} /> : <AlertTriangle size={28} />}
</span>
<div>
<p>{requeueResult.message}</p>
{requeueResult.failures?.length ? (
<ul>
{requeueResult.failures.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}
</ul>
) : null}
{requeueResult.status === 'failed' ? <small></small> : null}
</div>
</div>
) : (
<div className="downstream-requeue-confirm">
<AlertTriangle aria-hidden="true" size={24} />
<div>
<p>
{requeueTarget.kind === 'single'
? `${requeueTarget.record.status === 'delivered' ? '该记录已收到客户端确认。' : ''}确认重投这条${deliveryTypeLabel[requeueTarget.record.deliveryType] ?? requeueTarget.record.deliveryType}吗?`
: `即将重投所选 ${requeueTarget.ids.length} 条下游投递记录。`}
</p>
{requeueTarget.kind === 'single' ? <small> ID{requeueTarget.record.messageId ?? '-'}</small> : null}
<strong></strong>
</div>
</div>
)}
</Modal>
) : null}
</section> </section>
); );
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react'; import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi'; import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type GlobalBlacklistItem = DictionaryItem & { type GlobalBlacklistItem = DictionaryItem & {
phoneNumber?: string; phoneNumber?: string;
@@ -36,7 +37,7 @@ export function AdminGlobalBlacklistPage() {
const columns = useMemo<Array<TableColumn<GlobalBlacklistItem>>>(() => [ const columns = useMemo<Array<TableColumn<GlobalBlacklistItem>>>(() => [
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> }, { key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'createdAt', title: '入库时间', width: '190px', render: (record) => record.createdAt ?? '-' }, { key: 'createdAt', title: '入库时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' }, { key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
{ key: 'status', title: '状态', width: '120px', render: (record) => record.status ?? '-' }, { key: 'status', title: '状态', width: '120px', render: (record) => record.status ?? '-' },
{ {
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react'; import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi'; import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type SensitiveWordItem = DictionaryItem & { type SensitiveWordItem = DictionaryItem & {
word?: string; word?: string;
@@ -59,7 +60,7 @@ export function AdminSensitiveWordsPage() {
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> }, { key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> }, { key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}</Tag> }, { key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}</Tag> },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' }, { key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{ {
key: 'actions', key: 'actions',
title: '操作', title: '操作',
+25 -16
View File
@@ -45,13 +45,17 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
const completed = portal === 'admin' const completed = portal === 'admin'
? await adminApi.deleteGovernedTarget(targetType, targetId, body) ? await adminApi.deleteGovernedTarget(targetType, targetId, body)
: await clientApi.deleteGovernedTarget(targetType as Exclude<DeletionTargetType, 'channel'>, targetId, body); : await clientApi.deleteGovernedTarget(targetType as Exclude<DeletionTargetType, 'channel'>, targetId, body);
setResult(completed); onCompleted?.(completed); setResult(completed);
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '删除失败,请重新检查依赖后重试'); setError(failure instanceof Error ? failure.message : '删除失败,请重新检查依赖后重试');
} finally { setSubmitting(false); } } finally { setSubmitting(false); }
} }
function close() { if (!submitting) setOpen(false); } function close() {
if (submitting) return;
setOpen(false);
if (result) onCompleted?.(result);
}
const blocked = Boolean(preflight && !preflight.allowedActions.includes('delete')); const blocked = Boolean(preflight && !preflight.allowedActions.includes('delete'));
const footer = (requestClose: () => void) => result ? <Button onClick={close}></Button> : <> const footer = (requestClose: () => void) => result ? <Button onClick={close}></Button> : <>
<Button disabled={submitting} onClick={requestClose} variant="ghost"></Button> <Button disabled={submitting} onClick={requestClose} variant="ghost"></Button>
@@ -64,20 +68,25 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
<Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="danger">{children}</Button> <Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="danger">{children}</Button>
<Modal dirty={!result && reason.trim().length > 0} footer={({ requestClose }) => footer(requestClose)} onClose={close} open={open} title="删除资格与影响确认"> <Modal dirty={!result && reason.trim().length > 0} footer={({ requestClose }) => footer(requestClose)} onClose={close} open={open} title="删除资格与影响确认">
<div className="risk-action-content delete-risk-action"> <div className="risk-action-content delete-risk-action">
{loading ? <p role="status"></p> : null} {result ? (
{preflight ? <> <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong></strong><span>{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span></div></div>
<div className="risk-action-identity"> ) : (
{Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)} <>
</div> {loading ? <p role="status"></p> : null}
<section><h3></h3>{preflight.dependencies.length ? <ul className="delete-risk-dependencies">{preflight.dependencies.map((item) => <li key={item.kind}><strong>{item.label}</strong><span>{item.count} </span>{item.items.length ? <small>{item.items.join('')}</small> : <small></small>}</li>)}</ul> : null}</section> {preflight ? <>
{preflight.blockedReasons.length <div className="risk-action-identity">
? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul> {Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)}
: <p className="risk-action-passed"><ShieldCheck size={16} /></p>} </div>
<section><h3></h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul><p className="muted">{preflight.recoverability.description}</p></section> <section><h3></h3>{preflight.dependencies.length ? <ul className="delete-risk-dependencies">{preflight.dependencies.map((item) => <li key={item.kind}><strong>{item.label}</strong><span>{item.count} </span>{item.items.length ? <small>{item.items.join('')}</small> : <small></small>}</li>)}</ul> : null}</section>
{!blocked ? <Textarea label="删除原因" onChange={(event) => setReason(event.target.value)} placeholder="至少填写 4 个字符,原因将写入审计记录" required value={reason} /> : null} {preflight.blockedReasons.length
</> : null} ? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul>
{result ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong></strong><span>{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span></div></div> : null} : <p className="risk-action-passed"><ShieldCheck size={16} /></p>}
{error ? <p className="form-error" role="alert">{error}</p> : null} <section><h3></h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul><p className="muted">{preflight.recoverability.description}</p></section>
{!blocked ? <Textarea label="删除原因" onChange={(event) => setReason(event.target.value)} placeholder="至少填写 4 个字符,原因将写入审计记录" required value={reason} /> : null}
</> : null}
{error ? <p className="form-error" role="alert">{error}</p> : null}
</>
)}
</div> </div>
</Modal> </Modal>
</>; </>;
+66
View File
@@ -6919,6 +6919,72 @@
color: var(--color-text-strong); color: var(--color-text-strong);
} }
.downstream-requeue-confirm,
.downstream-requeue-result {
align-items: flex-start;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
gap: var(--space-3);
padding: var(--space-4);
}
.downstream-requeue-confirm {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.downstream-requeue-confirm > div,
.downstream-requeue-result > div {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.downstream-requeue-confirm p,
.downstream-requeue-confirm small,
.downstream-requeue-confirm strong,
.downstream-requeue-result p,
.downstream-requeue-result small {
margin: 0;
}
.downstream-requeue-confirm strong {
line-height: 1.6;
}
.downstream-requeue-result--success {
background: #f0fdf4;
border-color: #bbf7d0;
color: #166534;
}
.downstream-requeue-result--partial {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.downstream-requeue-result--failed {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.downstream-requeue-result p,
.downstream-requeue-result li {
line-height: 1.6;
overflow-wrap: anywhere;
}
.downstream-requeue-result ul {
margin: 0;
max-height: 180px;
overflow: auto;
padding-left: var(--space-5);
}
.downstream-delivery-toolbar > div, .downstream-delivery-toolbar > div,
.downstream-delivery-list__actions { .downstream-delivery-list__actions {
align-items: center; align-items: center;
+17 -2
View File
@@ -1,7 +1,22 @@
const beijingDateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
day: '2-digit',
hour: '2-digit',
hour12: false,
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone: 'Asia/Shanghai',
year: 'numeric',
});
export function formatDateTime(value?: string | Date | null) { export function formatDateTime(value?: string | Date | null) {
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-'; if (Number.isNaN(date.getTime())) return '-';
const pad = (part: number) => String(part).padStart(2, '0'); const parts = Object.fromEntries(
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; beijingDateTimeFormatter.formatToParts(date)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value]),
);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
} }