2 Commits
Author SHA1 Message Date
hectorzhao 809175b544 fix: enforce production React release builds
CSS quality / css-quality (push) Has been cancelled
2026-09-08 22:39:32 +08:00
hectorzhao 6816f56178 fix: harden notification polling and simplify pagination controls 2026-09-08 21:44:08 +08:00
17 changed files with 996 additions and 142 deletions
@@ -2239,3 +2239,8 @@
本节明确 5.19.1 中的事务范围为“每个日期独立事务”,四日窗口不共用一个长事务。对账、应用/通道利润及四类质量报表在同一日期内原子重建;失败保留该日旧报表,继续处理窗口内其他日期,只有四日全部成功才标记当天完成,失败下次检查仍可重试。默认启动 15 秒后执行、每小时检查,不承诺固定分钟触发。
应用利润成本只扫描目标日原短信关联的全部 accepted 提交,包括跨日补发;收入、成本快照、分片审计优先和历史成功回执兼容语义不变。通道维度仍按实际提交日统计。报表使用专属有限事务预算和数据库日锁,不调整发送/计费事务。T-5 及更早数据不被正常日任务改写,历史缺口须单独授权补齐;本轮不新增自动历史重算。实现与验收见 [日报生成超时修复](report-generation-reliability-20260908.md)。
### 2026-09-08 夜补充:公共容量控件与顶栏通知可靠性
通道报备明细及签名质量四Tab统一使用公共Pagination,去掉可见“每页数量”文字,仅显示容量选项,保持可访问名称、10/25/50/100、默认25和各Tab独立日期。运营顶栏通知刷新应合并重复触发、限制在途批次、超时取消、隐藏/离线/锁定暂停和有上限的失败退避;计数失败保留上次真实值并明确暂不可用,不能归零冒充成功。实现与浏览器外部注入问题边界见operations-fixes-20260908.md“浏览器异常与通知刷新修复”,不改业务端口、后端计数口径或短信链路。
+33
View File
@@ -34,3 +34,36 @@
- 通道报备明细首次进入/刷新默认25,改变容量回到第一页、清空当页选择,保留已应用筛选及URL范围;总数与页面内容来自同一个真实分页接口。增加请求序号和卸载失效保护,过期成功与失败都不能覆盖最新列表或错误;当前请求失败继续真实展示。
- 实际新增容量消费者只有AdminAnalyticsPage的四个模块与AdminReportTasksPage;不改CSS、后端、数据库、权限、详情/状态修改/导出逻辑或其他列表的容量设置。共享Pagination的原消费者通过兼容单测和前端全量回归验证。
- 验收覆盖四档请求、翻页后改容量、空结果、独立日期/筛选、乱序响应、跳转输入草稿、三视口和刷新/路由切换。执行结果见testing-progress.md本轮分页记录;用例OPS-PAGE0908-0105。
## 浏览器异常与通知刷新修复(2026-09-08 夜)
本轮授权为修复、本地提交、测试环境部署;预生产只读诊断,不推送、不发布预生产。补充本方案的公共分页和运营顶栏通知行为,不改变业务数据、API端口或计数口径。
### 当前证据与修复边界
- `et.reportAllChanges (<anonymous>:2:19429)` / `n.timeout (:2:5652)` 与 GoogleChrome/web-vitals 官方 issue #792 的堆栈及偏移完全一致。DevTools 的 LiveMetrics 在名为 `DevTools Performance Metrics` 的隔离上下文注入性能脚本,并在前面加一行 `window.devToolsReportSoftNavs`,说明匿名脚本第2行的来源机制。用户报告另一台 Chrome 152.0.7977.77 打开 F12 控制台时出现;不要求先打开 Performance 面板,DevTools 初始化即启动 LiveMetrics。仓库业务源码和前端依赖未引入 reportAllChanges/web-vitals。历史2026-09-05记录只把它列为外部注入待核验,没有完成这个异常的业务代码修复。
- 进一步在独立临时 Chrome 152.0.7977.76、真实预生产公开登录页捕获到了同名隔离上下文及匿名scriptId 5:长度20705 bytesSHA256 `0f2eb3b63431416befd0d826255fb1736117e0ddbca120c5c3e54aca03a1810d`,首行为 `window.devToolsReportSoftNavs = true;`,第2行19429列正是未经空数组检查的 `t.entries[0].startTime`。这确认了报错代码的DevTools归属和直接缺陷:延迟报告回调读取不存在的第一个性能条目。该次正常浏览未触发异常,另一台电脑为何产生空entries仍需现场事件证据。不通过覆盖Performance API、全局error过滤或吞错伪装修复。web-vitals #792仍开放,但DevTools已在2026-08-31合入正式修复[6a47f933Live Metrics: Handle empty INP entries](https://github.com/ChromeDevTools/devtools-frontend/commit/6a47f93393a72ca3ae77fdb0525296812d92f9a2),对应Chromium543499029,增加entries首项可选链和空条目测试;尚未确认包含该修复的已发布Chrome版本。隔离临时Chrome中已通过标准UI验证规避入口:DevTools F1 → 偏好设置 → Performance,关闭“启用软导航性能监控”,注入首行从true变false且刷新后保持。开关前后真实公开登录页各刷新验证码3次、异常均0;这证明控制开关生效,不冒称复现异常后已消除。关闭F12后同样应刷新以清除旧注入上下文。
- 六类通知 GET 的 ERR_CONNECTION_CLOSED 是传输层失败,与匿名脚本异常分别诊断。2026-09-08 21:0521:13API/Nginx稳定、无OOM或新增上游超时/限流/5xx证据;本机公网H1/H2共36次请求均收到预期401H2覆盖两个Cloudflare地址;源站本机约12ms,公网约1–4.65s。匿名401只证明链路可达,不代表登录后业务通过;没有依据修改Nginx、关闭HTTP2或认定Cloudflare/本机代理是根因。另一台电脑的历史连接关闭暂未复现,必须保留这一未验证项。
- 可复核的应用缺陷:AdminLayout原先30秒定时、focus和多种业务事件均直接触发六GET,没有批次互斥/超时/隐藏暂停,线上只读日志出现1–4秒内重复整批;四类计数失败时归零。该缺陷会放大网络抖动并显示误导计数,但不能据此声称已定位TCP关闭原因。
### 预期行为和实现范围
1. 公共Pagination容量只显示“25 条/页”等选中值,不显示“每页数量”标题;复用既有sr-only隐藏标签及唯一id保留无障碍名称。适用于通道报备明细与签名质量四Tab;四档10/25/50/100、默认25、独立日期/分页、其他Select标签和无容量消费者保持。
2. 运营顶栏仅保留一个通知请求批次,批次内六个独立只读GET并行。触发事件合并,业务刷新最多保留一次待执行刷新;失败后按30/60/120秒退避,上限120秒,focus不能绕过冷却连续发请求。
3. 单批设置15秒超时并取消请求,页面隐藏/离线/锁屏/卸载停止轮询和在途请求,恢复后按调度条件刷新。旧批次响应不能覆盖新状态;不新增全局POST重试、短信重投或业务写入。
4. 每类接口失败独立保留上次真实计数,并在对应通知入口说明计数暂不可用;首次未取得数据不能显示为已确认的零。成功后清除不可用状态。401/锁屏仍服从现有统一会话处理,不将权限失败包装成功。
5. 定向测试验证批次去重、取消/超时、退避、状态保留和恢复;真实测试环境验证六接口及分页,不用静态响应或localStorage代替业务数据。故障注入仅验收失败路径,与真实成功请求证据分别记录。
官方依据:[web-vitals #792](https://github.com/GoogleChrome/web-vitals/issues/792)、[LiveMetrics注入](https://raw.githubusercontent.com/ChromeDevTools/devtools-frontend/main/front_end/models/live-metrics/LiveMetrics.ts)、[设置注册](https://raw.githubusercontent.com/ChromeDevTools/devtools-frontend/main/front_end/panels/timeline/timeline-meta.ts)。
## 通知取消复核与前端构建模式补充(2026-09-08 22:35
第三轮真实测试浏览器诊断确认一个独立发布缺陷:标准 prepare 的 NODE_ENV=test 使 Vite/React 插件选择开发运行时,实际线上 index-TLGANFcU.js 包含 19 处 react_stack_bottom_frame、2 处 jsx-dev-runtimeSHA-256 为 a8db74bfdb24ef0892ca77fee891297d492d07b979c8653d5fd7c86c186e080c。先前仅依据生产构建命令推断线上运行生产 React 的判断撤回,以实际下载产物为准。
18 次通知原生 signal abort 均在可见且在线文档中发生,耗时约 3.114ms、reason=AbortError,全部落在 JS 225:176295 的通知 effect cleanupReact 146:26427/25089 映射为删除树 passive unmount 清理及其递归遍历,不是 15 秒超时。已确认取消来自布局清理链,但不能进一步把每次清理都写成 StrictMode 双 effect,不能据此宣称另一台电脑历史 ERR_CONNECTION_CLOSED 已定位或根除。原六类通知 GET 的保值、退避、取消及会话设计保持。
最小修复为发布工具仅给前端 npm run build 显式 production 环境;新增 Vite build/configResolved 的 isProduction 守卫,在错误模式下拒绝产出发布包,不改变 serve/Vitest配置或后端接口。真实 test/development 模式两个构建负例已拒绝;Windows 工具 37 项中 35 通过、2 平台跳过。默认构建/lint 正在执行、Linux 新 37 项待返回,不提前记录通过。须以重新生成的正式候选及真实三尺寸、刷新/跨路由、通知六 GET 验收关闭本问题,不能修改浏览器错误过滤来代替正式包复测。
最终检查结果补记(2026-09-08 22:36):默认 npm run build 成功,npm run lint 退出码 0;两个真实 test/development 构建负例准确拒绝。Linux 新版工具 37/37 通过、0 失败/0 跳过,耗时 0.192s,日志 SHA-256 为 305e7df43ff9d6506a755fd8d5e0adb6926e5ee89eb719d8baa015c231a4060b,位于主 TEMP/release-linux-unit-buildmode-2026-09-08T14-34-37-205Z/linux-unittest.log。本结果补齐上文执行中的本地检查;正式候选及真实部署页面仍待验收。
+26
View File
@@ -310,3 +310,29 @@ Windows内置tar解压含中文路径的完整归档曾报Invalid empty pathname
本次独立备份为测试/var/backups/cmpp-platform/night-risk-20260907T230742和预生产/opt/cmpp-platform-backups/night-risk-20260907T231021;前一轮失败备份目录不算恢复资产。旧API依赖及dist仍在各自/opt/cmpp-night-previous-时间戳,历史logs依赖继续保留。新审核记录和预留状态需结合业务恢复,不能把应用回退当作数据回退。
验收需同时读journald与应用append日志;本轮预生产journal无warning不能掩盖API文件内既有日报事务超时。在线回执可在备份与验收之间自动退款,遇到余额摘要变化应核对实际业务流水、消息和服务时间,不回写余额以强行制造摘要一致。测试安全代理缺少运行目录、预生产日报刷新超时作为独立遗留问题登记。
## 2026-09-08 前端候选安全门禁依赖修复
标准 prepare 的 security:verify 使用 createRequire(api/package.json) 读取 API 本地依赖及 brace-expansion 兼容适配器。仅安装根 node_modules 的前端候选可能向上解析到根依赖,导致安全门禁阻断。现已调整为:impact.frontend 或 impact.api 任一成立时,在独立候选依次执行 npm ci --include=dev、npm --prefix api ci --include=dev,再进入 security:verify 与 deploy:verify;不跳过安全检查,不在运行目录手工安装依赖。
npm run prisma:generate、API 生产构建,以及线上 api/dist、api/node_modules 的切换仍仅由 impact.api 控制。前端变更为安全检查安装的候选 API 依赖不进入线上 API;不因此停止或重启 API/Worker。依赖安装或门禁失败不生成可部署的 candidateHash,后续 deploy 拒绝进入备份和切换。
原测试发布编号 20260908T134432-6816f5617819-b92bbb04 已由标准 status 核验停在 preparebackup=null,尚未备份、停服或切换。工具修复改变 toolHash,须重新生成绑定新摘要的计划并执行 fresh preflight,不修改旧计划或复用失效现场检查;原失败候选和日志保留。
本次补充 4 项发布工具隔离回归:Windows 共 35 项,33 通过、2 项因符号链接权限与 Linux flock 平台限制跳过;Linux 35/35 通过且临时测试夹具残留 0。Linux 日志 SHA-256 为 77106f006cf22ea33c3b744c9c7cbf43d3885b32f4c6d71d8d00b49b1578e900;日志和补丁见本机 %TEMP%/cmpp-starttime-pagination-20260908。隔离测试使用真实临时归档、文件摘要和目录切换,外部命令受控替身仅用于测试,不替代真实发布验收。
授权变更记录:用户随后明确授权先完成测试环境验收,再推送本轮提交并发布预生产;此前“仅测试部署、不推送、不预生产”的表述保留为历史阶段边界。本节记录时业务提交 6816f5617819546850b1188508eda37866f4ca3c 已本地提交、尚未推送;修复工具仍属受保护的未跟踪文件,不据此纳入整套既有发布工具或其他脏文件。后续两环境执行与验收分别追加。
## 2026-09-08 22:35 前端正式构建模式修复
测试环境实际下载的 /assets/index-TLGANFcU.jsSHA-256a8db74bfdb24ef0892ca77fee891297d492d07b979c8653d5fd7c86c186e080c)含 react_stack_bottom_frame 与 jsx-dev-runtime。标准 prepare 把全部命令 NODE_ENV 设为 testVite 以 NODE_ENV===production 判断 isProductionReact 插件沿用该值,因此此前名为 build 的发布产物实际包含开发版 React。必须核验最终产物,不能只凭执行 vite build 推断生产运行时。
本次工具最小修复仅为前端 npm run build 复制独立命令环境并显式设置 NODE_ENV=production;安装、安全/部署门禁、API 的生成/构建仍保持原环境,既有依赖前置与组件切换范围不变。vite.config.ts 同时增加 apply: build 的 configResolved 守卫:isProduction 为假立即拒绝构建,防止其他构建入口再次产出开发版发布包;serve 与 Vitest 不应用该 build 守卫。
真实观测的 18 次通知信号取消发生于可见、在线页面,elapsed 约 3.114ms,均为 AbortError 且栈落在 225:176295 的通知 effect cleanup,不是 15 秒 TimeoutError。146:26427 对应 React 的 commitPassiveUnmountEffectsInsideOfDeletedTree_begin146:25089 对应递归 passive unmount;这是删除树清理路径,不能直接认定所有取消都是 StrictMode 双 effect,也不是另一台电脑 ERR_CONNECTION_CLOSED 的已证实根因。修正正式包后须重新验证资源模式和真实刷新/切页/通知行为。
截至本节,NODE_ENV=test、development 两个真实负例均被新守卫拒绝;默认构建和 lint 正在执行,尚未记为通过。工具 Windows 共 37 项,35 通过、2 项平台限制跳过;新增版本 Linux 37 项结果待返回。新工具摘要和新增 Vite 配置要求重新生成绑定精确提交的计划、执行对应证据与 fresh preflight;本节不表示新版本已部署或业务已验收。证据位于 %TEMP%/cmpp-starttime-pagination-20260908 的 live-react-mode-evidence.json、release-build-mode-fix.patch、build-mode-test.log、build-mode-development.log 及第三轮 browser 目录。
最终检查结果补记(2026-09-08 22:36):默认 npm run build 成功,npm run lint 退出码 0;两个真实 test/development 构建负例准确拒绝。Linux 新版工具 37/37 通过、0 失败/0 跳过,耗时 0.192s,日志 SHA-256 为 305e7df43ff9d6506a755fd8d5e0adb6926e5ee89eb719d8baa015c231a4060b,位于主 TEMP/release-linux-unit-buildmode-2026-09-08T14-34-37-205Z/linux-unittest.log。本结果补齐上文执行中的本地检查;正式候选及真实部署页面仍待验收。
+41
View File
@@ -5271,3 +5271,44 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对
| TC-DAILY-0908-07 | 默认/有效/非法/超大事务超时配置,实际 PostgreSQL 限时及事务结束 | 默认 30000ms、maxWait 5000ms;非法值回退默认、最大 120000msstatement_timeout 仅当前事务有效,其他业务事务不改变 |
| TC-DAILY-0908-08 | 失败后次周期、成功后同日重复周期,以及启动 15 秒内销毁服务 | 失败继续重试、全成功后同日跳过;销毁清除启动及周期定时器,无销毁后新任务 |
| TC-DAILY-0908-09 | 真实生成后调用报表查询/汇总/分页及 CSV | 数据与 PostgreSQL 一致;既有日期/租户/维度筛选和金额精度不变,API 无假成功或静态数据 |
### 浏览器注入、通知刷新与公共容量回归(2026-09-08 夜)
| 编号 | 场景/步骤 | 预期 |
| --- | --- | --- |
| OPS-PAGE0908-06 | 真实API打开通道报备明细和签名质量四Tab,在1600×1000、1366×768、390×844检查底部分页及刷新/跨路由 | 无可见“每页数量”标题,容量与翻页同区;隐藏名称可访问;10/25/50/100与默认25不变,各Tab日期独立,多个分页不串联,其他表单标签保持 |
| OPS-NOTIFY0908-01 | 通知批次仍在途时触发focus、定时和多个业务刷新事件 | 同时最多一批六GET,重复触发合并,业务事件至多一次后续刷新;不是触发多少次就发多少批 |
| OPS-NOTIFY0908-02 | 真实成功获取六类计数后单类/多类请求中断或超时 | 失败项保留上次真实值并提示暂不可用,成功项正常更新;首次失败不冒充已取得零值;没有全局POST重试 |
| OPS-NOTIFY0908-03 | 请求挂起超过15秒,连续失败,反复focus | 请求被abort;失败按30/60/120秒退避且有上限,focus不会造成重试风暴;恢复真实200后恢复普通轮询及可用说明 |
| OPS-NOTIFY0908-04 | 在途时隐藏页面/离线/锁屏/卸载,然后恢复可用状态 | 请求/计时器正确取消,旧响应不回写;恢复按调度刷新;现有401/权限/会话规则保持 |
| OPS-BROWSER0908-01 | Chrome152 F12报startTime时记录VM源码及执行上下文,对照DevTools软导航采集设置开/关并刷新切页 | 区分应用脚本与DevTools Performance Metrics;仅当实际A/B通过才记规避有效,不全局过滤异常;没有现场证据则记待验证 |
| OPS-BROWSER0908-02 | 连接关闭时对照同时间浏览器Network、源站access/error、API及Cloudflare链路 | 记录失败请求是否到达源站、协议/时间/状态;不能把匿名401或稍后正常当成历史故障已根除,不无依据关闭HTTP2或修改业务配置 |
### 标准发布前端候选安全依赖回归(2026-09-08)
本组验证候选依赖前置条件和既有组件切换边界;隔离命令替身仅用于故障测试,不代替真实安全门禁、现场 preflight 或业务验收。
| 编号 | 场景/步骤 | 预期 |
| --- | --- | --- |
| REL-PREP0908-01 | 仅前端变更,从真实临时归档 prepare;安全检查要求候选 API 本地依赖,再执行受控 deploy | 根 npm ci、API npm ci 先于 security:verify/deploy:verify;不显式生成 Prisma、不构建 API。前端产物更新,线上 api/dist/api/node_modules 摘要不变,无 API 目录切换或服务重启命令 |
| REL-PREP0908-02 | 仅 API 变更,记录候选依赖、生成、门禁与构建顺序 | 根与 API npm ci 各执行一次,之后显式 Prisma 生成、security:verify、deploy:verify、API build;没有遗漏安全门禁或误跑前端构建 |
| REL-PREP0908-03 | 前端候选的 API npm ci 注入失败,随后尝试 deploy | prepare 记录失败,未进入 security:verify,不产生 candidateHash,线上应用摘要不变;deploy 在备份/现场切换前拒绝 |
| REL-PREP0908-04 | 前端候选依赖安装成功,security:verify 注入失败,随后尝试 deploy | 不执行后续 deploy:verify 或构建,不产生 candidateHash,线上应用摘要不变;deploy 拒绝进入备份、停服和切换,不绕过安全门禁 |
本次执行:上述 4 项均通过;发布工具全量 Windows 35 项中 33 通过、2 项平台限制跳过,Linux 35/35 通过,Linux 日志 SHA-256 为 77106f006cf22ea33c3b744c9c7cbf43d3885b32f4c6d71d8d00b49b1578e900。原测试发布 20260908T134432-6816f5617819-b92bbb04 仅 prepare 失败、backup=null,未切换。用户后续授权的“测试验收后推送、再发布预生产”须分别完成真实验收,不能依据本组隔离测试直接判定发布完成。
### 正式前端构建模式与取消归因回归(2026-09-08 22:35
| 编号 | 场景/步骤 | 预期与当前执行状态 |
| --- | --- | --- |
| REL-BUILD0908-01 | prepare 分别处理前端/API 影响,逐条记录命令 NODE_ENV,父进程预设 development | 仅前端 npm run build 为 production;安装、门禁与 API 生成/构建仍为 test,父进程保持 development。工具新增隔离回归已通过 |
| REL-BUILD0908-02 | 前端构建命令注入失败,随后尝试 deploy | prepare 失败,不进入后续 style 等门禁,不产生 candidateHashdeploy 在备份/切换前拒绝;工具新增回归已通过 |
| REL-BUILD0908-03 | 真实 Vite build 分别设置 NODE_ENV=test、development,再运行默认正式构建 | 两个错误模式被 apply: build/configResolved isProduction 守卫拒绝,错误清楚且不放宽门禁;两个真实负例已拒绝,默认构建与 lint 仍在执行,待追加结果。守卫不应用 serve/Vitest |
| OPS-NOTIFY0908-05 | 下载实际发布 JS 核验模式,再采集快速切页/刷新时的 signal.reason、耗时、文档和调用栈 | 生产包不得包含开发 React/JSX 运行时;区分 15 秒超时、布局清理和浏览器导航取消,不把 ERR_ABORTED 一律当网络故障或一律忽略。旧包已确认 18 次可见在线的 3.114ms AbortError 均落 effect cleanup;正式包尚待真实复测 |
旧包 SHA-256a8db74bfdb24ef0892ca77fee891297d492d07b979c8653d5fd7c86c186e080ccleanup 位置 225:176295React 146:26427/25089 为删除树 passive unmount 链,不等同于确证全部 StrictMode 双 effect,也不证明历史 ERR_CONNECTION_CLOSED 根因。工具本次 Windows 37 项中 35 通过、2 平台限制跳过;Linux 新 37 项待回报。
最终检查结果补记(2026-09-08 22:36):默认 npm run build 成功,npm run lint 退出码 0;两个真实 test/development 构建负例准确拒绝。Linux 新版工具 37/37 通过、0 失败/0 跳过,耗时 0.192s,日志 SHA-256 为 305e7df43ff9d6506a755fd8d5e0adb6926e5ee89eb719d8baa015c231a4060b,位于主 TEMP/release-linux-unit-buildmode-2026-09-08T14-34-37-205Z/linux-unittest.log。本结果补齐上文执行中的本地检查;正式候选及真实部署页面仍待验收。
+38
View File
@@ -4764,3 +4764,41 @@ git diff --check
- 证据:%TEMP%/cmpp-report-fix-20260908/{baseline.json,api-tests.log,report-tests-final.log}%TEMP%/cmpp-report-diagnosis-20260908/{profile-select.cjs.result.jsonl,compare-candidate.cjs.result.jsonl}%TEMP%/cmpp-report-pg-1788858316792/integration.log。脚本、源库结果未输出凭据或短信正文。
- 未执行:预生产完整任务写入验收/自然定时观察、两环境部署、历史缺口补齐、完整应用登录与浏览器。本轮无前端改动;本地 HTTP 是独立真实报表 Controller/Service/PG,不代表完整认证/UI 验收。旧 api/tools/verify-report-recalculation.ts 含过期金额断言和业务造数,不执行也不在本轮改写。T-5 更早缺口仍须部署修复后单独授权补齐。
- 交付:本地报表代码、测试工具、需求/设计/用例和本节一起按精确 7 文件/文档增量提交,提交号以 Git 为准;未推送、未测试部署、未预生产部署、未短信发送/补发/重投/入队,未修改业务余额、通道、客户配置或管理员。提交前保护校验确认全部已有跟踪修改未被覆盖。
## 2026-09-08 夜:浏览器异常、顶栏连接失败与公共分页(进行中)
- 本轮授权:修改、本地提交、发布测试环境;预生产只读诊断。开始分支mainHEAD/origin均ebb185b22b4457a3b5ed1c9bcc6c45992a3e7f21,分叉0/0staged为空。19个既有tracked脏文件及release工具/诊断等untracked逐文件备份并保护,不能纳入本轮提交。两环境当前部署标记均ebb185b。
- 已完成只读归因:公共Select可见label造成“每页数量”换行;DevTools异常与官方#792的函数名及偏移一致,用户反馈另一台Chrome152.0.7977.77 F12 Console;后续脚本捕获见下文,不把业务代码改动当成浏览器内置脚本修复。
- 网络证据:2026-09-08 21:0521:13源站API/Nginx稳定,API NRestarts0、无新OOM36次公网H1/H2匿名六接口请求均401,无关闭、GOAWAY或5xx;Nginx没有限流/上游超时证据。历史另一台电脑ERR_CONNECTION_CLOSED未复现,实际关闭层尚未确定。确定应用存在整批刷新叠加和失败清零缺陷,按operations-fixes-20260908.md本轮设计做最小修复。
- 本轮证据目录:本机TEMP/cmpp-starttime-pagination-20260908(基线保护/本轮证据),TEMP/cmpp-notification-network-20260908(只读链路采样及findings.md)。不写入认证状态或客户记录。后续追加测试、提交和标准测试发布结果。
- 深入浏览器证据:临时Chrome152.0.7977.76捕获DevTools Performance Metrics匿名脚本,首行softnav=true,第2行19429列确为`t.entries[0].startTime`SHA256 0f2eb3b63431416befd0d826255fb1736117e0ddbca120c5c3e54aca03a1810d。官方修复6a47f933(2026-08-31/Chromium543499029)明确修正空entries;已在隔离浏览器F1标准设置取消软导航并刷新,脚本前缀变false,A/B均无pageerror。仅证明来源/直接缺陷及开关控制,未在另一台电脑复现;不改用户浏览器配置。证据TEMP/cmpp-devtools-headless-20260908/ab-valid。
- 用户本机侧边浏览器已登录预生产,已读到真实运营看板与通知菜单,当前dev日志error/warn为0。进一步跨路由CUA调用发生工具超时及kernel reset,不当作应用错误或通过。预生产本轮未发布。
- 21:32追加排查安全误封:20:00后仅1次管理员登录失败,无新增安全告警/封禁;当前nft IPv4/IPv6封禁集合为空、Nginx deny记录0,安全代理及Fail2ban active且NRestarts0。Nginx信任22个网络并使用CF-Connecting-IP/real_ip_recursive,两观测Cloudflare地址均在信任范围,API只信任loopback;没有错误来源识别或安全误封依据,不调整安全策略。
- 本地实现完成:分页2文件、通知布局/调度/API透传共8文件,所有业务调用仍为原六GET。通知定向18项通过;分页与关联页面18项通过。全量前端25文件132项通过(21:36:08,45.74秒)。首轮类型检查抓到分页测试误用了Playwright的exact参数,已删除两处不属于Testing Library的参数,字符串名称仍默认精确匹配,分页4项重跑通过;随后完整lint(含TypeScript/结构/CSS)、format:check、生产build、bundle:verify、security:verify、deploy:verify均通过,git diff --check通过。未修改后端、数据库、Gateway或CSS,无本轮API/Go测试需求;真实页面待测试发布后验收。
- 独立代码复审无阻断:覆盖单批/退避/超时部分成功、隐藏离线/锁定/卸载/身份失效和StrictMode。首次未知保留现有数字占位,但明确显示加载/未取得结果;六类失败均保留既有真实计数,不伪装正常零。实际signal→fetch测试使用测试隔离,单独标记,不当作业务验收。质量证据quality-results.json及各-final.log保存于本轮TEMP目录。
## 2026-09-08 22:03 标准 prepare 前端候选依赖修复与授权更新
- 现场阻断:测试发布 20260908T134432-6816f5617819-b92bbb04 的前端候选只执行根 npm cisecurity:verify 通过 createRequire(api/package.json) 校验 API brace-expansion 适配器时缺少本地依赖而向上解析错误版本。主线程先用标准 status 取得 phase=prepare、backup=null;没有备份、停服或切换,原失败候选保留。
- 最小修复:tools/release/remote.py 仅将候选 API npm ci 移入 frontend 或 api 条件;显式 Prisma 生成、API 构建和线上 API 产物/依赖切换仍只在 api 影响范围内执行。没有修改 security:verify、跳过门禁、手工修补线上依赖或替换标准编排。tools/release/test_release.py 仅追加 Preparation 的 4 项回归,两工具文件保留原未跟踪状态,未暂存、未提交整套既有第一期工具。
- 先失败后修复:新增测试在旧实现上复现前端候选缺 API 依赖及安装失败场景断言失败;修复后 Windows 标准 self-test 共 35 项,33 通过、2 项分别因符号链接权限与 Linux flock 限制跳过。测试以真实临时 tar、文件摘要和目录切换核验前端部署不改变线上 API 目录,不调用服务命令;受控命令替身只用于隔离失败场景。
- Linux 隔离验证于 22:00:38 返回 exitCode=035/35 通过,临时夹具残留 0Linux 日志 SHA-256 已回读核验为 77106f006cf22ea33c3b744c9c7cbf43d3885b32f4c6d71d8d00b49b1578e900。本地 dependency mitigation、security deployment、deploy verifier 通过;两 Python 文件解析和尾随空白检查通过。未修改 JS/Shell,因此未把未执行的对应语法或格式检查记为通过。
- 证据:%TEMP%/cmpp-starttime-pagination-20260908/release-prepare-before.log、release-prepare-after.log、release-prepare-fix.patch、release-linux-unit/result.json、release-linux-unit/linux-unittest.log。用例 REL-PREP0908-01~04 已追加,候选安装与线上切换边界同步部署/工具设计文档。工具摘要变化使旧计划失效,后续使用新计划、匹配精确提交的已执行验证证据和 fresh preflight;发布与业务验收另记,不以隔离测试冒充上线。
- 最新授权:用户已明确允许测试环境验收通过后推送本轮提交并部署预生产,取代本轮早先仅本地提交与测试部署的后续执行限制;此前文字继续保留为历史事实。记录时 6816f5617819546850b1188508eda37866f4ca3c 仅本地提交、未推送,预生产尚未执行本轮发布。授权不包含其他既有脏文件、短信发送/补发/重投/重新入队、余额/客户/通道配置或恢复管理员。
## 2026-09-08 22:35 测试前端开发运行时实证与构建模式修复
- 新证据纠正此前推断:只读下载测试实际 /assets/index-TLGANFcU.jsSHA-256 a8db74bfdb24ef0892ca77fee891297d492d07b979c8653d5fd7c86c186e080c,包含 19 处 react_stack_bottom_frame 和 2 处 jsx-dev-runtime。remote.prepare 统一 NODE_ENV=test;本地对应 Vite 以 NODE_ENV===production 决定 isProductionReact 插件沿用该值,确认发布过程实际产出了开发版 React,不能用“执行过 build”替代资源模式核验。
- 第三轮 browser/run-2026-09-08T14-27-36-420Z/fetch-diagnostics.ndjson 中 18 次 fetch-signal-abort 均可见在线,耗时约 3.114ms、reason=AbortError,全部经过 JS 225:176295 的通知 effect cleanup。146:26427 对应 commitPassiveUnmountEffectsInsideOfDeletedTree_begin146:25089 对应递归 passive unmount 遍历;不是 15 秒 TimeoutError,不是已逐项证实的 StrictMode 双 effect。确认存在开发包与布局卸载取消链,不宣称它解释另一台电脑历史 ERR_CONNECTION_CLOSED;正式包行为须重测。
- 工具最小修复:仅前端 npm run build 使用复制的 NODE_ENV=production 命令环境,其他安装/门禁/API 生成构建保留 test,父进程不污染;原 API 安全依赖补丁保留。主线程新增 vite.config.ts 的 cmpp-production-build-guardapply: build、configResolved 检查 isProduction,非 production 明确失败,不应用 serve/Vitest。没有修改通知业务计数口径、全局错误过滤或后端端口。
- 已执行:环境回归在旧实现明确失败(test != production),修复后 Windows 标准 self-test 37 项中 35 通过、2 项因符号链接权限与 Linux flock 限制跳过;包含新环境隔离与前端构建失败阻断回归。真实 NODE_ENV=test、development 两个 Vite 负例均由新守卫拒绝。默认构建、lint 当前正在执行,Linux 新 37 项结果待其他执行任务返回;本节不提前记录通过,也不复用前一版 Linux 35/35 充当本版结果。
- 证据:主 TEMP/cmpp-starttime-pagination-20260908 下 index-TLGANFcU.live.js、live-react-mode-evidence.json、release-build-mode-fix.patch、release-build-mode-before.log/after.log、build-mode-test.log、build-mode-development.log,以及第三轮 fetch-diagnostics.ndjson。两 Python 文件语法与尾随空白检查已通过。工具仍未跟踪、未暂存;Vite 配置及最终版本/测试/标准发布结果由主线程后续精确提交与追加。
- 待验收:新候选生产运行时、真实分页/三尺寸/刷新/切页/六通知请求与故障恢复,及授权顺序内的测试验收后推送、预生产独立发布。新工具摘要和配置改变后使用匹配精确提交的新计划及 fresh preflight,不把旧包只读观测或本地负例视为正式包业务完成。
最终检查结果补记(2026-09-08 22:36):默认 npm run build 成功,npm run lint 退出码 0;两个真实 test/development 构建负例准确拒绝。Linux 新版工具 37/37 通过、0 失败/0 跳过,耗时 0.192s,日志 SHA-256 为 305e7df43ff9d6506a755fd8d5e0adb6926e5ee89eb719d8baa015c231a4060b,位于主 TEMP/release-linux-unit-buildmode-2026-09-08T14-34-37-205Z/linux-unittest.log。本结果补齐上文执行中的本地检查;正式候选及真实部署页面仍待验收。
+5
View File
@@ -137,3 +137,8 @@
- 弹窗:使用 `Modal`
新增页面或改版时,先检查是否可以复用以上组件;只有现有组件无法表达业务交互时,才新增全局组件。
### 公共分页容量展示补充(2026-09-08 夜)
公共Pagination中的容量Select只显示“10/25/50/100 条/页”选中项,不显示“每页数量”标题;与翻页控件放在同一操作区。复用sr-only隐藏标签并以唯一id关联,保持键盘和读屏可访问;不要删除其他表单Select的可见标签。签名质量四Tab和通道报备明细沿用该组件,默认25和独立筛选状态保持。顶栏通知接口失败时须说明计数暂不可用,既有真实值只能作为上次数据保留,不能归零伪装无通知。
+22 -5
View File
@@ -1,11 +1,28 @@
import { request, withQuery } from '../core/httpClient';
import type { InfrastructureAlertSettings, InfrastructureAlertThresholds, InfrastructureMonitoringOverview, InfrastructureMonitoringRange } from '../types';
import type {
InfrastructureAlertSettings,
InfrastructureAlertThresholds,
InfrastructureMonitoringOverview,
InfrastructureMonitoringRange,
} from '../types';
export const adminInfrastructureMonitoringApi = {
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
getInfrastructureMonitoringNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary'),
getInfrastructureAlertThresholds: () => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds', { method: 'PUT', body: JSON.stringify(body) }),
markInfrastructureAlertRead: (fingerprint: string, activeAt: string) => request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`, { method: 'POST', body: JSON.stringify({ activeAt }) }),
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary', {
signal,
}),
getInfrastructureAlertThresholds: () =>
request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) =>
request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds', {
method: 'PUT',
body: JSON.stringify(body),
}),
markInfrastructureAlertRead: (fingerprint: string, activeAt: string) =>
request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(
`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`,
{ method: 'POST', body: JSON.stringify({ activeAt }) },
),
};
+2 -2
View File
@@ -39,8 +39,8 @@ import type {
export const adminOperationsApi = {
getDashboard: (tenantId?: string) =>
request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
getPendingAudits: (tenantId?: string) =>
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
getPendingAudits: (tenantId?: string, signal?: AbortSignal) =>
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }),
getSendQuality: (date?: string) =>
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
+28 -8
View File
@@ -2,15 +2,35 @@ import { request, withQuery } from '../core/httpClient';
import type { SecurityAlert, SecurityBlock, SecurityOverview, SecurityProtectedNetwork, SecurityRule } from '../types';
export const adminSecurityDetectionApi = {
getSecurityOverview: (range = '24h') => request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
getSecurityNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary'),
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)),
getSecurityOverview: (range = '24h') =>
request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
getSecurityNotificationSummary: (signal?: AbortSignal) =>
request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary', { signal }),
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) =>
request<{ items: SecurityAlert[]; total: number }>(withQuery('/admin/security-detection/alerts', query)),
listSecurityRules: () => request<SecurityRule[]>('/admin/security-detection/rules'),
updateSecurityRule: (id: string, body: Partial<SecurityRule>) => request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) => request<SecurityBlock>(`/admin/security-detection/alerts/${id}/block`, { method: 'POST', body: JSON.stringify(body) }),
ignoreSecurityAlert: (id: string, reason: string) => request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, { method: 'POST', body: JSON.stringify({ reason }) }),
updateSecurityRule: (id: string, body: Partial<SecurityRule>) =>
request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) =>
request<SecurityBlock>(`/admin/security-detection/alerts/${id}/block`, {
method: 'POST',
body: JSON.stringify(body),
}),
ignoreSecurityAlert: (id: string, reason: string) =>
request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listSecurityBlocks: () => request<SecurityBlock[]>('/admin/security-detection/blocks'),
unblockSecurityBlock: (id: string, reason: string) => request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, { method: 'POST', body: JSON.stringify({ reason }) }),
unblockSecurityBlock: (id: string, reason: string) =>
request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listProtectedNetworks: () => request<SecurityProtectedNetwork[]>('/admin/security-detection/protected-networks'),
addProtectedNetwork: (body: { network: string; name: string; reason: string }) => request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', { method: 'POST', body: JSON.stringify(body) }),
addProtectedNetwork: (body: { network: string; name: string; reason: string }) =>
request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', {
method: 'POST',
body: JSON.stringify(body),
}),
};
+67 -18
View File
@@ -12,26 +12,75 @@ import type {
} from '../types';
export const adminSignatureRetirementApi = {
getSignatureRetirementConfiguration: () => request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>('/admin/signature-retirement/configuration'),
getSignatureRetirementConfiguration: () =>
request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>(
'/admin/signature-retirement/configuration',
),
saveSignatureRetirementRule: (body: {
ruleType: SignatureRetirementRuleType; targetId?: string; enabled: boolean;
mobileWindowDays: number; mobileThreshold: number; unicomWindowDays: number; unicomThreshold: number;
telecomWindowDays: number; telecomThreshold: number; messageTemplate?: string;
}) => request<SignatureRetirementRule>('/admin/signature-retirement/rules', { method: 'PUT', body: JSON.stringify(body) }),
ruleType: SignatureRetirementRuleType;
targetId?: string;
enabled: boolean;
mobileWindowDays: number;
mobileThreshold: number;
unicomWindowDays: number;
unicomThreshold: number;
telecomWindowDays: number;
telecomThreshold: number;
messageTemplate?: string;
}) =>
request<SignatureRetirementRule>('/admin/signature-retirement/rules', {
method: 'PUT',
body: JSON.stringify(body),
}),
createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) =>
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }),
deleteSignatureRetirementWebhook: (id: string) => request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
listSignatureRetirementMessages: (query: { dateFrom?: string; dateTo?: string; dimensionType?: string; tenantId?: string; applicationId?: string; signatureKeyword?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'),
readSignatureRetirementMessage: (id: string) => request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
readAllSignatureRetirementMessagesToday: () => request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
suppressSignatureRetirementMessage: (id: string, body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, { method: 'POST', body: JSON.stringify(body) }),
listSignatureRetirementSuppressions: () => request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', {
method: 'POST',
body: JSON.stringify(body),
}),
deleteSignatureRetirementWebhook: (id: string) =>
request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
listSignatureRetirementMessages: (
query: {
dateFrom?: string;
dateTo?: string;
dimensionType?: string;
tenantId?: string;
applicationId?: string;
signatureKeyword?: string;
channelId?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
getSignatureRetirementUnreadCount: (signal?: AbortSignal) =>
request<{ count: number }>('/admin/signature-retirement/unread-count', { signal }),
readSignatureRetirementMessage: (id: string) =>
request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
readAllSignatureRetirementMessagesToday: () =>
request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
suppressSignatureRetirementMessage: (
id: string,
body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string },
) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, {
method: 'POST',
body: JSON.stringify(body),
}),
listSignatureRetirementSuppressions: () =>
request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
cancelSignatureRetirementSuppression: (id: string, reason: string) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) }),
getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })),
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
getSignatureRetirementHeatmap: (date?: string) =>
request<{
date: string;
dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[];
}>(withQuery('/admin/signature-retirement/heatmap', { date })),
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<UnreportedSignatureItem> & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)),
request<PagedResult<UnreportedSignatureItem> & { date: string }>(
withQuery('/admin/signature-retirement/unreported-signatures', query),
),
};
+36 -1
View File
@@ -34,12 +34,47 @@ describe('Pagination compatibility', () => {
it('derives total pages from the opted-in size when totalPages is omitted', () => {
const changePage = vi.fn();
render(<Pagination total={60} pageSize={25} onPageSizeChange={vi.fn()} onPageChange={changePage} />);
const changePageSize = vi.fn();
render(<Pagination total={60} pageSize={25} onPageSizeChange={changePageSize} onPageChange={changePage} />);
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('25 条/页');
expect(screen.getByLabelText(/^每页数量/)).toHaveAccessibleName(/^每页数量/);
expect(screen.getByText('每页数量')).toHaveClass('sr-only');
expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
fireEvent.click(screen.getByRole('button', { name: '末页' }));
expect(changePage).toHaveBeenLastCalledWith(3);
for (const size of [10, 25, 50, 100]) {
fireEvent.click(screen.getByLabelText(/^每页数量/));
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
'10 条/页',
'25 条/页',
'50 条/页',
'100 条/页',
]);
fireEvent.click(screen.getByRole('option', { name: `${size} 条/页` }));
expect(changePageSize).toHaveBeenLastCalledWith(size);
}
});
it('keeps hidden page-size labels uniquely associated across multiple paginations', () => {
const firstChange = vi.fn();
const secondChange = vi.fn();
render(
<>
<Pagination pageSize={25} onPageSizeChange={firstChange} />
<Pagination pageSize={50} onPageSizeChange={secondChange} />
</>,
);
const controls = screen.getAllByLabelText(/^每页数量/);
expect(controls).toHaveLength(2);
expect(controls[0]).toHaveAccessibleName(/^每页数量/);
expect(controls[1]).toHaveAccessibleName(/^每页数量/);
expect(controls[0].id).not.toBe(controls[1].id);
fireEvent.click(controls[1]);
fireEvent.click(screen.getByRole('option', { name: '100 条/页' }));
expect(secondChange).toHaveBeenCalledWith(100);
expect(firstChange).not.toHaveBeenCalled();
});
it('resets only the jump draft when the page changes and does not restore an older draft', () => {
+8 -2
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react';
import { useId, useState, type ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
@@ -80,6 +80,7 @@ export function Pagination({
pageSizeOptions = [10, 25, 50, 100],
onPageSizeChange,
}: PaginationProps) {
const pageSizeId = useId();
const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / (pageSize ?? 10)) : page));
function changePage(nextPage: number) {
@@ -91,12 +92,17 @@ export function Pagination({
{typeof total === 'number' ? <span> {total} </span> : <span />}
<div>
{typeof pageSize === 'number' && onPageSizeChange ? (
<>
<label className="sr-only" htmlFor={pageSizeId}>
</label>
<Select
label="每页数量"
id={pageSizeId}
value={String(pageSize)}
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
/>
</>
) : null}
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">
+175 -4
View File
@@ -1,7 +1,8 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { beforeEach, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { readSession, writeSession } from '@/api/session';
import { AdminLayout } from './AdminLayout';
import { AppShell } from './AppShell';
@@ -11,9 +12,11 @@ const api = vi.hoisted(() => ({
getSecurityNotificationSummary: vi.fn(),
getInfrastructureMonitoringNotificationSummary: vi.fn(),
current: vi.fn(),
request: vi.fn(),
}));
vi.mock('@/api/adminApi', () => ({ adminApi: api, portalSessionApi: { current: api.current } }));
vi.mock('@/api/core/httpClient', () => ({ request: api.request }));
beforeEach(() => {
vi.clearAllMocks();
@@ -37,15 +40,18 @@ beforeEach(() => {
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 7 });
api.getSecurityNotificationSummary.mockResolvedValue({ count: 2, criticalCount: 1 });
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 5, criticalCount: 0 });
api.request.mockResolvedValue({ count: 0 });
});
afterEach(() => vi.useRealTimers());
function LocationProbe() {
const location = useLocation();
return <p data-testid="route">{location.pathname + location.search}</p>;
}
async function renderAdmin() {
render(
function renderAdminShell() {
return render(
<MemoryRouter initialEntries={['/admin']}>
<Routes>
<Route element={<AdminLayout />}>
@@ -54,6 +60,10 @@ async function renderAdmin() {
</Routes>
</MemoryRouter>,
);
}
async function renderAdmin() {
renderAdminShell();
await waitFor(() => expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('7'), {
timeout: 5000,
});
@@ -156,3 +166,164 @@ it('does not add notification controls to a client shell without notification it
expect(screen.queryByRole('button', { name: '报备任务提醒' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '预警通知' })).not.toBeInTheDocument();
});
const advancePolling = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
function openNotificationMenu(button: string) {
fireEvent.click(screen.getByRole('button', { name: button }));
}
it('retains all six known counts on failure and clears unavailable descriptions after a successful retry', async () => {
vi.useFakeTimers();
api.request.mockImplementation((path: string) =>
Promise.resolve({ count: path.includes('sending-monitor') ? 13 : 11 }),
);
const { unmount } = renderAdminShell();
await act(() => Promise.resolve());
await advancePolling(0);
expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('18');
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('20');
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
for (const endpoint of [
api.getPendingAudits,
api.getSignatureRetirementUnreadCount,
api.getSecurityNotificationSummary,
api.getInfrastructureMonitoringNotificationSummary,
api.request,
]) {
endpoint.mockRejectedValue(new Error('network unavailable'));
}
await act(async () => fireEvent(window, new Event('cmpp-retirement-count-refresh')));
openNotificationMenu('报备任务提醒');
const reporting = screen.getByRole('menu', { name: '报备任务提醒' });
expect(within(reporting).getAllByText('计数暂不可用,显示上次结果')).toHaveLength(2);
expect(
within(reporting)
.getByRole('menuitem', { name: /签名清退预警/ })
.querySelector('strong'),
).toHaveTextContent('7');
expect(
within(reporting)
.getByRole('menuitem', { name: /报备状态变化通知/ })
.querySelector('strong'),
).toHaveTextContent('11');
openNotificationMenu('预警通知');
const alerts = screen.getByRole('menu', { name: '预警中心' });
expect(within(alerts).getAllByText('计数暂不可用,显示上次结果')).toHaveLength(3);
for (const [label, count] of [
['发送质量告警', '13'],
['安全检测与封禁', '2'],
['系统监控告警', '5'],
]) {
expect(
within(alerts)
.getByRole('menuitem', { name: new RegExp(label) })
.querySelector('strong'),
).toHaveTextContent(count);
}
openNotificationMenu('通知');
expect(screen.getAllByRole('menuitem', { name: /计数暂不可用/ })).toHaveLength(6);
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
expect(screen.getByRole('menuitem', { name: /签名导入待审/ })).toHaveAttribute(
'href',
'/admin/signatures?tab=import',
);
api.getPendingAudits.mockResolvedValue({
enterpriseCertifications: 0,
smsAudits: 0,
templates: 0,
signatures: 0,
signatureImports: 2,
drainageInfos: 0,
total: 2,
});
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 3 });
api.getSecurityNotificationSummary.mockResolvedValue({ count: 1, criticalCount: 0 });
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 0, criticalCount: 0 });
api.request.mockResolvedValue({ count: 0 });
await advancePolling(30000);
expect(screen.queryByRole('menuitem', { name: /计数暂不可用/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('2');
expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('3');
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('1');
openNotificationMenu('报备任务提醒');
expect(screen.getByText('今日未读且未抑制')).toBeVisible();
expect(screen.queryByText(/计数暂不可用/)).not.toBeInTheDocument();
unmount();
});
it('marks unknown counts as loading then unavailable, while retaining successful domains at timeout', async () => {
vi.useFakeTimers();
let resolveStalled!: (value: { count: number }) => void;
api.getSignatureRetirementUnreadCount.mockImplementation(
() =>
new Promise<{ count: number }>((resolve) => {
resolveStalled = resolve;
}),
);
const { unmount } = renderAdminShell();
await act(() => Promise.resolve());
await advancePolling(0);
openNotificationMenu('报备任务提醒');
expect(screen.getAllByText('计数加载中')).toHaveLength(2);
openNotificationMenu('通知');
expect(screen.getAllByRole('menuitem', { name: /计数加载中/ })).toHaveLength(6);
const signal = api.getSignatureRetirementUnreadCount.mock.calls[0][0] as AbortSignal;
expect(api.getPendingAudits.mock.calls[0]).toEqual([undefined, signal]);
expect(api.getSecurityNotificationSummary.mock.calls[0]).toEqual([signal]);
expect(api.getInfrastructureMonitoringNotificationSummary.mock.calls[0]).toEqual([signal]);
expect(api.request.mock.calls.slice(0, 2).every(([, options]) => options.signal === signal)).toBe(true);
await advancePolling(15000);
expect(signal.aborted).toBe(true);
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('7');
openNotificationMenu('报备任务提醒');
const retirement = screen.getByRole('menuitem', { name: /签名清退预警/ });
expect(retirement).toHaveTextContent('计数暂不可用,尚未取得结果');
expect(screen.getByText('按企业与小时汇总的未读消息')).toBeVisible();
await act(async () => resolveStalled({ count: 999 }));
expect(retirement.querySelector('strong')).toHaveTextContent('0');
expect(screen.getByRole('button', { name: '报备任务提醒' })).not.toHaveTextContent('999');
unmount();
});
it('keeps the last monitor count when its successful HTTP response says unavailable', async () => {
vi.useFakeTimers();
api.request.mockResolvedValue({ count: 8 });
const { unmount } = renderAdminShell();
await act(() => Promise.resolve());
await advancePolling(0);
api.request.mockResolvedValue({ count: 0, unavailable: true });
await act(async () => fireEvent(window, new Event('cmpp-monitor-alert-refresh')));
openNotificationMenu('预警通知');
const monitor = screen.getByRole('menuitem', { name: /发送质量告警/ });
expect(monitor.querySelector('strong')).toHaveTextContent('8');
expect(monitor).toHaveTextContent('计数暂不可用,显示上次结果');
expect(screen.getByText('1 条严重告警待处置')).toBeVisible();
unmount();
});
it('does not apply or repeat an old account request after the local session identity changes', async () => {
vi.useFakeTimers();
let resolveStalled!: (value: { count: number }) => void;
api.getSignatureRetirementUnreadCount.mockImplementation(
() =>
new Promise<{ count: number }>((resolve) => {
resolveStalled = resolve;
}),
);
const { unmount } = renderAdminShell();
await act(() => Promise.resolve());
await advancePolling(0);
const session = readSession('admin')!;
writeSession({ ...session, user: { ...session.user, id: 'another-admin' } });
await act(async () => resolveStalled({ count: 999 }));
expect(screen.getByRole('button', { name: '报备任务提醒' })).not.toHaveTextContent('999');
fireEvent(window, new Event('focus'));
fireEvent(window, new Event('cmpp-audit-count-refresh'));
await advancePolling(60000);
expect(api.getPendingAudits).toHaveBeenCalledTimes(1);
unmount();
});
+108 -96
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import {
Activity,
AlertTriangle,
@@ -35,6 +35,7 @@ import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/ses
import { AppShell } from '@/layouts/AppShell';
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
import { request } from '@/api/core/httpClient';
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
enterpriseCertifications: 0,
@@ -44,92 +45,95 @@ const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
drainageInfos: 0,
};
type NotificationValue<T> = { value: T; status: 'loading' | 'ready' | 'unavailable'; hasValue: boolean };
function initialNotification<T>(value: T): NotificationValue<T> {
return { value, status: 'loading', hasValue: false };
}
function updateNotification<T>(
previous: NotificationValue<T>,
result: PromiseSettledResult<T>,
available = true,
): NotificationValue<T> {
return result.status === 'fulfilled' && available
? { value: result.value, status: 'ready', hasValue: true }
: { ...previous, status: 'unavailable' };
}
function notificationDescription<T>(notification: NotificationValue<T>, description: string) {
if (notification.status === 'loading') return '计数加载中';
if (notification.status === 'unavailable') {
return notification.hasValue ? '计数暂不可用,显示上次结果' : '计数暂不可用,尚未取得结果';
}
return description;
}
function canPollNotifications(userId: string) {
const session = readSession('admin');
return Boolean(
session &&
session.user.id === userId &&
!session.locked &&
Date.now() - getLastUserActivityAt() < session.idleTimeoutSeconds * 1000,
);
}
export function AdminLayout() {
return (
<PortalSessionBoundary portal="admin">
{(session) => <AdminAuthenticatedLayout session={session} />}
{(session) => <AdminAuthenticatedLayout key={session.user.id} session={session} />}
</PortalSessionBoundary>
);
}
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
const [reportSummary, setReportSummary] = useState({ count: 0, unavailable: false });
const [monitorSummary, setMonitorSummary] = useState({ count: 0, unavailable: false });
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
const [pendingAudits, setPendingAudits] = useState(() => initialNotification(EMPTY_PENDING_AUDITS));
const [retirementSummary, setRetirementSummary] = useState(() => initialNotification({ count: 0 }));
const [reportSummary, setReportSummary] = useState(() => initialNotification({ count: 0 }));
const [monitorSummary, setMonitorSummary] = useState(() => initialNotification({ count: 0 }));
const [securityAlertSummary, setSecurityAlertSummary] = useState(() =>
initialNotification({ count: 0, criticalCount: 0 }),
);
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState(() =>
initialNotification({ count: 0, criticalCount: 0 }),
);
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
const loadPendingAuditCount = useCallback(() => {
const currentSession = readSession('admin');
if (
!currentSession ||
currentSession.locked ||
Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000
) {
return;
}
const canPoll = useCallback(() => canPollNotifications(session.user.id), [session.user.id]);
const loadPendingAuditCount = useCallback(async (signal: AbortSignal, isCurrent: () => boolean) => {
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
Promise.allSettled([
adminApi.getPendingAudits(),
adminApi.getSignatureRetirementUnreadCount(),
adminApi.getSecurityNotificationSummary(),
adminApi.getInfrastructureMonitoringNotificationSummary(),
request<{ count: number }>('/admin/report-notifications/summary'),
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary'),
])
.then(([audits, retirement, security, infrastructure, reporting, monitor]) => {
setMonitorSummary((previous) =>
monitor.status === 'fulfilled'
? { count: monitor.value.count, unavailable: Boolean(monitor.value.unavailable) }
: { ...previous, unavailable: true },
);
setReportSummary((previous) =>
reporting.status === 'fulfilled'
? { count: reporting.value.count, unavailable: false }
: { ...previous, unavailable: true },
);
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
setInfrastructureAlertSummary(
infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 },
);
})
.catch(() => {
setPendingAudits(EMPTY_PENDING_AUDITS);
setRetirementUnreadCount(0);
setSecurityAlertSummary({ count: 0, criticalCount: 0 });
setInfrastructureAlertSummary({ count: 0, criticalCount: 0 });
});
const results = await Promise.allSettled([
settleNotificationRequest(adminApi.getPendingAudits(undefined, signal), signal),
settleNotificationRequest(adminApi.getSignatureRetirementUnreadCount(signal), signal),
settleNotificationRequest(adminApi.getSecurityNotificationSummary(signal), signal),
settleNotificationRequest(adminApi.getInfrastructureMonitoringNotificationSummary(signal), signal),
settleNotificationRequest(request<{ count: number }>('/admin/report-notifications/summary', { signal }), signal),
settleNotificationRequest(
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary', { signal }),
signal,
),
]);
if (!isCurrent()) return false;
const [audits, retirement, security, infrastructure, reporting, monitor] = results;
const monitorAvailable = monitor.status === 'fulfilled' && !monitor.value.unavailable;
setPendingAudits((previous) => updateNotification(previous, audits));
setRetirementSummary((previous) => updateNotification(previous, retirement));
setSecurityAlertSummary((previous) => updateNotification(previous, security));
setInfrastructureAlertSummary((previous) => updateNotification(previous, infrastructure));
setReportSummary((previous) => updateNotification(previous, reporting));
setMonitorSummary((previous) => updateNotification(previous, monitor, monitorAvailable));
return results.every((result) => result.status === 'fulfilled') && monitorAvailable;
}, []);
useEffect(() => {
if (session.portal !== 'admin' || sessionLocked) {
return;
}
loadPendingAuditCount();
const timer = window.setInterval(loadPendingAuditCount, 30000);
const onFocus = () => loadPendingAuditCount();
const onAuditRefresh = () => loadPendingAuditCount();
window.addEventListener('focus', onFocus);
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-report-notification-refresh', onAuditRefresh);
window.addEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
window.addEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
return () => {
window.clearInterval(timer);
window.removeEventListener('focus', onFocus);
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
window.removeEventListener('cmpp-report-notification-refresh', onAuditRefresh);
window.removeEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
window.removeEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
window.removeEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
};
}, [loadPendingAuditCount, session.portal, sessionLocked]);
useNotificationPolling({
enabled: session.portal === 'admin' && !sessionLocked,
canPoll,
poll: loadPendingAuditCount,
});
const auditLabel = (label: string) =>
pendingAudits.status === 'ready'
? label
: `${label}${pendingAudits.status === 'loading' ? '计数加载中' : '计数暂不可用'}`;
return (
<AppShell
@@ -144,14 +148,14 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
reportingNotifications={[
{
label: '报备状态变化通知',
count: reportSummary.count,
description: reportSummary.unavailable ? '消息计数暂不可用,请重试' : '按企业与小时汇总的未读消息',
count: reportSummary.value.count,
description: notificationDescription(reportSummary, '按企业与小时汇总的未读消息'),
to: '/admin/report-records?tab=readiness',
},
{
label: '签名清退预警',
count: retirementUnreadCount,
description: '今日未读且未抑制',
count: retirementSummary.value.count,
description: notificationDescription(retirementSummary, '今日未读且未抑制'),
to: '/admin/signature-retirement',
},
{
@@ -163,40 +167,48 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
alertNotifications={[
{
label: '发送质量告警',
count: monitorSummary.count,
description: monitorSummary.unavailable ? '告警计数暂不可用,请重试' : '未读活动发送质量告警',
count: monitorSummary.value.count,
description: notificationDescription(monitorSummary, '未读活动发送质量告警'),
to: '/admin/monitor?tab=alerts',
},
{
label: '安全检测与封禁',
count: securityAlertSummary.count,
description:
securityAlertSummary.criticalCount > 0
? `${securityAlertSummary.criticalCount} 条严重告警待处置`
count: securityAlertSummary.value.count,
description: notificationDescription(
securityAlertSummary,
securityAlertSummary.value.criticalCount > 0
? `${securityAlertSummary.value.criticalCount} 条严重告警待处置`
: '待处置安全告警',
),
to: '/admin/security-detection',
},
{
label: '系统监控告警',
count: infrastructureAlertSummary.count,
description:
infrastructureAlertSummary.criticalCount > 0
? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警`
count: infrastructureAlertSummary.value.count,
description: notificationDescription(
infrastructureAlertSummary,
infrastructureAlertSummary.value.criticalCount > 0
? `${infrastructureAlertSummary.value.criticalCount} 条 Prometheus 严重告警`
: 'Prometheus 活动告警',
),
to: '/admin/system-monitoring#active-alerts',
},
]}
auditNotifications={[
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
{ label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' },
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/signatures' },
{
label: '签名导入待审',
count: pendingAudits.signatureImports ?? 0,
label: auditLabel('企业认证待审'),
count: pendingAudits.value.enterpriseCertifications,
to: '/admin/enterprise-audit',
},
{ label: auditLabel('短信审核待审'), count: pendingAudits.value.smsAudits, to: '/admin/sms-audit' },
{ label: auditLabel('模板待审'), count: pendingAudits.value.templates, to: '/admin/templates' },
{ label: auditLabel('签名待审'), count: pendingAudits.value.signatures, to: '/admin/signatures' },
{
label: auditLabel('签名导入待审'),
count: pendingAudits.value.signatureImports ?? 0,
to: '/admin/signatures?tab=import',
},
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
{ label: auditLabel('引流信息待审'), count: pendingAudits.value.drainageInfos, to: '/admin/drainage-audits' },
]}
navSections={[
{
+245
View File
@@ -0,0 +1,245 @@
import { StrictMode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { adminOperationsApi } from '@/api/admin/operations.api';
import { adminSignatureRetirementApi } from '@/api/admin/signature-retirement.api';
import { adminSecurityDetectionApi } from '@/api/admin/security-detection.api';
import { adminInfrastructureMonitoringApi } from '@/api/admin/infrastructure-monitoring.api';
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
const canPoll = () => true;
const refresh = () => window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
const focus = () => window.dispatchEvent(new Event('focus'));
const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(document, 'hidden', 'get').mockReturnValue(false);
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('keeps a single batch active and coalesces business events into one follow-up, without focus bursts', async () => {
const first = deferred<boolean>();
const second = deferred<boolean>();
const poll = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise).mockResolvedValue(true);
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
await advance(0);
act(() => {
refresh();
refresh();
window.dispatchEvent(new Event('cmpp-security-alert-count-refresh'));
focus();
});
expect(poll).toHaveBeenCalledTimes(1);
await act(async () => first.resolve(true));
expect(poll).toHaveBeenCalledTimes(2);
act(() => {
focus();
focus();
});
await act(async () => second.resolve(true));
await advance(29999);
expect(poll).toHaveBeenCalledTimes(2);
await advance(1);
expect(poll).toHaveBeenCalledTimes(3);
unmount();
});
it('aborts stalled requests at 15 seconds and retries at 30, 60, then at most 120 seconds despite focus or refresh', async () => {
const stalled = deferred<boolean>();
const signals: AbortSignal[] = [];
const poll = vi.fn((signal: AbortSignal) => {
signals.push(signal);
return settleNotificationRequest(stalled.promise, signal);
});
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
await advance(0);
for (const [attempt, delay] of [30000, 60000, 120000, 120000].entries()) {
await advance(14999);
expect(signals[attempt].aborted).toBe(false);
await advance(1);
expect(signals[attempt].aborted).toBe(true);
expect(signals[attempt].reason.name).toBe('TimeoutError');
act(() => {
focus();
refresh();
focus();
});
await advance(delay - 1);
expect(poll).toHaveBeenCalledTimes(attempt + 1);
await advance(1);
expect(poll).toHaveBeenCalledTimes(attempt + 2);
}
unmount();
await act(async () => stalled.resolve(true));
});
it('resets backoff after recovery and allows an explicit business refresh again', async () => {
const poll = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true);
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
await advance(0);
await advance(30000);
expect(poll).toHaveBeenCalledTimes(2);
await advance(60000);
expect(poll).toHaveBeenCalledTimes(3);
await advance(30000);
expect(poll).toHaveBeenCalledTimes(4);
await act(async () => refresh());
expect(poll).toHaveBeenCalledTimes(5);
unmount();
});
it.each(['hidden', 'offline'] as const)(
'cancels while %s, ignores a late result, and resumes only one batch',
async (state) => {
const first = deferred<number>();
const values: number[] = [];
const signals: AbortSignal[] = [];
const poll = vi.fn(async (signal: AbortSignal, isCurrent: () => boolean) => {
signals.push(signal);
const value = await settleNotificationRequest(first.promise, signal);
if (isCurrent()) values.push(value);
return true;
});
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
await advance(0);
const setAvailable = (available: boolean) => {
if (state === 'hidden') {
vi.spyOn(document, 'hidden', 'get').mockReturnValue(!available);
document.dispatchEvent(new Event('visibilitychange'));
} else {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(available);
window.dispatchEvent(new Event(available ? 'online' : 'offline'));
}
};
await act(async () => setAvailable(false));
expect(signals[0].aborted).toBe(true);
await act(async () => first.resolve(9));
await advance(120000);
act(() => {
focus();
refresh();
});
expect(values).toEqual([]);
expect(poll).toHaveBeenCalledTimes(1);
act(() => {
setAvailable(true);
setAvailable(true);
});
await advance(0);
expect(poll).toHaveBeenCalledTimes(2);
expect(values).toEqual([9]);
unmount();
},
);
it('preserves a failed retry deadline across hidden, offline, and locked transitions', async () => {
const poll = vi.fn().mockResolvedValue(false);
const { rerender, unmount } = renderHook(({ enabled }) => useNotificationPolling({ enabled, canPoll, poll }), {
initialProps: { enabled: true },
});
await advance(0);
await advance(30000);
expect(poll).toHaveBeenCalledTimes(2);
rerender({ enabled: false });
await advance(1000);
rerender({ enabled: true });
act(() => {
focus();
refresh();
window.dispatchEvent(new Event('online'));
document.dispatchEvent(new Event('visibilitychange'));
});
await advance(58999);
expect(poll).toHaveBeenCalledTimes(2);
await advance(1);
expect(poll).toHaveBeenCalledTimes(3);
unmount();
});
it('invalidates an old batch on lock or unmount even if its source ignores cancellation', async () => {
const requests = [deferred<number>(), deferred<number>()];
const applied: number[] = [];
const signals: AbortSignal[] = [];
const poll = vi.fn(async (signal: AbortSignal, isCurrent: () => boolean) => {
const request = requests[signals.length];
signals.push(signal);
const value = await request.promise;
if (isCurrent()) applied.push(value);
return true;
});
const { rerender, unmount } = renderHook(({ enabled }) => useNotificationPolling({ enabled, canPoll, poll }), {
initialProps: { enabled: true },
});
await advance(0);
rerender({ enabled: false });
expect(signals[0].aborted).toBe(true);
rerender({ enabled: true });
await advance(0);
expect(poll).toHaveBeenCalledTimes(2);
await act(async () => requests[0].resolve(100));
expect(applied).toEqual([]);
unmount();
expect(signals[1].aborted).toBe(true);
await act(async () => requests[1].resolve(200));
expect(applied).toEqual([]);
await advance(120000);
expect(poll).toHaveBeenCalledTimes(2);
});
it('starts only one batch under StrictMode and none when discarded before setup completes', async () => {
const poll = vi.fn().mockResolvedValue(true);
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }), {
wrapper: StrictMode,
});
expect(poll).not.toHaveBeenCalled();
await advance(0);
expect(poll).toHaveBeenCalledTimes(1);
unmount();
const discarded = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
discarded.unmount();
await advance(0);
expect(poll).toHaveBeenCalledTimes(1);
});
it('cancels all four notification API fetches and preserves the existing tenant filter argument', async () => {
const calls: { url: string; options: RequestInit | undefined }[] = [];
vi.spyOn(globalThis, 'fetch').mockImplementation((url, options) => {
calls.push({ url: String(url), options });
return new Promise<Response>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true });
});
});
const controller = new AbortController();
const { signal } = controller;
const pending = Promise.allSettled([
adminOperationsApi.getPendingAudits('tenant with spaces', signal),
adminSignatureRetirementApi.getSignatureRetirementUnreadCount(signal),
adminSecurityDetectionApi.getSecurityNotificationSummary(signal),
adminInfrastructureMonitoringApi.getInfrastructureMonitoringNotificationSummary(signal),
]);
expect(calls).toHaveLength(4);
expect(new URL(calls[0].url, 'http://localhost').searchParams.get('tenantId')).toBe('tenant with spaces');
expect(calls.every(({ options }) => options?.signal === signal && (options.method ?? 'GET') === 'GET')).toBe(true);
controller.abort();
const results = await pending;
expect(results.every((result) => result.status === 'rejected' && result.reason.name === 'AbortError')).toBe(true);
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ total: 4 }), { status: 200 }));
await expect(adminOperationsApi.getPendingAudits('existing-tenant')).resolves.toEqual({ total: 4 });
});
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef } from 'react';
const REFRESH_EVENTS = [
'cmpp-audit-count-refresh',
'cmpp-retirement-count-refresh',
'cmpp-report-notification-refresh',
'cmpp-monitor-alert-refresh',
'cmpp-security-alert-count-refresh',
'cmpp-infrastructure-alert-count-refresh',
];
const POLL_INTERVAL_MS = 30000;
const REQUEST_TIMEOUT_MS = 15000;
// Also settles an aborted request if it is waiting outside fetch (for example, for authentication).
export function settleNotificationRequest<T>(request: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener('abort', abort, { once: true });
request.then(
(value) => {
signal.removeEventListener('abort', abort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener('abort', abort);
reject(error);
},
);
if (signal.aborted) {
signal.removeEventListener('abort', abort);
abort();
}
});
}
type NotificationPollingOptions = {
enabled: boolean;
canPoll: () => boolean;
poll: (signal: AbortSignal, isCurrent: () => boolean) => Promise<boolean>;
};
export function useNotificationPolling({ enabled, canPoll, poll }: NotificationPollingOptions) {
const retry = useRef({ failures: 0, nextEligibleAt: 0 });
useEffect(() => {
if (!enabled) return;
let disposed = false;
let generation = 0;
let active: AbortController | undefined;
let timer: number | undefined;
let timeout: number | undefined;
let refreshPending = false;
const isAvailable = () => !document.hidden && navigator.onLine && canPoll();
const clearTimer = () => window.clearTimeout(timer);
const schedule = () => {
clearTimer();
if (disposed || active || !isAvailable()) return;
timer = window.setTimeout(() => start(false), Math.max(0, retry.current.nextEligibleAt - Date.now()));
};
const start = (businessRefresh: boolean) => {
if (disposed || !isAvailable()) return;
if (active) {
refreshPending ||= businessRefresh;
return;
}
if (Date.now() < retry.current.nextEligibleAt && (!businessRefresh || retry.current.failures > 0)) {
schedule();
return;
}
clearTimer();
refreshPending = false;
const controller = new AbortController();
active = controller;
const batchGeneration = generation;
const isCurrent = () => !disposed && generation === batchGeneration && isAvailable();
timeout = window.setTimeout(
() => controller.abort(new DOMException('通知计数请求超时', 'TimeoutError')),
REQUEST_TIMEOUT_MS,
);
void poll(controller.signal, isCurrent)
.catch(() => false)
.then((successful) => {
window.clearTimeout(timeout);
active = undefined;
if (disposed) return;
if (!isCurrent()) {
schedule();
return;
}
retry.current.failures = successful ? 0 : Math.min(retry.current.failures + 1, 3);
const delay = successful ? POLL_INTERVAL_MS : POLL_INTERVAL_MS * 2 ** (retry.current.failures - 1);
retry.current.nextEligibleAt = Date.now() + delay;
if (refreshPending && successful) {
start(true);
} else {
schedule();
}
});
};
const refresh = () => start(true);
const focus = () => start(false);
const availabilityChanged = () => {
if (!isAvailable()) {
clearTimer();
window.clearTimeout(timeout);
generation += 1;
refreshPending = false;
active?.abort();
} else {
schedule();
}
};
window.addEventListener('focus', focus);
window.addEventListener('online', availabilityChanged);
window.addEventListener('offline', availabilityChanged);
document.addEventListener('visibilitychange', availabilityChanged);
REFRESH_EVENTS.forEach((event) => window.addEventListener(event, refresh));
// Immediate setup/cleanup cycles can cancel this timer before network work starts.
schedule();
return () => {
disposed = true;
generation += 1;
clearTimer();
window.clearTimeout(timeout);
active?.abort();
window.removeEventListener('focus', focus);
window.removeEventListener('online', availabilityChanged);
window.removeEventListener('offline', availabilityChanged);
document.removeEventListener('visibilitychange', availabilityChanged);
REFRESH_EVENTS.forEach((event) => window.removeEventListener(event, refresh));
};
}, [enabled, canPoll, poll]);
}
+12 -1
View File
@@ -5,7 +5,18 @@ import path from 'path';
const apiProxyTarget = process.env.API_PROXY_TARGET ?? 'http://localhost:3000';
export default defineConfig({
plugins: [react()],
plugins: [
react(),
{
name: 'cmpp-production-build-guard',
apply: 'build',
configResolved(config) {
if (!config.isProduction) {
throw new Error('CMPP frontend builds require NODE_ENV=production; refusing a development React release.');
}
},
},
],
cacheDir: 'node_modules/.vite-cmpp',
resolve: {
alias: {