Files
TallyNote/docs/update-refactor-plan.md
T
Qiufeng ae5892d81c
TallyNote release / linux-x64 (push) Failing after 3m12s
feat: refactor online update to synchronous web-process download
- Download happens in web process (non-root) with real-time progress
- Root runner only handles privileged apply (stop/backup/switch/restart)
- Eliminates 'waiting for system scheduler' stuck state
- Frontend shows download bytes/speed/percentage with cancel button
- Staged download triggers apply request file for root runner
- systemd timeout reduced from 32min to 5min (no download phase)
- Tests adapted for synchronous download flow
release: 1.3.0
2026-09-10 23:18:33 +08:00

304 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 在线更新重构方案
## 一、问题背景
当前在线更新使用 4 次进程交接链路:
```
web 进程 → 写 update-request.json → tallynote-update.path 触发
→ tallynote-update.service → tallynote-update-runner.sh (root)
→ 下载 + 校验 + 暂存 + 停服 + 备份 + 切换 + 重启 + 健康检查
```
下载在 root runner 中执行,前端只能轮询 DB 状态,看不到实时进度。
多次出现"等待系统调度"卡死,根因是链路中任一环节出错都会断链。
## 二、目标
将下载移入 web 进程同步执行,root runner 只负责特权应用(停服/备份/切换/重启)。
链路从 4 次交接缩减为 1 次。
## 三、当前架构(需改动的文件清单)
| 文件 | 行数 | 职责 | 改动级别 |
|---|---|---|---|
| server/update-service.ts | ~420 | checkForUpdate, writeUpdateRequest, reconcileOrphanedUpdateJobs, cancelUpdateJob, publicUpdateJob | 大改 |
| server/update.ts | ~300 | fetchReleaseMetadata, fetchReleaseBytes, selectReleaseAsset, validateHttpsUrl | 小改 |
| server/app.ts (930-1230) | ~300 | 6 个 API 路由 | 大改 |
| scripts/tallynote-update-runner.sh | ~200 | root runner: flock+心跳+恢复+下载+校验+暂存+应用 | 大改 |
| scripts/tallynote-update.sh | ~100 | 手动更新/回滚入口 | 小改 |
| systemd/tallynote-update.service | ~30 | oneshot root 服务 | 小改 |
| systemd/tallynote-update.path | ~20 | 监听请求文件触发 | 不变 |
| web/src/main.tsx (697-790) | ~90 | UpdateCenter 组件 | 大改 |
| shared/contracts.ts (85-100) | ~15 | UpdateJobStatus 枚举 | 小改 |
| server/db/schema.ts (134-163) | ~30 | update_jobs 表 | 不变 |
| server/config.ts | ~100 | TALLYNOTE_UPDATE_* 配置 | 小改 |
| tests/update-api.test.ts | ~450 | 更新 API 测试 | 大改 |
## 四、改动后的架构
```
用户点"下载更新包"
↓
web 进程 (tallynote 用户, 非 root)
├── 创建 job 行 (status=downloading)
├── HTTPS 流式下载归档到 /var/lib/tallynote/staging/update-<jobId>.tar.gz
├── 边下载边更新 DB: downloadedBytes, downloadSpeedBps
├── 下载完成 → SHA-256 校验 → status=staged
└── 写 update-request.json (operation=apply, 含暂存路径)
↓
tallynote-update.path 触发 → tallynote-update.service (root)
├── 读请求文件
├── 停服 → 备份 → 原子切换 → 重启 → 健康检查
└── 更新 DB: status=completed/failed
```
## 五、详细代码修改
### 5.1 server/update-service.ts
**新增函数:**
```ts
// 同步下载归档,流式写入暂存目录,实时更新 DB 进度
export async function downloadReleaseAsset(
database: Database.Database,
config: AppConfig,
jobId: string,
assetUrl: string,
expectedSha256: string,
assetName: string,
): Promise<{ actualSha256: string; sizeBytes: number; downloadPath: string }>;
```
逻辑:
- 用 fetchReleaseBytes (已存在于 update.ts) 发起 HTTPS 请求
- 创建可写流到 config.dataDir/staging/update-<jobId>.tar.gz (tallynote 用户可写)
- pipeline(response.body → createHash('sha256') → fileStream),边算 hash 边写盘
- 每秒更新 DB: downloadedBytes, downloadSpeedBps, status=downloading
- 完成后比对 expectedSha256 vs actualSha256,不匹配 → status=failed
- 匹配 → status=staged, 写 downloadPath 到 DB
- 然后写 update-request.json (operation=apply)
**修改函数:**
- `reconcileOrphanedUpdateJobs`: 保留,但 queued 状态不再出现(下载在 web 进程内)
- `publicUpdateJob`: 保留,已支持 downloadedBytes/downloadSpeedBps 字段
- `cancelUpdateJob`: 增加 abort 下载流的能力
- `writeUpdateRequest`: 增加 stagedPath 字段传递暂存文件路径
**删除/简化:**
- QUEUED_UPDATE_TIMEOUT_MS 逻辑不再需要(下载不在 systemd 队列中等待)
### 5.2 server/app.ts — API 路由修改
**POST /api/update/download → 改为同步下载**
当前:创建 job → 写请求文件 → 返回 202
改为:
1. 创建 job (status=downloading)
2. 在请求处理函数内同步执行 downloadReleaseAsset
3. 下载完成后写 apply 请求文件
4. 返回 { job: { status: "staged", ... } }
5. 如果下载中客户端断开,设置 AbortController 取消下载
注意:Fastify 请求超时需配置为足够长(115MB / 最低网速)。设置路由级
bodyLimit=0 (不读 body) 并配置 reply 的 connectionTimeout。
**新增 SSE 端点:GET /api/update/progress**
返回 Server-Sent Events 流,推送实时下载进度:
```
event: progress
data: {"downloadedBytes": 12345678, "speedBps": 5242880, "sizeBytes": 120586240}
```
前端用 EventSource 监听。下载完成后关闭 SSE。
**POST /api/update/apply — 不变**
仍然读请求文件触发 root runner。
**GET /api/update/status — 不变**
仍然返回 job 状态。
### 5.3 scripts/tallynote-update-runner.sh
**删除:**
- 下载逻辑 (约 80 行)
- 校验 SHA-256 逻辑 (约 30 行)
- 暂存逻辑
- 心跳 (heartbeat) — 下载不再在 root 中,apply 很快不需要心跳
- QUEUED 状态处理
**保留:**
- flock 锁
- 恢复状态 (.update-state) — apply 阶段仍需要
- 停服 → 备份 → 原子切换 → 重启 → 健康检查
- 回滚逻辑
**简化后:** runner 只做 apply:读暂存路径 → 停服 → 备份 → 切换 → 启动 → 健康检查
约从 200 行缩减到 80 行。
### 5.4 scripts/tallynote-update.sh
手动入口不变,但 runner 已不下载,所以手动入口也跳过下载阶段。
`--rollback` 逻辑完全不变。
### 5.5 systemd/tallynote-update.service
```ini
# 简化:不再需要 32 分钟超时(无下载阶段)
TimeoutStartSec=5min
# 其余安全约束不变
```
### 5.6 systemd/tallynote-update.path
不变。仍然监听 update-request.json 触发 runner。
但请求文件的 operation 现在只有 "apply"。
### 5.7 web/src/main.tsx — UpdateCenter 组件
**当前流程(前端):**
1. 进入页面 → GET /api/update/status
2. 点"检查更新" → POST /api/update/check
3. 点"更新到 vX.X.X" → POST /api/update/download → 轮询 /api/update/jobs/:id
4. staged 后 → POST /api/update/apply → 轮询
5. completed → 显示"重新加载"
**改为:**
1. 进入页面 → GET /api/update/status(自动检查最新版本)
2. 点"检查更新" → POST /api/update/check
3. 点"下载更新包" → POST /api/update/download(同步)
- 同时打开 EventSource(/api/update/progress) 监听实时进度
- 显示:下载进度条 + 已下载/总量 + 网速 + 剩余时间
- 下载完成 → 自动切换到"立即更新"按钮
4. 点"立即更新" → POST /api/update/apply
- 弹窗显示:正在应用更新 → 倒计时 → 自动重连
5. 重连成功 → 显示"更新完成" + 版本号变化
**UI 状态机:**
```
idle → checking → hasUpdate
→ downloading (实时进度, 可取消)
→ verifying (校验中, 短暂)
→ staged (显示"立即更新"按钮)
→ applying (倒计时弹窗)
→ completed (显示"重新加载")
→ failed (显示错误 + 重试)
```
**取消下载:** 下载中显示"取消"按钮 → POST /api/update/cancel → abort 流
### 5.8 shared/contracts.ts
UpdateJobStatus 不变(仍包含所有状态)。
新增 downloadProgress 的事件类型定义。
### 5.9 server/config.ts
新增:
- `stagingDir`: path.join(dataDir, "staging") — 暂存目录
- `updateDownloadTimeoutMs`: 下载超时 (默认 10 分钟)
### 5.10 tests/update-api.test.ts
重写下载测试:
- mock HTTPS 响应,验证流式下载 + SHA-256 校验
- 验证下载进度写入 DB
- 验证下载完成后写 apply 请求文件
- 验证取消下载清理暂存文件
- apply 测试不变
## 六、不修改的部分
- 后端 API 契约语义不变(check/apply/cancel/status 接口签名不变)
- update_jobs 表结构不变
- 数据目录布局不变
- 安装/卸载逻辑不变
- 权限语义不变(web 非 root, runner root)
- SHA-256 强制校验不变
- 原子切换 + 自动回滚不变
- 版本号比较逻辑不变
- Release 元数据获取逻辑不变
## 七、向后兼容
- 旧版本安装(v1.2.9 及之前)升级到新版本后:
- 已有的 systemd 单元仍能工作
- 如果有遗留的 queued 状态 job,reconcileOrphanedUpdateJobs 会清理
- runner 简化后仍能处理 apply 请求
- 数据库迁移:不需要(表结构不变)
- 请求文件格式:增加 stagedPath 字段,旧 runner 忽略未知字段
## 八、验收标准
### 功能验收
1. 进入更新页面 → 自动检查最新版本 → 显示 Release 信息
2. 点"下载更新包" → 实时显示进度条、已下载字节数、网速
3. 下载完成 → 自动校验 SHA-256 → 显示"立即更新"
4. 点"立即更新" → 弹窗倒计时 → 服务重启 → 自动重连 → 显示新版本号
5. 更新失败 → 显示错误 → 可重试
6. 下载中可取消 → 暂存文件清理干净
7. 不出现"等待系统调度"状态
8. 不显示直链下载地址
9. 更新日志 markdown 正确渲染
10. 通知弹窗在右下角,使用柔和语义双层卡片样式
11. 无 emoji,使用 Lucide 图标
### 安全验收
12. 下载必须 HTTPS
13. SHA-256 校验不匹配时拒绝应用
14. web 进程不执行 systemctl
15. root runner 仍用 flock 防并发
16. 路径穿越、符号链接仍被拒绝
### 回滚验收
17. 应用失败 → 自动回滚到上一版本
18. 数据目录不被替换
19. 手动回滚 `sudo /usr/local/sbin/tallynote-update --rollback` 仍可用
### 测试验收
20. pnpm check 通过
21. pnpm test 全量通过
22. pnpm test:installer 通过
23. pnpm run build 通过
24. CI 构建通过(python3 pty 测试不依赖 expect)
### 前端验收
25. 页面切换过渡丝滑,无延迟感
26. 下载进度条垂直水平居中
27. 弹窗内图标与文字水平对齐
28. 响应式:窄屏不溢出、不遮挡
29. 键盘可操作核心流程
30. prefers-reduced-motion 下功能完整
## 九、实施顺序
1. 后端:server/update-service.ts 新增 downloadReleaseAsset
2. 后端:server/app.ts 改 download 路由 + 新增 progress SSE
3. 后端:server/config.ts 新增 stagingDir
4. 脚本:scripts/tallynote-update-runner.sh 简化(删下载/心跳)
5. systemd:tallynote-update.service 调整超时
6. 前端:web/src/main.tsx UpdateCenter 组件重写
7. 测试:tests/update-api.test.ts 重写下载测试
8. 全量验证:check + test + test:installer + build
9. 发布新版本
## 十、风险评估
| 风险 | 级别 | 缓解 |
|---|---|---|
| 长时间 HTTP 请求占用 Fastify 连接 | 中 | 路由级超时 + SSE 独立连接 |
| 下载中途 web 进程崩溃 | 低 | job 行标记 failed,暂存文件下次清理 |
| 并发下载 | 低 | DB 级活跃 job 检查 + 文件锁 |
| 暂存目录磁盘空间不足 | 低 | 下载前检查可用空间 |
| 旧版本残留的 queued job | 低 | reconcileOrphanedUpdateJobs 清理 |