# 脚本批量执行平台 — 技术说明（SSOT）

**最后更新**: 2026-05-22  
**状态**: 已实现，待 WSL / 外网 E2E 验证

## 1. 目标与约束

| 目标 | 实现方式 |
|------|----------|
| 2000+ 台批量执行 | 内置分批（默认 50 台/批），批内并发 10 |
| 长任务不阻塞 HTTP | `nohup` + 子机结束 `curl` 主站回调 |
| 短任务执行中可停止 | Agent `wait=false` → `pid` → `/exec/kill` |
| 减轻 MySQL 压力 | 执行态**只写 Redis**；MySQL **60s 分批刷盘** |
| 全链路可审计 | `audit_logs` 即时写入；`results._meta.events` 时间线 |
| 不用 WS 换票 / 异步 exec API | 同步 `POST /api/scripts/exec` + 页顶状态条轮询 |

**不可改 / 环境项**（`.env`）：

- `NEXUS_API_BASE_URL` — 长任务子机回调主站地址（必填，否则长任务 400）
- `NEXUS_SCRIPT_EXEC_BATCH_SIZE`（默认 50）、`NEXUS_SCRIPT_EXEC_CONCURRENCY`（默认 10）

**代码内常量**（`server/infrastructure/redis/script_execution_store.py`，不写 `.env`）：

| 常量 | 值 | 含义 |
|------|-----|------|
| `REDIS_TTL_SECONDS` | 7200 | 热数据最长 2h |
| `FLUSH_INTERVAL_SECONDS` | 60 | Redis→MySQL 刷盘周期 |
| `FLUSH_BATCH_LIMIT` | 50 | 每轮最多刷 50 条执行记录 |
| `STUCK_NO_PROGRESS_SECONDS` | 3600 | 卡住检测阈值（1h） |

## 2. 架构与数据流

```mermaid
flowchart LR
  UI[scripts.html / script-executions.html]
  API[POST /api/scripts/exec]
  SVC[ScriptService]
  Redis[(Redis script_exec:id)]
  Flush[script_execution_flush 60s]
  MySQL[(script_executions)]
  Agent[子机 Agent :8800]
  CB[POST /api/agent/script-callback]

  UI --> API --> SVC
  SVC --> Redis
  SVC --> Agent
  Agent -->|长任务结束 curl| CB --> Redis
  Flush --> MySQL
  UI -->|GET executions/id| Redis
  UI -.->|历史列表可能滞后| MySQL
```

### 2.1 Redis 热数据

- Key: `script_exec:{execution_id}` — JSON 全量快照（status、results、events、operator…）
- 队列: `script_exec:flush_queue` — 待刷 MySQL 的 id 集合
- 长任务回调: `script_job:{job_id}` — 一次性 secret（TTL 7 天，见 `script_jobs.py`）

### 2.2 MySQL 冷数据

- 表: `script_executions`
- `results` JSON：`{ "<server_id>": { ... }, "_meta": { "events": [...] } }`
- 刷盘后从 flush 队列移除；读接口**优先 Redis**，无 Redis 再读 MySQL

### 2.3 进度字段

- API 字段 `progress`: `"done/total"`（`pending` 计为未完成）
- 实现: `execution_progress_summary()` in `script_execution_store.py`

## 3. 执行模式

### 3.1 短任务（默认）

1. 主站 `POST` 子机 Agent `/exec` + `wait: false` → 得 `pid`，**立即** `patch_live` 写入 Redis（`phase: pending`）
2. 主站 `POST` `/exec/wait` 等待结束
3. 用户点「停止」→ 主站 `POST` `/exec/kill`（需**新版 Agent**）
4. 未升级 Agent：回退同步 `/exec` `wait=true`，**无法**中途停止

### 3.2 长任务（`long_task: true`）

1. 命令存库前缀 `[long_task] `
2. `script_jobs.build_long_task_command()`：`nohup` + 日志 `/var/log/nexus-job/{job_id}.log` + 结束 curl 主站
3. 点火成功 → `phase: pending` + `job_id` + `started_at`
4. 子机 `POST /api/agent/script-callback` → 更新 results + WebSocket `script_job_done`
5. 停止：kill `${LOG}.pid` + 撤销 Redis job secret

## 4. HTTP API（JWT，除回调）

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/scripts/exec-config` | `batch_size`, `concurrency` |
| POST | `/api/scripts/exec` | `server_ids`, `command`, `timeout`, `long_task`, `credential_id?` |
| GET | `/api/scripts/executions` | 分页历史 `limit`, `offset`, `status` |
| GET | `/api/scripts/executions/{id}` | `?fetch_logs=true` 拉子机 tail 日志 |
| POST | `/api/scripts/executions/{id}/stop` | 停止 pending |
| POST | `/api/scripts/executions/{id}/retry` | 失败/未完成重试；**新建** `execution_id`，响应含 `parent_execution_id` |
| POST | `/api/scripts/executions/{id}/mark-stuck` | 人工标记卡住 |
| POST | `/api/agent/script-callback` | `job_id`, `secret`, `server_id`, `exit_code`（无 JWT；Redis 限速） |

## 4.1 安全与执行面

| 风险 | 现状 | 缓解 |
|------|------|------|
| 子机 `subprocess_shell` / 长任务 `bash -c` | 运维必需能力 | 仅 API Key 可调 `/exec`；主站 `check_dangerous_command` **拒绝**危险模式；Agent 每次 exec 打 WARNING 审计日志 |
| 回调 HTTP 明文 | 生产配置错误 | `master_callback_url()` 非 localhost 强制 `https://` |
| 回调刷接口 | 无 JWT | `script_callback_rate`：每 job 10/min、每 IP 60/min |
| 重试语义 | 每次 retry 新建记录 | API 返回 `parent_execution_id` 关联原单；历史页按 id 追踪 |

