Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf29c030a | ||
|
|
6d4ae00ae0 | ||
|
|
3c7847c8f5 |
@@ -56,7 +56,7 @@ PostgreSQL 18 兼容工作继续冻结。
|
||||
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.26/install.sh | sudo env KAIDI_APP_PORT=18080 bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.29/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/<version>`,业务服务继续运行。
|
||||
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/<version>`,业务服务继续运行。
|
||||
3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。
|
||||
4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。
|
||||
5. 更新器安装并保护新版本目录,原子切换 `/www/wwwroot/kaidi/current`,校验旧 Java 的受控 PID 后发送停止信号;宝塔守护自动启动新版本。
|
||||
6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本,
|
||||
保留数据库备份和事务证据供人工处理。
|
||||
|
||||
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。
|
||||
|
||||
@@ -18,5 +18,5 @@ IOSchedulingPriority=6
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/kaidi /www/wwwroot/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system
|
||||
ReadWritePaths=/opt/kaidi -/www/wwwroot/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system
|
||||
UMask=0077
|
||||
|
||||
+15
-3
@@ -62,6 +62,9 @@ case "$RUNTIME_ENV_FILE" in
|
||||
esac
|
||||
|
||||
bootstrap_die() {
|
||||
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
|
||||
fail "$1"
|
||||
fi
|
||||
printf '%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -174,6 +177,15 @@ prepare_update_layout() {
|
||||
|| bootstrap_die "Private update directories could not be prepared"
|
||||
}
|
||||
|
||||
prepare_queue_layout() {
|
||||
if [ -d "$PROCESSING_DIR" ] && [ ! -L "$PROCESSING_DIR" ] \
|
||||
&& [ -d "$FAILED_REQUEST_ROOT" ] && [ ! -L "$FAILED_REQUEST_ROOT" ]; then
|
||||
return 0
|
||||
fi
|
||||
install -d -o root -g root -m 0700 "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" \
|
||||
|| bootstrap_die "Private update queue directories could not be prepared"
|
||||
}
|
||||
|
||||
secure_release_tree() {
|
||||
release_dir=$1
|
||||
chown -R root:"$SERVICE_GROUP" "$release_dir" || return 1
|
||||
@@ -197,9 +209,6 @@ verify_release_access() {
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_update_layout
|
||||
load_runtime_database_env
|
||||
|
||||
# shellcheck disable=SC2329 # Invoked by the EXIT trap below.
|
||||
cleanup() {
|
||||
rc=$?
|
||||
@@ -616,6 +625,7 @@ validate_release_operations() {
|
||||
"$WORK_DIR/extracted/ops/kaidi-update.path" >/dev/null || return 1
|
||||
}
|
||||
|
||||
prepare_queue_layout
|
||||
exec 9>"$LOCK_FILE"
|
||||
flock -n 9 || exit 0
|
||||
recover_interrupted_transaction
|
||||
@@ -650,6 +660,8 @@ TARGET_VERSION=$REQUESTED_VERSION
|
||||
REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
|
||||
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \
|
||||
|| fail "Update request action is invalid"
|
||||
prepare_update_layout
|
||||
load_runtime_database_env
|
||||
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
|
||||
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|
||||
|| fail "UPDATE_RELEASE_TOKEN is invalid"
|
||||
|
||||
+9
-9
@@ -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 秒刷新、安装中刷新恢复、禁用状态和失败可见性 |
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
"pageType": "business",
|
||||
"pageTemplate": "system-update",
|
||||
"requiresAuth": true,
|
||||
"description": "查看当前版本、获取签名更新包、立即重启并查询历史更新记录。",
|
||||
"description": "查看当前版本、独立获取版本信息、显式下载签名更新包、下载完成后安装重启并查询历史更新记录。",
|
||||
"breadcrumb": ["系统治理", "系统更新"],
|
||||
"roles": ["SYSTEM_ADMIN"],
|
||||
"permission": "admin:update:view"
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -29,30 +29,40 @@
|
||||
<div class="update-actions">
|
||||
<t-button
|
||||
variant="outline"
|
||||
:loading="fetchingUpdate"
|
||||
:disabled="!canFetchUpdate || updateBusy || restarting"
|
||||
@click="fetchUpdate"
|
||||
:loading="checkingUpdate"
|
||||
:disabled="!canCheckUpdate || checkingUpdate || downloadingUpdate || installingUpdate"
|
||||
@click="checkLatest"
|
||||
>
|
||||
<template #icon><t-icon name="cloud-download" /></template>
|
||||
获取更新
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
获取版本
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="canRestart"
|
||||
v-if="canDownloadUpdate"
|
||||
theme="primary"
|
||||
:loading="restarting"
|
||||
:disabled="updateBusy || fetchingUpdate"
|
||||
@click="restartNow"
|
||||
:loading="downloadingUpdate"
|
||||
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
|
||||
@click="downloadNow"
|
||||
>
|
||||
<template #icon><t-icon name="cloud-download" /></template>
|
||||
立即更新
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="canInstall"
|
||||
theme="primary"
|
||||
:loading="installingUpdate"
|
||||
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
|
||||
@click="installNow"
|
||||
>
|
||||
<template #icon><t-icon name="poweroff" /></template>
|
||||
立即重启
|
||||
立即更新并重启
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps">
|
||||
<t-step-item title="获取版本" content="检查最新发布" />
|
||||
<t-step-item title="下载校验" content="下载并验证签名" />
|
||||
<t-step-item title="立即重启" content="备份并切换版本" />
|
||||
<t-step-item title="下载更新" content="下载并验证签名" />
|
||||
<t-step-item title="安装重启" content="备份并切换版本" />
|
||||
<t-step-item title="完成更新" content="健康检查后刷新" />
|
||||
</t-steps>
|
||||
|
||||
@@ -66,7 +76,11 @@
|
||||
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
|
||||
</t-alert>
|
||||
<t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false">
|
||||
{{ updateStatus?.message || '正在读取系统版本状态' }}
|
||||
<div class="update-status-message">
|
||||
<strong>{{ updateStateLabel }}</strong>
|
||||
<span>{{ updateStatus?.message || '正在读取系统版本状态' }}</span>
|
||||
<small v-if="updateActionHint">{{ updateActionHint }}</small>
|
||||
</div>
|
||||
</t-alert>
|
||||
|
||||
<div v-if="updateStatus?.releaseNotes" class="release-notes">
|
||||
@@ -133,8 +147,9 @@ type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
|
||||
const updateStatus = ref<SystemUpdateView | null>(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<AuditLog[]>([]);
|
||||
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<TagTheme>(() => {
|
||||
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 {
|
||||
|
||||
@@ -319,6 +319,8 @@ grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \
|
||||
|| fail 'application service no longer has a bounded restart burst'
|
||||
grep -Fqx 'Restart=no' "$ROOT/deploy/systemd/kaidi-update.service" \
|
||||
|| fail 'update service can automatically repeat a failed switching transaction'
|
||||
grep -Fq -- '-/www/wwwroot/kaidi' "$ROOT/deploy/systemd/kaidi-update.service" \
|
||||
|| fail 'update service requires the Baota application path on a systemd-only installation'
|
||||
! grep -Fq '/var/lib/kaidi-update/processing' "$ROOT/deploy/systemd/kaidi-update.path" \
|
||||
|| fail 'update path can automatically repeat a claimed switching transaction'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
|
||||
@@ -26,18 +26,35 @@ for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
|
||||
done
|
||||
|
||||
missing_service_user="kaidi-fixture-missing-$$"
|
||||
mkdir -p "$WORK/bootstrap/app" "$WORK/bootstrap/state/inbox" \
|
||||
"$WORK/bootstrap/state/processing" "$WORK/bootstrap/state/failed" "$WORK/bootstrap/log" \
|
||||
"$WORK/bootstrap/bin"
|
||||
cat > "$WORK/bootstrap/bin/flock" <<'SH'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
SH
|
||||
chmod 0755 "$WORK/bootstrap/bin/flock"
|
||||
jq -n \
|
||||
'{action:"DOWNLOAD",version:"1.0.0-preview.2",reason:"bootstrap fixture"}' \
|
||||
> "$WORK/bootstrap/state/inbox/request.json"
|
||||
if KAIDI_APP_ROOT="$WORK/bootstrap/app" \
|
||||
KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \
|
||||
KAIDI_LOG_ROOT="$WORK/bootstrap/log" \
|
||||
KAIDI_SERVICE_USER="$missing_service_user" \
|
||||
KAIDI_SERVICE_GROUP="$missing_service_user" \
|
||||
PATH="$WORK/bootstrap/bin:$PATH" \
|
||||
sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then
|
||||
fail 'updater accepted a missing service identity during bootstrap'
|
||||
fi
|
||||
grep -Fq "Service user $missing_service_user is missing" "$WORK/bootstrap.log" \
|
||||
|| fail 'updater bootstrap failure did not preserve its diagnostic'
|
||||
! grep -Eq 'No such file|nonexistent directory|cannot create' "$WORK/bootstrap.log" \
|
||||
|| fail 'updater bootstrap failure was masked by an unavailable status directory'
|
||||
[ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \
|
||||
|| fail 'updater bootstrap failure did not persist FAILED'
|
||||
[ ! -e "$WORK/bootstrap/state/inbox/request.json" ] \
|
||||
&& [ ! -e "$WORK/bootstrap/state/processing/request.json" ] \
|
||||
|| fail 'updater bootstrap failure left a request permanently queued'
|
||||
find "$WORK/bootstrap/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|
||||
|| fail 'updater bootstrap failure did not archive the claimed request'
|
||||
|
||||
write_mock_commands() {
|
||||
local mock_bin=$1
|
||||
|
||||
Reference in New Issue
Block a user