diff --git a/README.md b/README.md index 9ecb309..601af5e 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ PostgreSQL 18 兼容工作继续冻结。 先在宝塔面板停止并删除当前错误的 Java 项目,再执行: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.27/install.sh | sudo env KAIDI_APP_PORT=18080 bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.28/install.sh | sudo env KAIDI_APP_PORT=18080 bash ``` 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 @@ -189,7 +189,8 @@ systemctl is-enabled kaidi-update.path #### 5. 后台在线更新 -初始化脚本已启用 `kaidi-update.path`,后台“系统治理 → 系统更新”流程保持不变:点击“获取更新”下载并验签,确认后点击“立即重启”。 +初始化脚本已启用 `kaidi-update.path`。后台“系统治理 → 系统更新”按明确阶段执行:点击“获取版本”只读取版本信息, +发现新版本后点击“立即更新”才开始下载和验签;页面显示“下载完成”后,再点击“立即更新并重启”。 root 更新器原子切换 `/www/wwwroot/kaidi/current`,向当前 Java 进程发送停止信号;宝塔的意外重启守护随后按新 `current` 链接拉起 Java, 健康检查通过后页面等待 10 秒并自动刷新。更新器不会安装或启动 `kaidi-finance.service`,也不会修改 Nginx。 @@ -245,12 +246,13 @@ git push origin v1.0.0-preview.25 更新流程固定为: -1. 点击“获取更新”,后端排队 `DOWNLOAD`;更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、文件名和压缩包路径校验, - 通过后缓存到 `/var/lib/kaidi-update/cache/`,业务服务继续运行。 -2. 页面显示 `READY/等待重启` 后才出现“立即重启”;未缓存或版本不一致的包不能进入安装。 -3. 管理员点击“立即重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。 -4. 更新器安装并保护新版本目录,原子切换 `/www/wwwroot/kaidi/current`,校验旧 Java 的受控 PID 后发送停止信号;宝塔守护自动启动新版本。 -5. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线由前端轮询恢复。任一检查失败则切回上一应用版本, +1. 点击“获取版本”,只实时请求 Gitea Latest Release 版本、发布时间和发布说明,不排队下载。 +2. 发现新版本后显示“立即更新”;管理员点击后才排队 `DOWNLOAD`。更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、 + 文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/`,业务服务继续运行。 +3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。 +4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。 +5. 更新器安装并保护新版本目录,原子切换 `/www/wwwroot/kaidi/current`,校验旧 Java 的受控 PID 后发送停止信号;宝塔守护自动启动新版本。 +6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本, 保留数据库备份和事务证据供人工处理。 忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。 diff --git a/docs/财务系统开发交付总方案.md b/docs/财务系统开发交付总方案.md index 86bf672..1f79e5a 100644 --- a/docs/财务系统开发交付总方案.md +++ b/docs/财务系统开发交付总方案.md @@ -76,7 +76,7 @@ | 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 | | 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 | | PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 | -| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;系统管理员点击“获取更新”后自动检查并下载,验签缓存进入 `READY` 后显示“立即重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新或短暂断线发生在安装中时恢复轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 | +| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 | | Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release | | Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 | | 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI | @@ -695,7 +695,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、 | PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 | | PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 | | PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 | -| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、更新包获取、立即重启和历史更新记录 | +| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、显式下载、下载完成确认、安装重启和历史更新记录 | ### 8.2 PAGE-01 登录页 @@ -1002,9 +1002,9 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业 ### 8.22.1 PAGE-22 系统更新页 -系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取更新”。 +系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取版本”。 -更新固定分两阶段。管理员点击“获取更新”后,前端先检查最新版本,再自动提交 `DOWNLOAD` 请求;root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY`,期间当前业务版本继续运行。进入 `READY` 后页面显示“立即重启”,点击后只允许安装同版本缓存,并在切换前重新验证。健康检查成功后页面显示 10 秒倒计时并自动刷新;若页面在 `INSTALL_QUEUED/BACKING_UP/INSTALLING/RUNNING` 时刷新或短暂断线,重新进入后自动恢复 3 秒轮询。失败时保留明确状态并恢复已验证的上一版本。 +更新固定为“检查、下载、安装”三个服务端阶段和四个明确用户反馈。管理员点击“获取版本”时只请求最新版本、发布时间和发布说明,不提交下载;发现新版本后显示“立即更新”,再次确认后才提交 `DOWNLOAD`。root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY/下载完成`,期间当前业务版本继续运行。此时页面显示“立即更新并重启”,点击后只允许安装同版本缓存,并在切换前重新验证。页面以 3 秒轮询显示排队、下载、校验、备份、安装和重启状态;命令已经被服务端接受但 HTTP 响应丢失时也会主动重读状态。健康检查成功后显示 10 秒倒计时并自动刷新;失败时保留明确状态并恢复已验证的上一版本。 页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。公共 Gitea 默认不配置 Token;如改接私有镜像,只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。 @@ -1045,7 +1045,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业 | PAGE-19 查询报表 | `pages/reports` | `Tabs`、`Form`、`Table`、按需 `ECharts` | `/api/v1/reports/{reportCode}`、`drilldown`、`exports` | 六类报表同口径汇总、下钻、导出;不以硬编码数字或前端合计冒充结果 | | PAGE-20 审计日志 | `pages/governance/audit` | `Form`、`Table`、`Drawer`、`Tag` | `/api/v1/audit/logs`、`/api/v1/audit/exports` | 授权审计人员按请求编号还原关键动作;日志不可由业务页面修改/删除 | | PAGE-21 权限与配置 | `pages/governance/settings` | `Tabs`、`Tree`、`Table`、`Form`、`Dialog` | `/api/v1/admin/users`、`roles`、`scopes`、`templates`、`parameters` | 仅超级管理员可用;用户、角色、范围、模板和参数按版本与动作白名单管理 | -| PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取更新自动完成检查和下载;验签完成后显示立即重启;健康后 10 秒刷新;历史操作来自不可变审计日志 | +| PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取版本只检查;立即更新才下载;下载完成后显示立即更新并重启;健康后 10 秒刷新;历史操作来自不可变审计日志 | 每个页面只有同时满足以下条件才算完成: @@ -1083,7 +1083,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业 | PAGE-19 | 默认项目综合台账和当前月份/授权范围 | 筛选 → 汇总 → 点击金额下钻 → 导出 | 汇总、明细和导出同一口径;每个数字可到来源对象 | | PAGE-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 | | PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 | -| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取更新 → 自动下载验签 → 立即重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 | +| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取版本 → 立即更新并下载验签 → 下载完成 → 立即更新并重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 | 所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。 @@ -1109,7 +1109,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业 | PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 | | PAGE-20 审计日志 | `occurredFrom/To`(默认近 24 小时)、`actorId`、`identityCode`、`objectType`、`objectId`、`action`、`requestId`、`result` | `occurredAt`(默认倒序)、`eventSequence` | 时间、用户、身份、范围摘要、对象、动作、结果、原因、请求编号;查看脱敏前后值、导出;没有修改/删除 | | PAGE-21 权限与配置 | `resource`、`keyword`、`status`、`versionStatus`、`effectiveDate` | `name/code/status/updatedAt/effectiveAt` | 用户/角色/范围/模板/参数的代码、名称、版本、状态、生效时间、发布人;新增、编辑、启停、发布,按资源动作白名单执行 | -| PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取更新、立即重启 | +| PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取版本、立即更新、立即更新并重启 | 列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。 @@ -1648,7 +1648,7 @@ PAGE-01~PAGE-22 必须从以下模板组合,不允许每个开发人员各 | 录入/导入 | PAGE-10、PAGE-12 的编辑状态 | 分组表单 + 动态明细 + 附件 + 校验结果 + 底部固定操作栏 | `Form`、`Row`、`Col`、`Input`/`MoneyInput`、仅整数 `InputNumber`、`Select`、`DatePicker`、`Upload`、`Alert` | | 审批处理 | PAGE-11、PAGE-17 | 待办列表 + 业务摘要 + 附件/校验 + 审批时间线 + 当前节点动作 | `Table`、`Descriptions`、`Steps`、`Timeline`、`Dialog`、`Textarea` | | 配置管理 | PAGE-21 | 配置分类 + 版本化列表/详情 + 新增编辑弹窗;生效操作和业务操作分离 | `Tabs`、`Tree`、`Table`、`Form`、`Dialog`、`Popconfirm` | -| 系统更新 | PAGE-22 | 版本摘要 + 获取/下载进度 + 立即重启 + 审计历史 | `Steps`、`Table`、`Alert`、`Tag`、`Button` | +| 系统更新 | PAGE-22 | 版本摘要 + 独立检查 + 下载进度/完成确认 + 安装重启 + 审计历史 | `Steps`、`Table`、`Alert`、`Tag`、`Button` | | 结果/异常 | 全部页面 | 明确状态、可理解原因、请求编号和下一步动作 | `Result`、`Empty`、`Alert`、`Button`、`Skeleton` | 工作台不是营销首页,不使用大幅 Hero、插画横幅或仅作装饰的图表。图表必须回答一个业务问题,并提供同口径数字和可下钻明细;无有效业务含义时使用表格。 @@ -2932,7 +2932,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance | 检查项 | 本轮结果 | 证据边界 | | --- | --- | --- | | 固定更新源 | Git remote 已绑定 `https://git.awaioi.com/ERP-Team/kaidi.git`;生产读取 `https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,资产 URL 必须与 API 的 scheme、host、port 完全同源 | 页面不能修改更新地址、Token、脚本或命令;生产只读 Token 与 Gitea Actions 发布权限分离 | -| 两阶段交互 | 管理员点击“获取更新”后自动完成检查和下载;下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即重启”确认不切换版本 | +| 分阶段交互 | 管理员点击“获取版本”只检查发布信息;发现新版本后点击“立即更新”才下载。下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY/下载完成`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即更新并重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即更新并重启”确认不切换版本 | | 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、Nginx、更新 path unit 和静态首页健康检查;成功后页面倒计时 10 秒自动刷新,安装中刷新或短暂断线会恢复 3 秒轮询;失败自动恢复并复验上一版本 | 10 秒是页面刷新等待,不延迟服务端安装;真实 Linux systemd 主机仍须执行发布后冒烟和备份恢复演练 | | 凭据保护 | Token 只从权限 `0600` 的 root 配置/Token 文件读取,curl 通过临时 Header 文件使用;认证请求不跟随重定向,Token 不进入进程参数、页面、状态 JSON、审计参数或日志 | Token 轮换时必须同步更新 `/etc/kaidi/kaidi.env` 和 `/etc/kaidi/update.env` | | 接口与自动化 | OpenAPI 为 `206 paths / 287 schemas`;后端全量 `211/211`,Vitest `53/53`、完整 Playwright `183/183`、更新专项 Playwright `15/15` 通过;ShellCheck、Actionlint、安装、更新和 Gitea draft/恰好 9 资产/发布 fixture 通过 | 更新专项覆盖下载确认、安装弹窗、10 秒刷新、安装中刷新恢复、禁用状态和失败可见性 | diff --git a/frontend/contracts/routes.json b/frontend/contracts/routes.json index 6130b8f..3edc713 100644 --- a/frontend/contracts/routes.json +++ b/frontend/contracts/routes.json @@ -272,7 +272,7 @@ "pageType": "business", "pageTemplate": "system-update", "requiresAuth": true, - "description": "查看当前版本、获取签名更新包、立即重启并查询历史更新记录。", + "description": "查看当前版本、独立获取版本信息、显式下载签名更新包、下载完成后安装重启并查询历史更新记录。", "breadcrumb": ["系统治理", "系统更新"], "roles": ["SYSTEM_ADMIN"], "permission": "admin:update:view" diff --git a/frontend/e2e/system-update.e2e.ts b/frontend/e2e/system-update.e2e.ts index 16e08af..c6046b9 100644 --- a/frontend/e2e/system-update.e2e.ts +++ b/frontend/e2e/system-update.e2e.ts @@ -7,7 +7,7 @@ function envelope(data: unknown) { return { data, requestId: '01M00000000000000000000090' }; } -function session() { +function session(canExecute = true) { return { authenticated: true, user: { @@ -19,12 +19,13 @@ function session() { }, roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }], activeRole: 'SYSTEM_ADMIN', - permissions, + permissions: canExecute ? permissions : permissions.filter((permission) => permission !== 'admin:update:execute'), }; } function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) { const busy = [ + 'QUEUED', 'DOWNLOAD_QUEUED', 'INSTALL_QUEUED', 'VERIFYING', @@ -69,6 +70,12 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen }; } +interface FixtureOptions { + canExecute?: boolean; + failDownloadResponse?: boolean; + failInstallResponse?: boolean; +} + async function installFixture( page: Page, downloadBodies: unknown[], @@ -78,7 +85,9 @@ async function installFixture( completeAfterInstall = false, initialState?: string, completeBusyAfterFirstRead = false, + options: FixtureOptions = {}, ) { + const { canExecute = true, failDownloadResponse = false, failInstallResponse = false } = options; let checked = recoveryPending || Boolean(initialState); let state = recoveryPending ? 'FAILED' : initialState || 'IDLE'; const history: Array> = []; @@ -93,7 +102,7 @@ async function installFixture( objectType: 'SYSTEM_UPDATE', objectPublicId: 'SYSTEM_UPDATE', resultCode: 'SUCCESS', - reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即重启' : null, + reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null, beforeJson: null, afterJson: JSON.stringify({ targetVersion }), occurredAt: '2026-08-16T00:02:00Z', @@ -103,9 +112,9 @@ async function installFixture( await page.route('**/api/v1/**', async (route: Route) => { const request = route.request(); const pathname = new URL(request.url()).pathname; - if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) }); - if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) }); - if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) }); + if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session(canExecute)) }); + if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session(canExecute).user) }); + if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session(canExecute).roles) }); if (pathname === '/api/v1/auth/csrf') { return route.fulfill({ headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' }, @@ -129,19 +138,23 @@ async function installFixture( if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') { checked = true; recordHistory('SYSTEM_UPDATE_CHECK'); - return route.fulfill({ json: envelope(updateView(state, checked, enabled, recoveryPending)) }); + const response = updateView(state, checked, enabled, recoveryPending); + if (!canExecute) response.allowedActions = ['CHECK']; + return route.fulfill({ json: envelope(response) }); } if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') { downloadBodies.push(request.postDataJSON()); recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST'); state = 'READY'; + if (failDownloadResponse) return route.abort('connectionreset'); return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) }); } if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') { installBodies.push(request.postDataJSON()); recordHistory('SYSTEM_UPDATE_REQUEST'); const queued = updateView('INSTALL_QUEUED', true, enabled); - state = completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED'; + state = failInstallResponse || completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED'; + if (failInstallResponse) return route.abort('connectionreset'); return route.fulfill({ json: envelope(queued) }); } if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') { @@ -158,7 +171,7 @@ async function installFixture( }); } -test('system administrator checks and queues a signed online update', async ({ page }) => { +test('system administrator checks, downloads, and explicitly installs a signed online update', async ({ page }) => { const downloadBodies: unknown[] = []; const installBodies: unknown[] = []; await installFixture(page, downloadBodies, installBodies); @@ -166,16 +179,77 @@ test('system administrator checks and queues a signed online update', async ({ p await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新'); await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1'); - await page.getByRole('button', { name: '获取更新', exact: true }).click(); + await page.getByRole('button', { name: '获取版本', exact: true }).click(); await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2'); - await expect(page.getByRole('button', { name: '立即重启', exact: true })).toBeVisible({ timeout: 5_000 }); - expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]); - await expect(page.getByText('下载更新包', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '立即更新', exact: true })).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0); + expect(downloadBodies).toEqual([]); - await page.getByRole('button', { name: '立即重启', exact: true }).click(); + await page.getByRole('button', { name: '立即更新', exact: true }).click(); + expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]); + const updateState = page.locator('.version-item').filter({ hasText: '更新状态' }); + await expect(updateState.getByText('下载已排队', { exact: true })).toBeVisible(); + await expect(updateState.getByText('下载完成', { exact: true })).toBeVisible({ timeout: 8_000 }); + await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible(); + + await page.getByRole('button', { name: '立即更新并重启', exact: true }).click(); await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible(); - expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]); + expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即更新并重启' }]); +}); + +test('rechecking a downloaded package prompts installation instead of downloading again', async ({ page }) => { + await installFixture(page, [], [], true, false, false, 'READY'); + await page.goto('/governance/update'); + + await page.getByRole('button', { name: '获取版本', exact: true }).click(); + + await expect(page.getByText('更新包已下载完成,请点击“立即更新并重启”安装新版本', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible(); +}); + +test('view-only administrators see a download permission message', async ({ page }) => { + await installFixture(page, [], [], true, false, false, undefined, false, { canExecute: false }); + await page.goto('/governance/update'); + + await page.getByRole('button', { name: '获取版本', exact: true }).click(); + + await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0); +}); + +test('download and install continue polling after an accepted command loses its response', async ({ page }) => { + await installFixture(page, [], [], true, false, false, undefined, false, { + failDownloadResponse: true, + }); + await page.goto('/governance/update'); + + await page.getByRole('button', { name: '获取版本', exact: true }).click(); + await page.getByRole('button', { name: '立即更新', exact: true }).click(); + await expect( + page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }), + ).toBeVisible({ timeout: 8_000 }); + await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible(); +}); + +test('accepted install continues to the refresh countdown after its response is lost', async ({ page }) => { + await installFixture(page, [], [], true, false, false, undefined, false, { + failInstallResponse: true, + }); + await page.goto('/governance/update'); + + await page.getByRole('button', { name: '获取版本', exact: true }).click(); + await page.getByRole('button', { name: '立即更新', exact: true }).click(); + await expect( + page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }), + ).toBeVisible({ timeout: 8_000 }); + await page.getByRole('button', { name: '立即更新并重启', exact: true }).click(); + + await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ + timeout: 8_000, + }); }); test('brand logo returns the active identity to its workbench', async ({ page }) => { @@ -193,8 +267,9 @@ test('disabled online updates expose status without an executable check action', await page.goto('/governance/update'); await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: '获取更新', exact: true })).toBeDisabled(); - await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '获取版本', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0); }); test('rollback recovery keeps the failure visible without an install action', async ({ page }) => { @@ -202,15 +277,19 @@ test('rollback recovery keeps the failure visible without an install action', as await page.goto('/governance/update'); await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0); }); test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => { await installFixture(page, [], [], true, false, true); await page.goto('/governance/update'); - await page.getByRole('button', { name: '获取更新', exact: true }).click(); - await page.getByRole('button', { name: '立即重启', exact: true }).click({ timeout: 5_000 }); + await page.getByRole('button', { name: '获取版本', exact: true }).click(); + await page.getByRole('button', { name: '立即更新', exact: true }).click({ timeout: 5_000 }); + await expect( + page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }), + ).toBeVisible({ timeout: 8_000 }); + await page.getByRole('button', { name: '立即更新并重启', exact: true }).click(); await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ timeout: 5_000, @@ -221,7 +300,9 @@ test('reloading during installation resumes polling and starts the refresh count await installFixture(page, [], [], true, false, false, 'INSTALLING', true); await page.goto('/governance/update'); - await expect(page.getByText('安装中', { exact: true })).toBeVisible(); + await expect( + page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('安装中', { exact: true }), + ).toBeVisible(); await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ timeout: 6_000, }); diff --git a/frontend/src/pages/governance/SystemUpdatePage.vue b/frontend/src/pages/governance/SystemUpdatePage.vue index cd201d6..10ce14c 100644 --- a/frontend/src/pages/governance/SystemUpdatePage.vue +++ b/frontend/src/pages/governance/SystemUpdatePage.vue @@ -29,30 +29,40 @@
- - 获取更新 + + 获取版本 + + 立即更新 + + - 立即重启 + 立即更新并重启
- - + + @@ -66,7 +76,11 @@ 新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。 - {{ updateStatus?.message || '正在读取系统版本状态' }} +
+ {{ updateStateLabel }} + {{ updateStatus?.message || '正在读取系统版本状态' }} + {{ updateActionHint }} +
@@ -133,8 +147,9 @@ type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger'; const updateStatus = ref(null); const isNarrow = ref(false); const statusLoading = ref(false); -const fetchingUpdate = ref(false); -const restarting = ref(false); +const checkingUpdate = ref(false); +const downloadingUpdate = ref(false); +const installingUpdate = ref(false); const statusError = ref(''); const historyRows = ref([]); const historyLoading = ref(false); @@ -167,14 +182,27 @@ const currentIsLatest = computed( !updateBusy.value && !['FAILED', 'UNKNOWN'].includes(updateStatus.value.state), ); -const canFetchUpdate = computed( +const canCheckUpdate = computed( () => updateStatus.value?.enabled !== false && - updateStatus.value?.state !== 'READY' && - (updateStatus.value?.allowedActions.includes('CHECK') === true || - updateStatus.value?.allowedActions.includes('DOWNLOAD') === true), + !updateBusy.value && + updateStatus.value?.allowedActions.includes('CHECK') === true, +); +const canDownloadUpdate = computed( + () => + updateStatus.value?.enabled !== false && + !updateBusy.value && + Boolean(updateStatus.value?.latestVersion) && + updateStatus.value?.updateAvailable === true && + updateStatus.value?.allowedActions.includes('DOWNLOAD') === true, +); +const canInstall = computed( + () => + updateStatus.value?.enabled !== false && + !updateBusy.value && + updateStatus.value?.state === 'READY' && + updateStatus.value?.allowedActions.includes('INSTALL') === true, ); -const canRestart = computed(() => updateStatus.value?.allowedActions.includes('INSTALL') === true); const updateStep = computed(() => { const state = updateStatus.value?.state || 'IDLE'; if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3; @@ -190,11 +218,11 @@ const updateStateLabel = computed(() => { DISABLED: '未启用', CURRENT: '已是最新', QUEUED: '已排队', - DOWNLOAD_QUEUED: '等待下载', + DOWNLOAD_QUEUED: '下载已排队', INSTALL_QUEUED: '等待重启', VERIFYING: '校验中', DOWNLOADING: '下载中', - READY: '等待重启', + READY: '下载完成', BACKING_UP: '备份中', INSTALLING: '安装中', RUNNING: '重启中', @@ -206,6 +234,20 @@ const updateStateLabel = computed(() => { '待获取' ); }); +const updateActionHint = computed(() => { + const state = updateStatus.value?.state || 'IDLE'; + if (state === 'READY') { + return canInstall.value ? '下载完成,请点击“立即更新并重启”安装新版本。' : '下载完成,但当前账号没有安装权限。'; + } + if (canDownloadUpdate.value) return '已发现新版本,请点击“立即更新”开始下载。'; + if (updateStatus.value?.updateAvailable) return '已发现新版本,但当前账号没有下载权限。'; + if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING'].includes(state)) return '更新包正在下载和校验,请稍候。'; + if (['INSTALL_QUEUED', 'BACKING_UP', 'INSTALLING', 'RUNNING'].includes(state)) { + return '更新包已确认,系统正在备份、切换并重启。'; + } + if (state === 'IDLE' && !updateStatus.value?.latestVersion) return '点击“获取版本”检查最新发布。'; + return ''; +}); const updateStateTheme = computed(() => { const state = updateStatus.value?.state || 'IDLE'; if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success'; @@ -233,7 +275,7 @@ async function loadStatus(silent = false) { updateStatus.value = status; if (installStates.has(status.state)) awaitingRefresh.value = true; handleCompletion(status); - if (busyStates.has(status.state)) startPolling(); + if (busyStates.has(status.state) || downloadingUpdate.value || installingUpdate.value) startPolling(); else stopPolling(); } catch (error) { if (!silent) statusError.value = friendlyError(error, '系统版本状态读取失败'); @@ -266,9 +308,9 @@ async function refreshPage() { await Promise.all([loadStatus(), loadHistory()]); } -async function fetchUpdate() { - if (fetchingUpdate.value || updateBusy.value || !canFetchUpdate.value) return; - fetchingUpdate.value = true; +async function checkLatest() { + if (checkingUpdate.value || updateBusy.value || !canCheckUpdate.value) return; + checkingUpdate.value = true; statusError.value = ''; try { const checked = await checkSystemUpdate(); @@ -278,36 +320,61 @@ async function fetchUpdate() { MessagePlugin.info('当前已是最新版本'); return; } - if (!checked.latestVersion || !checked.allowedActions.includes('DOWNLOAD')) { - throw new Error('发现新版本,但当前账号没有下载更新包的权限'); + if (checked.state === 'READY') { + MessagePlugin.success( + checked.allowedActions.includes('INSTALL') + ? '更新包已下载完成,请点击“立即更新并重启”安装新版本' + : '更新包已下载完成,但当前账号没有安装权限', + ); + } else if (!checked.allowedActions.includes('DOWNLOAD')) { + MessagePlugin.info('发现新版本,但当前账号没有下载权限'); + } else { + MessagePlugin.success(`发现新版本 ${checked.latestVersion},请点击“立即更新”开始下载`); } - updateStatus.value = await downloadSystemUpdate(checked.latestVersion); - await loadHistory(true); - MessagePlugin.success('更新包已开始下载,下载和签名校验完成后可立即重启'); - startPolling(); } catch (error) { - statusError.value = friendlyError(error, '获取更新失败'); + statusError.value = friendlyError(error, '获取版本失败'); } finally { - fetchingUpdate.value = false; + checkingUpdate.value = false; } } -async function restartNow() { +async function downloadNow() { const version = updateStatus.value?.latestVersion; - if (!version || restarting.value || !canRestart.value) return; - restarting.value = true; + if (!version || downloadingUpdate.value || !canDownloadUpdate.value) return; + downloadingUpdate.value = true; + statusError.value = ''; + startPolling(); + try { + updateStatus.value = await downloadSystemUpdate(version); + MessagePlugin.success('更新包已开始下载,完成后可点击“立即更新并重启”'); + void loadHistory(true); + } catch (error) { + statusError.value = friendlyError(error, '下载更新包失败'); + void loadStatus(true); + } finally { + downloadingUpdate.value = false; + startPolling(); + } +} + +async function installNow() { + const version = updateStatus.value?.latestVersion; + if (!version || installingUpdate.value || !canInstall.value) return; + installingUpdate.value = true; statusError.value = ''; stopRefreshCountdown(); + awaitingRefresh.value = true; + startPolling(); try { - updateStatus.value = await installSystemUpdate(version, '管理员确认立即重启'); - awaitingRefresh.value = true; - MessagePlugin.success('重启任务已提交,系统恢复后页面将自动刷新'); - await loadHistory(true); - startPolling(); + updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启'); + MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新'); + void loadHistory(true); } catch (error) { - statusError.value = friendlyError(error, '立即重启失败'); + statusError.value = friendlyError(error, '立即更新并重启失败'); + void loadStatus(true); } finally { - restarting.value = false; + installingUpdate.value = false; + startPolling(); } } @@ -324,6 +391,7 @@ function stopPolling() { function handleCompletion(status: SystemUpdateView) { if (!awaitingRefresh.value) return; if (status.state === 'SUCCEEDED') { + statusError.value = ''; awaitingRefresh.value = false; startRefreshCountdown(); void loadHistory(true); @@ -374,7 +442,7 @@ function actionLabel(action: string) { { SYSTEM_UPDATE_CHECK: '获取版本', SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包', - SYSTEM_UPDATE_REQUEST: '立即重启', + SYSTEM_UPDATE_REQUEST: '立即更新并重启', }[action] || action ); } @@ -503,6 +571,17 @@ onBeforeUnmount(() => { border-top: 1px solid var(--td-component-border); } +.update-status-message { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; +} + +.update-status-message small { + color: var(--td-text-color-secondary); +} + .release-notes span, .section-heading p, .section-heading > span {