# 推送失败重试 + 实时进度 — 设计规格 > ⚠️ **已归档** — 2026-05-22 > 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`;PHP 前端 (`retry.php`/`servers.php`) 已迁移到 Tailwind CSS v4 + Alpine.js;`datetime.utcnow()` 已弃用。功能已实现,保留本文档供历史参考。 ## 背景 当前推送 `asyncio.gather` 等待全部完成后一次性返回,大数据量时前端无反馈。失败推送只记录 SyncLog,需人工回到服务器管理页重试。需:**实时进度 + 自动重试 + 一致性验证后消失**。 ## 核心设计 ### 数据模型 **新增 PushRetryJob 表:** ```python class PushRetryJob(Base): __tablename__ = "push_retry_jobs" id = Column(Integer, primary_key=True, autoincrement=True) server_id = Column(Integer, ForeignKey("servers.id"), nullable=False) server_name = Column(String(100)) # 冗余,方便列表显示 source_path = Column(String(500)) target_path = Column(String(500)) status = Column(String(20), default="pending") # pending/retrying/abandoned retry_count = Column(Integer, default=0) max_retries = Column(Integer, default=100) next_retry_at = Column(DateTime) # 初始 NOW()+5min last_error = Column(Text) created_at = Column(DateTime, default=datetime.datetime.utcnow) updated_at = Column(DateTime, default=datetime.datetime.utcnow) ``` ### 生命周期 ``` 推送失败 ──→ INSERT job (status=pending, next_retry=NOW+5min) │ ▼ 后台 loop (60s) ──→ 扫描 next_retry_at <= NOW() │ rsync -avz --stats (重新推送) │ rsync -avzn --delete (一致性 dry-run) │ ┌───────────┴───────────┐ 一致 不一致/错误 │ │ DELETE job retries < 100 ? (消失) │ ┌───────┴───────┐ 是 否 │ │ next_retry status=abandoned =NOW+5min (人工处理) ``` ### 一致性验证命令 ```bash rsync -avzn --stats --delete --out-format='%i %n' source/ server:target/ ``` - `-n` dry-run - `--delete` 检测目标多余文件 - 输出 `Number of files: 0` → 完全一致 ### 实时进度 **sync_engine 改动:** `push_to_server` 新增 `progress_callback` 参数: ```python async def push_to_server(server, source, target, progress_callback=None): # rsync --out-format='%i %n' --info=progress2 # 逐行解析: # >f+++++++++ file.txt → 文件新增 # .f...p..... file2.txt → 权限变更 for line in iter(proc.stdout.readline, b''): if m := re.match(r'^\S+\s+(.+)$', line): file_name = m.group(1) transferred += 1 if progress_callback: await progress_callback(server.id, { "file": file_name, "transferred": transferred, "total": total_files, # 从 --stats 预取 }) ``` **WebSocket 推送路径:** ``` push_to_server → progress_callback → WebSocket manager → 前端 → broadcast({server_id, progress}) ``` ### API 端点 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/servers/retry-jobs` | 重试队列列表 | | POST | `/api/servers/retry-jobs/{id}/retry-now` | 立即重试单条 | | POST | `/api/servers/retry-jobs/retry-all` | 批量重试全部 pending | | DELETE | `/api/servers/retry-jobs/{id}` | 删除/放弃单条 | | DELETE | `/api/servers/retry-jobs/clear-done` | 清除已完成/放弃的 | ### 前端 **retry.php — 重试队列页:** ``` ┌─ 重试队列 (3) ───────────────────────────────────────┐ │ 服务器 源目录 目标目录 重试 操作 │ │ web-01 /www/wwwroot/proj /var/www/site 2 🔄⏭ │ │ api-01 /www/wwwroot/api /opt/api 0 🔄⏭ │ │ db-01 /data/backup /backup 5 🔄⏭ │ │ [全部重试] [清除] │ └──────────────────────────────────────────────────────┘ ``` **servers.php 推送对话框改实时:** ``` ┌─ 推送中... ─────────────────────────────────┐ │ web-01 ████████████░░░░░░ 120/350 文件 │ │ 45MB · ETA 2 分钟 │ │ web-02 ████████████████░░ 200/350 文件 │ │ 80MB · ETA 1 分钟 │ │ 完成 1/3 │ │ [后台运行] │ └──────────────────────────────────────────────┘ ``` sidebar 角标:`重试队列 3` ### 后台服务 **新增 retry_engine.py:** ```python async def retry_loop(): while True: db = SessionLocal() jobs = db.query(PushRetryJob).filter( PushRetryJob.status == "pending", PushRetryJob.next_retry_at <= datetime.utcnow(), ).all() for job in jobs: job.status = "retrying" db.commit() # rsync 推送 result = await push_to_server(server, job.source_path, job.target_path) # dry-run 一致性 consistent = await verify_consistency(server, job.source_path, job.target_path) if consistent: db.delete(job) else: job.retry_count += 1 if job.retry_count >= job.max_retries: job.status = "abandoned" else: job.status = "pending" job.next_retry_at = datetime.utcnow() + timedelta(minutes=5) job.last_error = result.get("message", "") db.commit() db.close() await asyncio.sleep(60) ``` 在 `main.py` `lifespan` 中启动 `retry_loop`。 ### 文件统计 | 文件 | 改动 | 预估行数 | |------|------|---------| | `server/database.py` | PushRetryJob 模型 | +15 | | `server/services/retry_engine.py` | 新建,retry loop + 一致性验证 | +120 | | `server/api/servers.py` | push 端点 WebSocket 进度 + 失败写 job | +30 | | `server/services/sync_engine.py` | 流式解析 rsync + progress_callback | +40 | | `server/main.py` | 启动 retry_loop + WS manager | +15 | | `web/retry.php` | 新建 | +80 | | `web/servers.php` | 推送对话框实时进度条 | +60 | | 各页面 sidebar | 加重试队列链接 + 角标 | +20 | **预估:8 文件,~380 行。** ## 规格自检 - [x] 无占位符/TODO - [x] 重试机制完整:失败 → INSERT → 定时 scan → rsync → verify → delete/retry/abandon - [x] 进度即时:WebSocket + callback 逐文件推送 - [x] 一致性:rsync dry-run --delete,0 个变化才消失 - [x] 前端独立页面 + sidebar 角标 - [x] 不在推送对话框卡住用户