子机应网络隔离：仅中心机 IP 可访问 Agent `:8800`。

## 5. 子机 Agent 接口

文件: `web/agent/agent.py`

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/exec` | `wait` 默认 true；false 返回 `pid` |
| POST | `/exec/wait` | 等待 `pid` |
| POST | `/exec/kill` | 终止 `pid` |

**部署**：各子机更新 Agent 后重启；否则短任务 stop 不可用。

## 6. 后台任务

- 模块: `server/background/script_execution_flush.py`
- 注册: `server/main.py` → `script_execution_flush_loop()`（primary worker）
- 每 60s：
  1. `detect_stuck_executions()` — 单台 pending 超 1h → `server_auto_stuck`；整单无更新 1h → `auto_stuck`
  2. `flush_pending_batch()` — 最多 50 条写入 MySQL

## 7. 审计（`audit_logs.action`）

| action | 触发场景 |
|--------|----------|
| `execute_started` | 创建执行记录 |
| `execute_command` | 记录实际下发命令摘要 |
| `stop_execution` | 用户停止 |
| `retry_execution` | 用户重试 |
| `mark_stuck` | 用户标记卡住 |
| `script_job_callback` | 长任务子机回调 |
| `auto_stuck` | 整单 1h 无 Redis 更新 |
| `server_auto_stuck` | 单台 pending 超 1h |

前端筛选: `web/app/audit.html` 已增加上述选项。

## 8. 前端页面

| 页面 | 路径 | 功能 |
|------|------|------|
| 脚本库 | `web/app/scripts.html` | 执行弹窗、分批、长任务勾选、页顶「批量执行状态」 |
| 执行历史 | `web/app/script-executions.html` | 列表、进度、停止/重试/卡住、详情+日志、10s 轮询 |
| 侧栏 | `web/app/layout.js` | 菜单项「执行历史」 |

页顶状态条按钮：刷新状态、状态+日志、停止、重试、标记卡住。

## 9. 涉及文件清单

### 后端

| 文件 | 职责 |
|------|------|
| `server/application/services/script_service.py` | 分批执行、Agent 调用、stop/retry/list |
| `server/application/services/script_jobs.py` | 长任务 nohup + job Redis |
| `server/application/services/script_job_callback.py` | 回调合并结果 |
| `server/infrastructure/redis/script_execution_store.py` | Redis 热数据、刷盘队列、卡住检测、progress |
| `server/background/script_execution_flush.py` | 60s 循环 |
| `server/infrastructure/database/script_repo.py` | `list_recent`, `update_status` |
| `server/api/scripts.py` | REST 路由 |
| `server/api/agent.py` | `script-callback` |
| `server/config.py` | `SCRIPT_EXEC_BATCH_SIZE`, `SCRIPT_EXEC_CONCURRENCY` |
| `server/main.py` | 注册 flush 后台任务 |

### 前端 / Agent

| 文件 | 职责 |
|------|------|
| `web/app/scripts.html` | 执行 UI + 状态条 |
| `web/app/script-executions.html` | 历史页 |
| `web/app/layout.js` | 侧栏 |
| `web/app/audit.html` | 审计筛选 |
| `web/agent/agent.py` | exec / wait / kill |

## 10. 已知限制

1. MySQL 视图比 Redis **最多晚 60s**（直接查库时）；页面轮询读 Redis 无影响。
2. 短任务 stop 依赖子机 Agent 新版本。
3. 自动卡住：单台按 `started_at`；整单按 `updated_at`（非单台日志活跃度）。
4. 危险命令仍由 `check_dangerous_command` 拦截。
5. 长任务依赖 `NEXUS_API_BASE_URL` 对子机可达，且生产须为 HTTPS（本地可用 `http://localhost`）。
6. **重试**会创建新的 `script_executions.id`；原单结果保留，通过 `parent_execution_id` 关联。

## 11. 测试清单（待执行）

- [ ] T-SE1: 选 3 台短任务，页顶进度 `3/3`，审计有 `execute_started`
- [ ] T-SE2: 选 60 台，确认分 2 批、合并一条状态卡
- [ ] T-SE3: 长任务 + 配置 `NEXUS_API_BASE_URL`，回调后状态变完成、WS 通知
- [ ] T-SE4: 长任务执行中「停止」，子机进程结束
- [ ] T-SE5: 新版 Agent 短任务执行中「停止」
- [ ] T-SE6: 执行历史页停止/重试/状态+日志
- [ ] T-SE7: 等待或调低 `STUCK_NO_PROGRESS_SECONDS` 验证 `server_auto_stuck` / `auto_stuck`
- [ ] T-SE8: Redis 有 key 时 MySQL 在 60s 内出现对应行

## 12. 相关 Changelog

| 文档 | 内容 |
|------|------|
| [2026-05-22-script-execution-platform.md](../changelog/2026-05-22-script-execution-platform.md) | 本次交付总览与文件变更索引 |
| [2026-05-22-script-exec-batching.md](../changelog/2026-05-22-script-exec-batching.md) | 内置分批与 exec-config |
| [2026-05-22-script-job-callback.md](../changelog/2026-05-22-script-job-callback.md) | 长任务回调与 Redis 刷盘摘要 |
