# 推送失败重试 + 实时进度 — 设计规格

> ⚠️ **已归档** — 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] 不在推送对话框卡住用户
