# 简化推送模型 实现计划 > ⚠️ **已归档** — 2026-05-22 > 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`;`server/api/tasks.py` 已删除;PHP 前端 (`servers.php`/`tasks.php`/`push.php`) 已迁移到 Tailwind CSS v4 + Alpine.js;sync_engine 已改为 asyncssh。功能已实现,保留本文档供历史参考。 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 将复杂 SyncTask 模型简化为 Server.target_path + 批量 push API + 分类选择推送界面 **架构:** Server 模型新增 target_path/category 字段;删除 SyncTask 表;新增 POST /api/servers/push 批量推送端点;sync_engine 简化为纯单向 push;前端 servers.php 加入分类推送对话框 **技术栈:** Python FastAPI + SQLAlchemy + MySQL + PHP 7.4 + rsync --- ### 任务 1:数据库模型变更 **文件:** - 修改:`server/database.py`(全文件) - 修改:`server/config.py:27`(删 SYNC_INTERVAL_SECONDS) - 修改:`.env.example:26-30`(删 trigger/sync 配置) - [ ] **步骤 1:Server 模型加 target_path + category,删 SyncTask,调整 SyncLog** 修改 `server/database.py`,在 Server 模型的 `description` 字段后添加: ```python target_path = Column(String(500), nullable=True, comment="推送目标路径") category = Column(String(100), nullable=True, comment="服务器分类(如 Web服务器/API服务器)") ``` 删除整个 SyncTask 类(第 112-141 行)。 修改 SyncLog 类:删除 `task_id` 列,添加 `source_path`、`target_path` 列: ```python class SyncLog(Base): """Push operation log""" __tablename__ = "sync_logs" id = Column(Integer, primary_key=True, autoincrement=True) server_id = Column(Integer, ForeignKey("servers.id"), nullable=False) source_path = Column(String(500), nullable=False, comment="源目录") target_path = Column(String(500), nullable=False, comment="目标目录") trigger_type = Column(String(20), comment="触发类型: manual") status = Column(String(20), comment="pending/running/success/failed") files_total = Column(Integer, default=0) files_transferred = Column(Integer, default=0) files_skipped = Column(Integer, default=0) bytes_transferred = Column(Integer, default=0) duration_seconds = Column(Integer, default=0) diff_summary = Column(Text, nullable=True, comment="rsync输出摘要") error_message = Column(Text, nullable=True) started_at = Column(DateTime, default=datetime.datetime.utcnow) finished_at = Column(DateTime, nullable=True) server = relationship("Server", back_populates="sync_logs") ``` 更新 Server 模型的 relationship(删 sync_tasks): ```python sync_logs = relationship("SyncLog", back_populates="server", cascade="all, delete-orphan") ``` - [ ] **步骤 2:删 config.py 中 SYNC_INTERVAL_SECONDS** ```python # 删除以下行: # SYNC_INTERVAL_SECONDS: int = 300 # 5 minutes ``` - [ ] **步骤 3:更新 .env.example** 删 `NEXUS_SYNC_INTERVAL_SECONDS` 和 `NEXUS_TRIGGER_FILENAME` 行。 - [ ] **步骤 4:验证数据库初始化** ```bash python -m py_compile server/database.py && python -m py_compile server/config.py && echo "OK" ``` - [ ] **步骤 5:Commit** ```bash git add server/database.py server/config.py .env.example git commit -m "refactor(db): 简化数据模型 — Server 加 target_path/category,删 SyncTask,调整 SyncLog" ``` --- ### 任务 2:Python API 变更 — 删除 tasks 路由 + 新增 push 端点 + 清理 agent **文件:** - 删除:`server/api/tasks.py` - 修改:`server/api/servers.py`(添加 push 端点 + ServerCreate/Update/Response 更新) - 修改:`server/api/agent.py`(删除 /trigger 端点) - 修改:`server/main.py`(删除 tasks 路由注册,更新 imports) - [ ] **步骤 1:删除 tasks.py + 清理 main.py 中 tasks 路由** ```bash rm server/api/tasks.py ``` 修改 `server/main.py`,删除 `from server.api import servers, tasks, agent, auth` 中的 `tasks` → 改为 `from server.api import servers, agent, auth`。删除 `tasks.router` 的 `app.include_router` 调用(含 dependencies 行)。 - [ ] **步骤 2:更新 servers.py — Pydantic 模型加 target_path/category** 修改 `server/api/servers.py` 的 `ServerCreate`、`ServerUpdate`、`ServerResponse`: ```python class ServerCreate(BaseModel): name: str domain: str port: int = 22 username: str = "root" auth_method: str = "key" password: Optional[str] = None ssh_key_path: Optional[str] = None agent_port: int = 8601 agent_api_key: Optional[str] = None description: Optional[str] = None target_path: Optional[str] = None # 新增 category: Optional[str] = None # 新增 class ServerUpdate(BaseModel): # ... 所有已有字段 ... target_path: Optional[str] = None # 新增 category: Optional[str] = None # 新增 class ServerResponse(BaseModel): id: int name: str domain: str port: int username: str auth_method: str agent_port: int description: Optional[str] target_path: Optional[str] # 新增 category: Optional[str] # 新增 is_online: bool last_heartbeat: Optional[datetime] system_info: Optional[str] created_at: datetime updated_at: datetime class Config: from_attributes = True ``` - [ ] **步骤 3:servers.py 添加 POST /push 批量推送端点** 在 `server/api/servers.py` 末尾添加: ```python from pydantic import BaseModel as PydanticBase class PushRequest(PydanticBase): source_path: str server_ids: List[int] @router.post("/push") async def push_to_servers(req: PushRequest, db: Session = Depends(get_db)): """批量推送到多台服务器""" if not req.server_ids: raise HTTPException(status_code=400, detail="No servers selected") servers_list = db.query(Server).filter(Server.id.in_(req.server_ids)).all() if not servers_list: raise HTTPException(status_code=404, detail="No servers found") from server.services.sync_engine import push_to_server import asyncio tasks = [] for server in servers_list: target = server.target_path or "/" tasks.append(push_to_server(server, req.source_path, target)) results = await asyncio.gather(*tasks, return_exceptions=True) successes = sum(1 for r in results if isinstance(r, dict) and r.get("status") == "success") return { "total": len(servers_list), "success": successes, "failed": len(servers_list) - successes, "results": [ {"server_id": s.id, "server_name": s.name, **r} if isinstance(r, dict) else {"server_id": s.id, "server_name": s.name, "status": "error", "message": str(r)} for s, r in zip(servers_list, results) ], } ``` - [ ] **步骤 4:删除 agent.py 中 /trigger 端点** 修改 `server/api/agent.py`,删除 `TriggerPayload` 类和 `@router.post("/trigger")` 整个函数。 - [ ] **步骤 5:验证编译** ```bash python -m py_compile server/api/servers.py server/api/agent.py server/main.py && echo "OK" ``` - [ ] **步骤 6:Commit** ```bash git add server/api/ server/main.py git rm server/api/tasks.py 2>/dev/null git commit -m "refactor(api): 删除 tasks 路由,新增 POST /api/servers/push,清理 agent trigger" ``` --- ### 任务 3:服务层简化 — sync_engine 纯 push + 清理 ssh_direct + 删除 trigger_poller **文件:** - 修改:`server/services/sync_engine.py` - 修改:`server/services/ssh_direct.py` - 删除:`server/services/trigger_poller.py` - 修改:`server/main.py`(启动逻辑中删除 trigger_poller 引用) - [ ] **步骤 1:重写 sync_engine.py 为纯 push** ```python """Sync engine — push source directory to target server via rsync""" import asyncio from datetime import datetime from typing import Dict, Any from server.database import SessionLocal, SyncLog, Server from server.config import settings async def push_to_server(server: Server, source_path: str, target_path: str) -> Dict[str, Any]: """Push source_path to server:target_path via rsync over SSH""" db = SessionLocal() try: log = SyncLog( server_id=server.id, source_path=source_path, target_path=target_path, trigger_type="manual", status="running", started_at=datetime.utcnow(), ) db.add(log) db.commit() db.refresh(log) ssh_opts = f"-p {server.port} -o ConnectTimeout={settings.HEALTH_CHECK_TIMEOUT}" if not settings.SSH_STRICT_HOST_CHECKING: ssh_opts += " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" if server.auth_method == "key" and server.ssh_key_path: ssh_opts += f" -i {server.ssh_key_path}" cmd = [ "rsync", "-avz", "--stats", "-e", f"ssh {ssh_opts}", f"{source_path.rstrip('/')}/", f"{server.username}@{server.domain}:{target_path.rstrip('/')}/", ] start = datetime.utcnow() proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() end = datetime.utcnow() output = stdout.decode() err = stderr.decode() duration = int((end - start).total_seconds()) # 解析统计 import re files_total = files_transferred = bytes_transferred = 0 for line in output.split("\n"): if m := re.search(r'Number of files(?:\s+transferred)?:\s*([\d,]+)', line): if "transferred" in line: files_transferred = int(m.group(1).replace(",", "")) else: files_total = int(m.group(1).replace(",", "")) elif m := re.search(r'Total (?:transferred )?file size:\s*([\d,.]+)\s*bytes', line): bytes_transferred = int(m.group(1).replace(",", "").replace(".", "")) success = proc.returncode == 0 status = "success" if success else "failed" log.status = status log.files_total = files_total log.files_transferred = files_transferred log.files_skipped = files_total - files_transferred log.bytes_transferred = bytes_transferred log.duration_seconds = duration log.diff_summary = output[-2000:] if len(output) > 2000 else output log.error_message = err if err else None log.finished_at = end db.commit() summary = f"Transferred {files_transferred} files ({bytes_transferred} bytes) in {duration}s" return { "status": status, "message": summary if success else f"Failed: {err[:200]}", "log_id": log.id, "duration": duration, "files_transferred": files_transferred, "bytes_transferred": bytes_transferred, } except Exception as e: if 'log' in locals(): try: log.status = "failed" log.error_message = str(e) log.finished_at = datetime.utcnow() db.commit() except Exception: pass return {"status": "failed", "message": str(e)} finally: db.close() ``` 这完全替换原有的 `compute_diff`、`execute_sync`、`trigger_sync_task` 函数。 - [ ] **步骤 2:清理 ssh_direct.py — 删除触发文件轮询** 删除 `ssh_trigger_poll_loop` 函数、`check_trigger_file`、`remove_trigger_file`、`check_triggers_on_server`、`list_remote_dir` 函数,以及 `_trigger_locks`、`_trigger_lock_key` 变量和 import `Dict`。 保留:`ssh_health_check_loop`、`check_server_health_ssh`、`SSHConfig`、`get_ssh_config`、`create_ssh_client`、`ssh_connection`、`exec_command`。 - [ ] **步骤 3:删除 trigger_poller.py** ```bash rm server/services/trigger_poller.py ``` - [ ] **步骤 4:更新 main.py 启动逻辑** 修改 `server/main.py` 第 79-103 行,三种模式都删除 `trigger_poll_loop` 相关: ```python if SYNC_MODE == "ssh_direct": from server.services.ssh_direct import ssh_health_check_loop background_tasks.append(asyncio.create_task(ssh_health_check_loop())) elif SYNC_MODE == "agent": from server.services.health_checker import health_check_loop background_tasks.append(asyncio.create_task(health_check_loop())) else: from server.services.ssh_direct import ssh_health_check_loop from server.services.health_checker import health_check_loop background_tasks.append(asyncio.create_task(ssh_health_check_loop())) background_tasks.append(asyncio.create_task(health_check_loop())) ``` - [ ] **步骤 5:验证编译** ```bash python -m py_compile server/services/sync_engine.py server/services/ssh_direct.py server/main.py && echo "OK" ``` - [ ] **步骤 6:Commit** ```bash git add server/services/sync_engine.py server/services/ssh_direct.py server/main.py git rm server/services/trigger_poller.py git commit -m "refactor(services): sync_engine 简化为纯 push,删除 trigger_poller,清理 ssh_direct" ``` --- ### 任务 4:PHP API 客户端更新 **文件:** - 修改:`web/api_client.php` - 修改:`web/api_proxy.php` - [ ] **步骤 1:api_client.php 删 task 方法,加 pushServers** 删除:`getTasks`、`getTask`、`createTask`、`updateTask`、`deleteTask`、`triggerSync`、`getTaskDiff`、`getTaskLogs` 方法。 删除 `getDashboardStats` 中的 tasks 部分,简化为只统计 servers。 新增方法: ```php public function pushServers($sourcePath, $serverIds) { return $this->request('POST', '/api/servers/push', [ 'source_path' => $sourcePath, 'server_ids' => $serverIds, ]); } ``` - [ ] **步骤 2:api_proxy.php 删 task action,加 push** 删除 case:`get_tasks`、`get_task`、`trigger_sync`、`sync_all`、`get_diff`、`get_task_logs`、`get_logs`(logs 数据通过 sync_logs API 读取)。 新增 case: ```php case 'push_servers': $input = json_decode(file_get_contents('php://input'), true); $result = api()->pushServers($input['source_path'] ?? '', $input['server_ids'] ?? []); break; ``` - [ ] **步骤 3:Commit** ```bash git add web/api_client.php web/api_proxy.php git commit -m "refactor(php): API 客户端删除 task 方法,新增 pushServers" ``` --- ### 任务 5:前端 — servers.php 加入分类 + 目标路径 + 推送对话框 **文件:** - 修改:`web/servers.php` - [ ] **步骤 1:列表表头加分类和目标路径列** 在 servers.php 表格表头 `Agent端口` 后添加: ```php 分类 目标路径 ``` 在循环中对应位置添加: ```php ``` - [ ] **步骤 2:编辑表单加分类下拉和目标路径输入框** 在 servers.php 的 `
` 中,`description` 字段前添加: ```php
``` - [ ] **步骤 3:添加 "推送" 按钮 + 推送对话框 CSS/HTML/JS** 在 servers.php 的顶部操作区添加: ```php ``` 添加推送对话框样式(参考 tasks.php 的 .fm-overlay 风格,更紧凑): ```css .push-overlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.4); z-index:9999; align-items:center;justify-content:center; } .push-overlay.active { display:flex; } .push-dialog { background:#fff; border-radius:8px; width:90%; max-width:700px; max-height:85vh; overflow-y:auto; box-shadow:0 20px 50px rgba(0,0,0,0.3); } .push-dialog h3 { padding:16px 20px; margin:0; border-bottom:1px solid #e5e7eb; font-size:16px; } .push-body { padding:20px; } .push-body .src-row { display:flex; gap:8px; align-items:center; margin-bottom:16px; } .push-body .src-row input { flex:1; } .push-category { margin-bottom:12px; } .push-cat-title { font-weight:600; font-size:14px; padding:8px 0; cursor:pointer; display:flex; align-items:center; gap:6px; } .push-cat-title .count { font-weight:400; color:#9ca3af; font-size:12px; } .push-cat-title .cat-check { margin-left:auto; font-weight:400; font-size:12px; color:#3b82f6; cursor:pointer; } .push-server { display:flex; align-items:center; gap:8px; padding:6px 12px; margin-left:20px; font-size:13px; } .push-server label { flex:1; cursor:pointer; } .push-server .path { color:#6b7280; font-size:12px; } .push-footer { padding:12px 20px; border-top:1px solid #e5e7eb; display:flex; align-items:center; gap:12px; } .push-footer .btn-push { background:#22c55e; color:#fff; border:none; padding:10px 24px; border-radius:6px; font-weight:600; cursor:pointer; } .push-footer .btn-push:hover { background:#16a34a; } .push-footer .btn-push:disabled { background:#9ca3af; cursor:not-allowed; } .push-footer .summary { flex:1; font-size:13px; color:#6b7280; } ``` 推送对话框 HTML: ```html

推送文件

``` 推送 JS: ```javascript let pushServersData = []; function loadPushServers() { const API_BASE = ; const API_KEY = ; fetch(API_BASE + '/api/servers/', { headers: API_KEY ? {'X-API-Key': API_KEY} : {} }) .then(r => r.json()).then(data => { pushServersData = data.data || data; renderPushDialog(); }); } function renderPushDialog() { const grouped = {}; pushServersData.forEach(s => { const cat = s.category || '未分类'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(s); }); document.getElementById('pushTotalCount').textContent = pushServersData.length; let html = ''; for (const [cat, servers] of Object.entries(grouped)) { html += '
' + '' + esc(cat) + ' (' + servers.length + ' 台)' + '全选此分类' + '
'; servers.forEach(s => { html += '
' + '' + '' + '→ ' + esc(s.target_path || '/') + '' + '
'; }); html += '
'; } document.getElementById('pushCategoryList').innerHTML = html; } function openPushDialog() { document.getElementById('pushOverlay').classList.add('active'); loadPushServers(); } function closePushDialog() { document.getElementById('pushOverlay').classList.remove('active'); } function toggleAllServers() { const all = document.getElementById('pushSelectAll').checked; document.querySelectorAll('.server-check').forEach(cb => cb.checked = all); document.querySelectorAll('.cat-check-all').forEach(cb => cb.checked = all); updatePushCount(); } function toggleCategory(el) { const cat = el.dataset.cat; const checked = el.checked; document.querySelectorAll('.server-check[data-cat="' + esc(cat) + '"]').forEach(cb => cb.checked = checked); updatePushCount(); } function checkCategory(cat) { document.querySelectorAll('.server-check[data-cat="' + esc(cat) + '"]').forEach(cb => cb.checked = true); document.querySelector('.cat-check-all[data-cat="' + esc(cat) + '"]').checked = true; updatePushCount(); } function updatePushCount() { const checked = document.querySelectorAll('.server-check:checked').length; document.getElementById('pushSummary').textContent = '已选 ' + checked + ' 台'; document.getElementById('pushBtnCount').textContent = checked; document.getElementById('pushSelectAll').checked = checked === pushServersData.length; document.getElementById('btnDoPush').disabled = checked === 0; } function doPush() { const ids = Array.from(document.querySelectorAll('.server-check:checked')).map(cb => parseInt(cb.dataset.id)); if (!ids.length) return; const sp = document.getElementById('pushSourcePath').value.trim(); if (!sp) { alert('请输入源目录'); return; } document.getElementById('btnDoPush').disabled = true; document.getElementById('btnDoPush').textContent = '推送中...'; fetch('api_proxy.php?action=push_servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_path: sp, server_ids: ids }) }).then(r => r.json()).then(data => { const d = data.data || data; alert('推送完成: 成功 ' + (d.success || 0) + ' / 失败 ' + (d.failed || 0)); closePushDialog(); location.reload(); }).catch(e => { alert('推送失败: ' + e.message); document.getElementById('btnDoPush').disabled = false; document.getElementById('btnDoPush').innerHTML = '推送到 ' + ids.length + ' 台服务器'; }); } function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } ``` - [ ] **步骤 4:sidebar 导航删除 "同步任务" 链接,添加 "推送日志"** ```php
  • 推送日志
  • ``` 删掉 `
  • ...` 那一行。 - [ ] **步骤 5:Commit** ```bash git add web/servers.php git commit -m "feat(web): servers.php 加入分类/目标路径编辑 + 分类批量推送对话框" ``` --- ### 任务 6:tasks.php → push.php + index.php + logs.php 更新 **文件:** - 重命名:`web/tasks.php` → `web/push.php` - 修改:`web/index.php` - 修改:`web/logs.php` - 修改:`web/servers.php`(sidebar 链接更新) - [ ] **步骤 1:tasks.php 改为推送历史页** ```bash mv web/tasks.php web/push.php ``` 修改 `push.php`:删掉所有任务表单/编辑/添加/删除相关代码。保留表格结构,改为显示 SyncLog(推送历史): ```php $logs = []; $resp = api()->getAllLogs(100); $logs = $resp['data'] ?? []; ``` 表格列改为:ID、服务器、源目录、目标目录、状态、文件数、传输大小、耗时、时间。 - [ ] **步骤 2:index.php 仪表盘更新** 删除 "活跃同步任务" 卡片。新增 "推送目标服务器" 卡片(统计有 target_path 的服务器数)。 dashboard stats 简化为:服务器总数、在线服务器、离线服务器、已配置推送目标。 - [ ] **步骤 3:logs.php 简化** 删除 task_id 筛选,保留 server_id 和 status 筛选。 - [ ] **步骤 4:所有页面 sidebar 链接更新** 将 `` 改为 `推送日志`。 在 servers.php、index.php、push.php、logs.php、settings.php、deploy.php 的 sidebar 中统一修改。 - [ ] **步骤 5:Commit** ```bash git add web/ git commit -m "refactor(web): tasks.php→push.php 推送历史页,仪表盘/日志简化,sidebar 统一" ``` --- ### 任务 7:脚本和配置文件清理 **文件:** - 修改:`install.sh` - 修改:`deploy/install_central.sh` - 修改:`deploy/install_agent.sh` - 修改:`agent/install.sh` - 修改:`agent/agent.sh` - 修改:`agent/config.example.yaml` - 修改:`web/agent/install.sh` - 修改:`web/agent/agent.sh` - 修改:`web/deploy.php` - [ ] **步骤 1:agent 脚本删除 trigger 相关** `agent/agent.sh` 删除 `notify_central` 函数和 `watch_directory` 中对 `TRIGGER_FILE` 的检测逻辑。保留 heartbeat。 `agent/install.sh` 和 `web/agent/install.sh` 删除 `NEXUS_TRIGGER_FILE` 环境变量行。 `agent/config.example.yaml` 删除 `trigger_file` 字段。 - [ ] **步骤 2:install.sh 清理** 删除 `--db-path` 参数和 `DB_PATH` 变量。更新 help 文本删除 trigger/cron 相关说明。 - [ ] **步骤 3:deploy/install_central.sh 清理** 删除 trigger/sync 相关 echo 提示。 - [ ] **步骤 4:deploy/install_agent.sh 清理** 删除 `trigger_file` 配置项。 - [ ] **步骤 5:web/deploy.php 清理** 删除 `.sync-trigger` 命令示例文本。 - [ ] **步骤 6:Commit** ```bash git add agent/ deploy/ install.sh web/agent/ web/deploy.php git commit -m "chore: 清理 scripts/config 中的 trigger/task 引用" ``` --- ### 任务 8:最终验证和文档更新 **文件:** - 修改:`docs/CHANGELOG.md` - 修改:`README.md`(部分) - 修改:`宝塔部署指南.md`(部分) - [ ] **步骤 1:全量 Python 编译验证** ```bash find server -name "*.py" | xargs python -m py_compile && echo "All Python OK" ``` - [ ] **步骤 2:更新 CHANGELOG.md** 追加迭代 2 记录。 - [ ] **步骤 3:更新 README.md 架构图和特性列表** 删除 sync_tasks / trigger_file / cron 相关描述。更新系统架构图。 - [ ] **步骤 4:Commit** ```bash git add docs/ README.md 宝塔部署指南.md git commit -m "docs: 更新 CHANGELOG/README/宝塔指南 适配简化推送模型" ``` --- ## 自检 - [x] 规格全覆盖:数据模型 ✓ API ✓ 服务层 ✓ 前端 ✓ 脚本 ✓ 文档 ✓ - [x] 无占位符/TODO - [x] 类型一致性:`push_to_server(server, source_path, target_path)` 签名在 sync_engine 和 servers.py 一致 - [x] 每个任务都有精确文件路径和完整代码