diff --git a/docs/deployment/env-version-matrix.md b/docs/deployment/env-version-matrix.md new file mode 100644 index 0000000..a7c4231 --- /dev/null +++ b/docs/deployment/env-version-matrix.md @@ -0,0 +1,148 @@ +# 三机环境版本矩阵(Mac 开发 / NAS 容器 / Windows VPS) + +> 创建:2026-07-14。目的:记录三机 Python + 关键依赖版本现状,暴露不一致,给出 Lock 建议。 +> 数据来源:Mac venv311 + NAS 容器均经实地 `pip list` 核实;VPS 基于部署文档 `vps-production-runbook.md`,未本轮直接 SSH 复核(SSH 不通)。 + +--- + +## 1. 机器角色一览 + +| 机器 | 角色 | Python | 虚拟环境 / 路径 | 数据来源 | +|------|------|--------|-----------------|----------| +| **Mac Mini** | 开发 + 测试 | 3.11.15(venv311) | `./venv311/`(项目内) | 实地 `pip list` | +| Mac 系统 Python | 不参与项目 | 3.14.6(homebrew)/ 3.9.6(/usr/bin) | — | `python3 --version` | +| **NAS 容器** `sanguo_vnpy_v2` | 测试 + 回测 + 采集(生产近邻) | 3.10.20 | `/app`(容器内)= NAS `/volume1/homes/admin/.sanguo_projects/sanguo_vnpy_v2` | 实地 `docker exec ... pip list` | +| **Windows VPS** `49.232.102.198` | 生产(miniQMT + bridge) | 3.10.11 | `C:\Python310\python.exe` | 部署文档(本轮未直连) | + +--- + +## 2. 关键依赖版本矩阵 + +> 版本号 = `pip show` 的 Version 字段;❌ = 未安装;🚫 = 平台不兼容(Windows only);🔍 = 仅有源码引用(`sys.path.insert`)未 pip install。 + +| 依赖 | Mac venv311 (3.11.15) | NAS 容器 (3.10.20) | Windows VPS (3.10.11) | 备注 | +|------|----------------------|--------------------|-----------------------|------| +| **python** | 3.11.15 | 3.10.20 | 3.10.11 | ⚠️ Mac 比 prod 高一个小版本 | +| **vnpy** | 🔍 源码引用(`vnpy_v4.4.0/`) | 🔍 源码引用(`/app/vnpy_v4.4.0`)+ `vnpy 4.4.0` pip metadata | ❌ 不装(VPS 只跑 bridge) | Mac 容器均靠 `sys.path.insert` 引用源码,**不 pip install vnpy** | +| vnpy_ctastrategy | ❌ | 1.4.1 | ❌ | | +| vnpy_sqlite | ❌ | 1.1.3 | ❌ | | +| **fastapi** | 0.139.0(本次新装) | 0.139.0 | ✅ 装了(文档未给版本) | 一致 | +| **uvicorn** | 0.51.0(本次新装) | 0.49.0 | ✅ 装了 | 小版本差 | +| **akshare** | ❌ | ❌ | ❌ | 三机都没装;日线采集走 NAS 宿主系统 python(akshare 在 NAS 宿主,不在容器里) | +| **baostock** | 0.9.3 | 0.9.3 | ❌ | Mac/NAS 一致 | +| **polars** | 1.42.1(本次新装) | 1.42.1 | ❌ | Mac/NAS 一致;Mac 此前缺失致 collect-only 失败 | +| **xtquant** | 🚫 Windows only | 🚫 Windows only | ✅(miniQMT site-packages) | VPS 专属,靠 miniQMT 客户端 | +| **pandas** | 3.0.3 | 2.3.3 | ? | 🔴 **major 版本分裂**(Mac=3.x / NAS=2.x) | +| **numpy** | 1.26.4 | 2.2.6 | ? | 🔴 **major 版本分裂**(Mac=1.x / NAS=2.x) | +| **pyarrow** | 25.0.0 | 24.0.0 | ❌ | 小版本差 | +| **PyJWT** | 2.13.0(本次新装) | 2.13.0 | ❌ | 一致 | +| **bcrypt** | 5.0.0(本次新装) | 5.0.0 | ❌ | 一致 | +| **TA-Lib** | ❌ | 0.6.8 | ❌ | Mac 缺(需 brew install ta-lib) | +| **pyzmq** | ❌ | 27.1.0 | ❌ | Mac 缺 | +| **PySide6** | ❌ | 6.8.2.1 | ❌ | Mac 缺(GUI 依赖,dev 可选) | +| **SQLAlchemy** | ❌ | 2.0.51 | ❌ | Mac 缺 | +| **redis** | ❌ | 8.0.1 | ❌ | Mac 缺 | +| **APScheduler** | ❌ | 3.11.3 | ❌ | Mac 缺 | +| **deap** | ❌ | 1.4.4 | ❌ | Mac 缺(遗传算法,回测用) | +| **plotly** | ❌ | 6.8.0 | ❌ | Mac 缺 | +| **scipy** | 1.17.1 | 未列(应已装) | ❌ | Mac 有 | +| **pytest** | 9.1.1 | 未列 | ❌ | dev 工具 | + +--- + +## 3. 当前发现的不一致(按严重度) + +### 🔴 CRITICAL —— 可能在 Mac 跑通但在 prod 静默踩坑 + +1. **pandas major 版本分裂**:Mac venv311 = **pandas 3.0.3**,NAS 容器 = **pandas 2.3.3**。 + - pandas 3.0 有大量破坏性变更(默认 dtype、`chained assignment`、`SettingWithCopyWarning` 升级为异常等)。 + - 在 Mac 过的测试,到 NAS 容器可能因为 pandas 3→2 的 API 差异失败或行为不同。 + - **这是最危险的不一致**:单测绿不代表行为一致。 + +2. **numpy major 版本分裂**:Mac venv311 = **numpy 1.26.4**,NAS 容器 = **numpy 2.2.6**。 + - numpy 2.0 有 breaking change(部分标量类型 Promotion 规则改变、`np.float_` 移除等)。 + - 方向与 pandas 相反:Mac 反而比 prod 旧。 + +3. **Python minor 分裂**:Mac venv311 = **3.11.15**,NAS/VPS = **3.10.x**。 + - 影响有限但存在(如 `match` 语法、`tomllib`、`ExceptionGroup`、类型 Hint 差异)。 + - `pyproject.toml` 已声明 `requires-python = ">=3.10"`,理论上 3.11 合法,但偏离 prod。 + +### 🟡 WARNING —— Mac venv311 依赖不完整(已知) + +4. **venv311 缺核心运行时依赖**(本轮未补,按任务约束只做 collect-only + trader 子集): + - `TA-Lib`(需先 `brew install ta-lib`,C 库依赖) + - `pyzmq` / `SQLAlchemy` / `redis` / `APScheduler` / `deap` / `plotly` / `PySide6` + - `akshare`(Mac 完全没装,日线采集靠 NAS 宿主 python) + - `vnpy`(源码引用,故意不 pip install —— 容器也是源码引用) + +5. **VPS 信息未本轮 SSH 复核**:49.232.102.198:22 Connection closed,本轮失败。文档记录的版本来自 `vps-production-runbook.md`,标注日期前的核实结果。 + +### 🟢 INFO —— 可接受的小差异 + +6. **polars/pandas/pyarrow 小版本 drift**(pyarrow 25 vs 24、uvicorn 0.51 vs 0.49):patch/minor 差异,影响小。 + +--- + +## 4. Lock 建议(推荐方案,不执行) + +### 推荐基线:对齐 NAS 容器(最稳定的生产近邻) + +> 理由:NAS 容器是当前依赖最齐、跑得最稳的环境;VPS 只跑 bridge 子集,依赖面窄;Mac 是开发机,应模拟 prod 而不是超前。Python 3.10 是三机唯一交集。 + +| 维度 | 推荐基线(= NAS 当前) | 落地动作(Mac 侧) | +|------|----------------------|-------------------| +| Python | **3.10.x**(容器 3.10.20 / VPS 3.10.11) | 重建 venv:`python3.10 -m venv venv310`(用 homebrew `python@3.10`),废弃 `venv311` | +| pandas | **2.3.x**(NAS 2.3.3) | `pip install 'pandas>=2.3,<3'`(**锁 <3**) | +| numpy | **2.2.x**(NAS 2.2.6) | `pip install 'numpy>=2.2,<3'` | +| polars | **1.42.x** | 已对齐 | +| pyarrow | **24.x** | `pip install 'pyarrow>=24,<25'` | +| fastapi / uvicorn / PyJWT / bcrypt | 当前版本即可 | 已对齐 | + +### 落地步骤(建议,不在本任务范围) + +1. **生成 lockfile**:在 NAS 容器内跑 `pip freeze > requirements-lock.txt`,作为三机共享 lock 基线。 +2. **Mac 重建 venv310**: + ```bash + brew install python@3.10 + /opt/homebrew/bin/python3.10 -m venv venv310 + ./venv310/bin/pip install -r requirements-lock.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + # 不含 vnpy(源码引用)/ PySide6(可选 GUI)/ xtquant(Windows only)/ akshare(可选,按需) + ``` +3. **加 requirements 约束**:在 `pyproject.toml` 的 `dependencies` 加上限: + ```toml + "pandas>=2.3,<3", # 避免 pandas 3.x breaking change + "numpy>=2.2,<3", + ``` +4. **CI 校验**:在 Gitea Actions 加一步 `pip check` + import 探针,防止 drift 再发生。 +5. **VPS 不动**:VPS 只跑 `sanguo_qmt_bridge`(依赖面 = fastapi + uvicorn + xtquant),与主项目 lock 解耦。 + +### 不推荐的方向 + +- ❌ **把 prod 升到 pandas 3 / numpy 2 / Python 3.11 来"追平" Mac**:prod 是稳定优先,3.0 新 major 须先在测试库充分回归。 +- ❌ **在 Mac venv311 补装 vnpy**:vnpy 源码引用是项目既定设计(容器也是源码引用),pip install 会引入版本冲突。 + +--- + +## 5. 本轮修复记录(2026-07-14) + +为让 `pytest --collect-only` 0 错误通过,在 venv311 内新装: + +| 包 | 版本 | 原因 | +|----|------|------| +| polars + polars-runtime-32 | 1.42.1 | `tests/factor/test_data_adapter.py` collect 失败;polars 是 `pyproject.toml [alpha]` 真实依赖 | +| fastapi | 0.139.0 | `tests/api/*` collect 失败(`No module named 'fastapi'`) | +| uvicorn[standard] | 0.51.0 | fastapi.testclient 间接需要 | +| PyJWT | 2.13.0 | `sanguo_api/auth.py` `import jwt` | +| bcrypt | 5.0.0 | `sanguo_api/auth.py` `import bcrypt` | + +结果:`pytest --collect-only -q` 从 `380 collected + 1 error` → **`384 collected, 0 errors`**。 +`pytest tests/trader/ tests/data_platform/` → **228 passed, 0 failed**。 + +--- + +## 6. 待办(跟踪项) + +- [ ] VPS SSH 复核(本轮 22 端口 Connection closed,下一轮巡检补实测版本) +- [ ] pandas 3.x 锁上限决策(见 §4 步骤 3) +- [ ] venv310 重建计划排期 +- [ ] requirements-lock.txt 生成(从 NAS 容器 freeze) diff --git a/docs/deployment/nas-deploy-plan.md b/docs/deployment/nas-deploy-plan.md index 7c8260a..c3cd509 100644 --- a/docs/deployment/nas-deploy-plan.md +++ b/docs/deployment/nas-deploy-plan.md @@ -47,13 +47,29 @@ DOCKER="/var/packages/Docker/target/usr/bin/docker" SRC=~/.openclaw/sanguo_projects/sanguo_vnpy_v2/ DEST=sanguo-nas:/volume1/homes/admin/.sanguo_projects/sanguo_vnpy_v2/ -# 1) 同步代码(已排除数据/缓存/配置——配置含部署态密码,不覆盖) +# 1) 同步代码(已排除数据/缓存/venv/entrypoint/配置——配置含部署态密码,不覆盖) +# ⚠️ 每个 pattern 必须单独一个 --exclude=PATTERN(见下方警告框) rsync -avz --delete \ - --exclude='.git' '__pycache__' '*.pyc' '.pytest_cache' \ - --exclude='data/' 'logs/' 'temp/' '*.log' '.DS_Store' '.venv/' 'node_modules/' 'config/' \ + --exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='.pytest_cache' \ + --exclude='data/' --exclude='data_cache/' --exclude='logs/' --exclude='temp/' \ + --exclude='*.log' --exclude='.DS_Store' \ + --exclude='.venv/' --exclude='venv/' --exclude='venv311/' \ + --exclude='node_modules/' --exclude='config/' \ + --exclude='entrypoint.sh' \ -e ssh \ "$SRC" "$DEST" +``` +> ⚠️ **rsync --exclude 写法警告(2026-07-14 dry-run 实测固化)** +> +> 旧写法 `--exclude='.git' '__pycache__' '*.pyc' ...`(一个 `--exclude` 后紧挨多个 bare pattern)**实测排除失效**——rsync 把 bare pattern 当**源路径**处理(报 `lstat: No such file or directory`),`--delete` 因此会误删 NAS 上: +> - `data_cache/` 全量 staging parquet(**万级文件,dev/NAS 差约 1 万个**) +> - `/app/entrypoint.sh`(容器 Entrypoint,删除后 `docker restart` 启动失败) +> - 以及 `venv/` 等 dev-only 目录被误推上去 +> +> **必须**用每 pattern 单独 `--exclude=PATTERN` 写法(如上命令)。每次改排除列表后建议先 `rsync -avzn ...`(dry-run)确认 `deleting` 列表无意外项再实跑。 + +```bash # 2) 重启容器 ssh sanguo-nas "$DOCKER restart sanguo_vnpy_v2" @@ -69,6 +85,24 @@ curl -s http://192.168.2.154:8000/api/v1/auth/login -X POST \ | `ssh sanguo-nas "$DOCKER ps"` | `Up` | | `POST /api/v1/auth/login` | 返回 `{"token":...}` | +### 附:`entrypoint.sh` 根因说明(2026-07-14 dry-run + docker inspect 实证) + +`docker inspect sanguo_vnpy_v2` 显示容器 `Entrypoint=[/app/entrypoint.sh]`、`Cmd=[]`, +即容器启动**必须**找到 `/app/entrypoint.sh`。但 dev 仓库根目录已无此文件(已移到 +`docker/entrypoint.sh`)。若 rsync 带 `--delete` 且不排除,会删掉 NAS 上的 +`/app/entrypoint.sh` → 下次 `docker restart` 直接启动失败(entrypoint not found)。 + +- **短期保护(已落实)**:上方步骤 1 命令加了 `--exclude='entrypoint.sh'`,NAS 现有文件 + 不会被删,容器可继续启动。代价:dev 侧对 entrypoint 的改动不会自动同步(需手动处理)。 +- **根治方案(待用户确认采用哪种)**: + + | 方案 | 做法 | 适用 | + |------|------|------| + | ① 纳入 git 根目录版本化 | dev 根目录恢复 `entrypoint.sh`(与 `docker/entrypoint.sh` 统一为同一份),去掉 `--exclude='entrypoint.sh'`,rsync 正常同步 | entrypoint 需随代码迭代频繁改 | + | ② 由镜像层提供 | 确认 `entrypoint.sh` 由 Dockerfile `COPY` 进镜像;则 bind-mount 不应覆盖它(调整挂载/文件位置) | entrypoint 极少改、希望与代码解耦 | + + > ⚠️ 两种方案互斥。当前默认走"短期保护",**待用户确认**后再切到 ① 或 ②。 + --- ## 四、依赖变更(改 requirements-docker.txt)— 偶尔 diff --git a/docs/deployment/vps-production-runbook.md b/docs/deployment/vps-production-runbook.md new file mode 100644 index 0000000..419c357 --- /dev/null +++ b/docs/deployment/vps-production-runbook.md @@ -0,0 +1,400 @@ +# VPS 生产运维 Runbook + +> **sanguo QMT bridge 生产环境运维手册。** VPS 已部署并在运行,本文档是"运维 + 已验证状态记录",不是从零搭建指南。 +> +> 相关文档: +> - bridge 接口契约:[`sanguo_qmt_bridge/README.md`](../../sanguo_qmt_bridge/README.md) +> - 首次部署参考(历史):[`d-phase-windows-deploy.md`](./d-phase-windows-deploy.md) / [`windows-bridge-setup.md`](./windows-bridge-setup.md) +> - NAS 容器运维:[`nas-deploy-plan.md`](./nas-deploy-plan.md) +> +> **安全约定**:本文档进 git,**不硬编码 BRIDGE_TOKEN**。所有命令中 `` 为占位符,真实值见 Claude memory `windows-vps-access.md` 或 VPS 系统环境变量 `BRIDGE_TOKEN`。 + +--- + +## 1. 已验证生产状态(2026-07-14 巡检) + +> 以下事实经实地验证,直接采纳。下次巡检时更新日期并重新核实。 + +| 项目 | 值 | 备注 | +|------|-----|------| +| **VPS** | `49.232.102.198` | 腾讯云轻量,Win Server 2022,4C/16G/180G SSD | +| **SSH** | `ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198` | Mac ~/.ssh/id_ed25519 免密 | +| **磁盘** | C: 24G 用 / 156G 空闲 | 充裕 | +| **Python** | `C:\Python310\python.exe` (3.10.11) | xtquant import OK | +| **miniQMT** | `C:\国金QMT交易端\` userdata `userdata_mini` | 模拟账户 `66639661` 已登录 | +| **bridge 代码** | `C:\sanguo_qmt_bridge\` | FastAPI + uvicorn :8765 | +| **bridge 启动脚本** | `C:\run_bridge.ps1` | 设环境变量 + BRIDGE_SESSION_ID + uvicorn | +| **Caddy** | `C:\caddy\` + `C:\run_caddy.ps1` | :443 → :8765 反代,Let's Encrypt 自动续期 | +| **schtasks** | `sanguo-bridge` + `sanguo-caddy` 均 Running | SYSTEM 账户 / onstart 自启 | + +### 进程状态 + +| 进程 | 映像名 | 角色 | +|------|--------|------| +| XtMiniQmt | `XtMiniQmt.exe` | miniQMT 客户端,已登录模拟账户 | +| python | `python.exe` | uvicorn bridge :8765 | +| caddy | `caddy.exe` | HTTPS 反代 :443 → :8765 | + +### bridge 端点验证(VPS 本地 127.0.0.1:8765) + +| 端点 | 响应 | +|------|------| +| `GET /health` | `{"status":"ok","miniqmt_connected":true}` | +| `GET /account` | `{"ok":true,"cash":9997075.51,"frozen":9001769.0,"market_value":2901.0,"total":9999978.51}` | +| `GET /positions` | `{"ok":true,"positions":[...sh600000(200股), sz000001(100股)...]}` | + +### 三条访问路径 + +| # | 路径 | 地址 | 场景 | +|---|------|------|------| +| ① | VPS 本地 | `http://127.0.0.1:8765` | 运维 RDP/SSH 内验证 | +| ② | 公网 HTTPS | `https://bridge.mysanguo.top` | 外部客户端(Mac 浏览器等)| +| ③ | NAS 容器经 Mac 隧道 | `http://192.168.2.101:8765` | NAS sanguo 容器调 bridge | + +--- + +## 2. 拓扑图 + +``` +┌───────────────────────────────────────────────────┐ +│ Windows VPS · 49.232.102.198 │ +│ Win Server 2022 · 4C/16G/180G SSD │ +│ │ +│ ┌───────────┐ xtquant ┌──────────────┐ │ +│ │ miniQMT │◄──────────│ bridge │ │ +│ │ 66639661 │ │ :8765 │ │ +│ └───────────┘ └──────┬───────┘ │ +│ │ 反代 │ +│ ┌──────▼───────┐ │ +│ │ Caddy │ │ +│ │ :443 │ │ +│ │ Let's Encr. │ │ +│ └──────┬───────┘ │ +└──────────────────────────────────┼────────────────┘ + │ + ┌────────────────────────┼───────────────────┐ + │ │ │ + ① VPS 本地 ② 公网 HTTPS ③ NAS→Mac→VPS + http://127.0.0.1:8765 https://bridge. http://192.168.2.101 + (运维 RDP/SSH) mysanguo.top :8765 + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────────┐ ┌──────────────┐ + │ VPS 终端 │ │ 外部客户端 │ │ Mac Mini │ + └──────────┘ │(Mac 浏览器等) │ │ 192.168.2.101│ + └──────────────┘ │ 开发 + 隧道 │ + │ │ + │ ssh -N -L │ + │ 0.0.0.0:8765:│──SSH:22──► VPS + │ 127.0.0.1:8765│ + │ │ + │ caffeinate │ + │ -i -s 防睡眠 │ + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ NAS 容器 │ + │ 192.168.2.154│ + │ sanguo_vnpy │ + │ _v2 (测试+备) │ + └──────────────┘ + + ⚠ 华为光猫拦截 NAS→VPS 的 80/443 → NAS 不能走路径② + → NAS 容器走路径③:HTTP 到 Mac :8765 → Mac SSH 隧道 → VPS :8765 + → Mac 需常驻 SSH tunnel + caffeinate -i -s 防睡眠 +``` + +### 三机分工 + +| 机器 | IP | 角色 | 能做什么 | 不能做什么 | +|------|-----|------|----------|-----------| +| **VPS** | 49.232.102.198 | 生产 | miniQMT + bridge + Caddy 全链路 | 无开发环境 | +| **Mac Mini** | 192.168.2.101 | 开发 + 隧道中继 | 写代码、git、scp 部署、SSH 隧道 | 跑不了 xtquant(Windows only) | +| **NAS** | 192.168.2.154 | 测试 + 备份 | Docker 容器跑集成测试、回测、每日备份 | 无法直连 VPS 80/443(光猫拦截) | + +--- + +## 3. 日常运维操作 + +> 以下命令均从 **Mac Mini** 执行,通过 SSH 远程操控 VPS。 + +### 3.1 健康巡检(一条命令验三端点) + +**快速健康(无需 token):** + +```bash +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 \ + "curl -s http://127.0.0.1:8765/health" +``` + +期望:`{"status":"ok","miniqmt_connected":true}` + +**完整三端点验证(含 account + positions,需 token):** + +复杂引号场景用 stdin 喂 PowerShell(见 [§8 Windows 坑](#8-windows-跑命令的坑)): + +```bash +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 "powershell -Command -" <<'PS1' +Write-Host "=== health ===" +curl.exe -s http://127.0.0.1:8765/health +Write-Host "`n=== account ===" +curl.exe -s -H "X-Bridge-Token: " http://127.0.0.1:8765/account +Write-Host "`n=== positions ===" +curl.exe -s -H "X-Bridge-Token: " http://127.0.0.1:8765/positions +PS1 +``` + +**检查 schtasks 状态 + 进程 + 磁盘:** + +```bash +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 "powershell -Command -" <<'PS1' +Write-Host "=== schtasks ===" +schtasks /query /tn "sanguo-bridge" /fo list | Select-String "Status" +schtasks /query /tn "sanguo-caddy" /fo list | Select-String "Status" +Write-Host "=== processes ===" +Get-Process python,caddy,XtMiniQmt -ErrorAction SilentlyContinue | Format-Table Name,Id,CPU -Auto +Write-Host "=== disk ===" +Get-PSDrive C | Format-Table Used,Free -Auto +PS1 +``` + +### 3.2 bridge 重启(必须换 BRIDGE_SESSION_ID) + +> **关键**:xtquant 的 `connect()` 复用旧 session 会返回 -1。每次重启 bridge 前必须换 `BRIDGE_SESSION_ID`,否则 bridge 起来但 miniQMT 连不上。 + +**步骤:** + +1. **改 session_id** — RDP 或 SSH 编辑 `C:\run_bridge.ps1`,把 `$env:BRIDGE_SESSION_ID` 改为新值(如日期递增 `20260714` → `20260715` 或加后缀 `20260714b`): + +```bash +# SSH 在线编辑(PowerShell 替换) +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 "powershell -Command -" <<'PS1' +$path = "C:\run_bridge.ps1" +$content = Get-Content $path -Raw +# 把旧 session_id 替换为今天的(按实际值调整正则) +$newId = Get-Date -Format "yyyyMMddHHmm" +$content = $content -replace 'BRIDGE_SESSION_ID\s*=\s*"\d+"', "BRIDGE_SESSION_ID = `"$newId`"" +Set-Content $path -Value $content -Encoding UTF8 +Write-Host "session_id updated to $newId" +# 确认 +Select-String -Path $path -Pattern "BRIDGE_SESSION_ID" +PS1 +``` + +2. **重启 schtask:** + +```bash +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 \ + "schtasks /end /tn \"sanguo-bridge\" & schtasks /run /tn \"sanguo-bridge\"" +``` + +3. **等 5 秒后验证健康**(同 §3.1 快速健康命令)。 + +### 3.3 Caddy 重启 + +Caddy 无 session 状态问题,直接重启: + +```bash +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 \ + "schtasks /end /tn \"sanguo-caddy\" & schtasks /run /tn \"sanguo-caddy\"" +``` + +验证公网: + +```bash +curl -s https://bridge.mysanguo.top/health +``` + +### 3.4 miniQMT 崩溃恢复(human-gated) + +> miniQMT 是券商客户端 GUI 程序,崩溃后需要人工 RDP 登录。**无法自动化。** + +**症状**:`/health` 返回 `{"miniqmt_connected": false}`,或 bridge 日志 xtquant connect 报错。 + +**恢复步骤(人工操作):** + +1. RDP 登录 VPS(远程桌面 `49.232.102.198`) +2. 手动启动 miniQMT 客户端 → 登录模拟账户 `66639661` +3. 确认客户端进入极简模式/独立交易界面 +4. **换 BRIDGE_SESSION_ID 后重启 bridge**(同 §3.2) +5. 验证 `/health` → `miniqmt_connected: true` + +--- + +## 4. dev → test → prod 发布流水线 + +``` + Mac Mini (开发) NAS (测试) VPS (生产) + ───────────── ────────── ────────── + 写代码 git pull scp bridge 代码 + sanguo_qmt_bridge/ → 容器跑集成测试 → schtasks 重启 + 本地 unit test 验证通过 → 健康巡检 + │ │ │ + ▼ ▼ ▼ + git push ──────────► gitea ──────► NAS pull ────► VPS deploy + (git.mysanguo.top) (Docker 容器) (scp + restart) + │ + 每日 VPS 状态 + 备份 → NAS +``` + +| 阶段 | 机器 | 动作 | 验证 | +|------|------|------|------| +| dev | Mac Mini | 改 `sanguo_qmt_bridge/` 代码 | 本地 unit test(`pytest tests/`)| +| push | Mac Mini | `git push origin master` | gitea 仓库更新 | +| test | NAS | 容器 `git pull` + 跑集成测试 | bridge_client 测试通过、数据管道 OK | +| prod | VPS | scp 变更文件 + 重启 bridge | `/health` + `/account` + `/positions` 三通 | +| backup | NAS | 每日 VPS 状态备份到 NAS | bridge 代码 + config 快照 | + +> **注意**:NAS 测试只能验 bridge_client 端(发 HTTP 请求到 bridge),无法验 xtquant/miniQMT 端(Windows only)。bridge 服务端 + xtquant + miniQMT 的端到端验证只能在 VPS 上做。 + +--- + +## 5. 代码部署到 VPS(Mac → VPS scp + 重启) + +### 5.1 scp 变更文件 + +bridge 代码在 Mac 的 `sanguo_qmt_bridge/` 目录,改完后 scp 到 VPS: + +```bash +# 只传变更的 .py 文件(快速迭代) +scp -i ~/.ssh/id_ed25519 \ + sanguo_qmt_bridge/bridge.py \ + sanguo_qmt_bridge/xt_gateway.py \ + sanguo_qmt_bridge/auth.py \ + Administrator@49.232.102.198:C:/sanguo_qmt_bridge/ + +# 或整目录同步(含 requirements.txt 等) +scp -i ~/.ssh/id_ed25519 sanguo_qmt_bridge/*.py \ + Administrator@49.232.102.198:C:/sanguo_qmt_bridge/ +``` + +> **Windows scp 路径用正斜杠**:`C:/sanguo_qmt_bridge/`(不是反斜杠)。 + +### 5.2 换 session_id + 重启 + +```bash +# 1. 换 BRIDGE_SESSION_ID(同 §3.2 步骤 1) +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 "powershell -Command -" <<'PS1' +$path = "C:\run_bridge.ps1" +$content = Get-Content $path -Raw +$newId = Get-Date -Format "yyyyMMddHHmm" +$content = $content -replace 'BRIDGE_SESSION_ID\s*=\s*"\d+"', "BRIDGE_SESSION_ID = `"$newId`"" +Set-Content $path -Value $content -Encoding UTF8 +Write-Host "session_id → $newId" +PS1 + +# 2. 重启 schtask +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 \ + "schtasks /end /tn \"sanguo-bridge\" & schtasks /run /tn \"sanguo-bridge\"" + +# 3. 等 5 秒,验证 +sleep 5 +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 \ + "curl -s http://127.0.0.1:8765/health" +``` + +### 5.3 如改了 requirements.txt(依赖变更) + +```bash +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 \ + "cd C:\sanguo_qmt_bridge && C:\Python310\python.exe -m pip install -r requirements.txt" +# 然后同 5.2 重启 +``` + +--- + +## 6. 人类闸口清单 + +> 以下操作无法自动化,必须人工执行。自动化脚本碰到这些步骤应明确报"需人工干预"。 + +| 操作 | 为什么只能人做 | 频率 | +|------|---------------|------| +| **VPS 开通 / 重置密码** | 云厂商控制台操作 | 极低(首次/安全事件) | +| **miniQMT 券商客户端登录** | GUI 程序 + 可能需验证码/密码 | 崩溃后 / VPS 重启后 | +| **BRIDGE_TOKEN 轮换** | 需同时在 VPS + NAS + Mac 三处同步更新 | 定期(安全策略) | +| **Let's Encrypt 证书异常处理** | Caddy 自动续期,但 Rate Limit / DNS 异常需人工介入 | 极低(自动续期正常时无需干预) | +| **华为光猫 / 网络配置变更** | 运营商设备,SSH 碰不到 | 极低 | +| **VPS 计划任务创建/修改** | 首次配置 `schtasks /create` 需 RDP | 低(配置变更时) | + +### BRIDGE_TOKEN 轮换流程(人工) + +1. 生成新 token:`python -c "import secrets; print(secrets.token_urlsafe(32))"` +2. VPS:更新 `C:\run_bridge.ps1` 里的 BRIDGE_TOKEN(或系统环境变量) +3. NAS:更新 sanguo 容器环境变量 + `docker restart sanguo_vnpy_v2` +4. Mac:更新 `sanguo_trader/bridge_client.py` 引用的配置或环境变量 +5. VPS:换 session_id + 重启 bridge(§3.2) +6. 全链路验证三端点 + +--- + +## 7. 排障表 + +| 现象 | 根因 | 排查 / 修复 | +|------|------|-------------| +| `/health` → `miniqmt_connected: false` | miniQMT 未登录 / 崩溃 / userdata 路径错 | RDP 检查 miniQMT 客户端状态(§3.4),确认 `MINIQMT_USERDATA` 指向正确 userdata_mini | +| bridge 启动 xtquant connect 返回 -1 | **BRIDGE_SESSION_ID 与旧 session 冲突** | 换 session_id 后重启(§3.2)——这是最常见坑 | +| 401 token 无效 | VPS 与 NAS/Mac 的 `BRIDGE_TOKEN` 不一致 | 核对三处 token 值一致 | +| 公网 `https://bridge.mysanguo.top` 502 | Caddy 没启 或 bridge 没启 | 先验 VPS 本地 `curl 127.0.0.1:8765/health`;本地通=bridge OK→查 Caddy(§3.3);本地不通→查 bridge | +| 公网 DNS 解析到错误 IP(如 198.18.1.244) | **Mac 本地代理(Clash/Surge)DNS 劫持到 fake-IP**。公网 DNS(8.8.8.8)解析正确到 49.232.102.198,但代理层拦截 | 修复①:`/etc/hosts` 加 `49.232.102.198 bridge.mysanguo.top`;修复②:代理规则里该域名直连(bypass proxy) | +| NAS 容器访问 `bridge.mysanguo.top` 超时 | **华为光猫拦截 NAS→VPS 的 80/443** | 走路径③:Mac SSH 隧道 `http://192.168.2.101:8765`(Mac 需常驻 tunnel + caffeinate) | +| Mac SSH 隧道断了 → NAS 容器连不上 bridge | Mac 睡眠 / SSH 进程退出 | Mac 跑 `caffeinate -i -s &` 防睡眠;用 autossh 或 launchd 守护 SSH tunnel | +| 下单报 `[120141][证券交易未初始化]` | **非交易日**(miniQMT 交易日才初始化交易通道)| 等交易日。这是 miniQMT 设计,不是 bug | +| 影子下单未触发 | `live.enabled` 未开 / 当日无成交 / BRIDGE_TOKEN 未设 / bridge_url 不对 | 逐项检查 NAS `config/data_platform.yaml` + 环境变量 | +| 重复下单 | — | 不会。`paper_shadow_orders` 表 `UNIQUE(account_id, trade_id)` 幂等去重 | +| cmd 中文/emoji 乱码 | cmd 默认 GBK 编码 | 用 PowerShell + `chcp 65001`,或 python 加 `-X utf8`(§8) | +| scp 中文路径失败 | Windows 中文目录 + SSH 编码 | 用正斜杠路径 + ASCII 变量名,中文路径用搜索代替字面量 | + +--- + +## 8. Windows 跑命令的坑 + +> Windows SSH 远程跑命令有三个经典坑:编码、引号、中文路径。 + +### 8.1 编码(GBK → UTF8) + +cmd 默认 GBK,中文输出和 emoji 会乱码。 + +```bash +# PowerShell 设 UTF8(代码页 65001) +ssh ... "powershell -Command \"[Console]::OutputEncoding = [Text.Encoding]::UTF8; chcp 65001; <你的命令>\"" + +# Python 加 -X utf8 +ssh ... "C:\Python310\python.exe -X utf8 script.py" +``` + +### 8.2 引号嵌套 → 用 stdin + +SSH → cmd → PowerShell 三层引号极易出错。复杂脚本用 stdin 喂: + +```bash +# powershell -Command - 从 stdin 读脚本 +ssh -i ~/.ssh/id_ed25519 Administrator@49.232.102.198 "powershell -Command -" <<'PS1' +# 这里写 PowerShell,引号无需转义 +curl.exe -s -H "X-Bridge-Token: " http://127.0.0.1:8765/account +PS1 +``` + +`<<'PS1'` 的单引号防止 Mac shell 展开变量,PowerShell 在 VPS 侧原样执行。 + +### 8.3 中文路径 + +`C:\国金QMT交易端\` 等中文路径在 SSH 传输中可能因编码错乱。 + +- scp 目标用正斜杠:`C:/sanguo_qmt_bridge/`(ASCII) +- 需引用中文路径时,在 PowerShell 内用变量拼接或 `Get-ChildItem` 搜索,不写字面量: + +```powershell +# 不写 "C:\国金QMT交易端\...",用搜索 +$qmt = Get-ChildItem C:\ -Directory | Where-Object Name -like "*QMT*" +``` + +### 8.4 curl vs Invoke-WebRequest + +PowerShell 里 `curl` 默认是 `Invoke-WebRequest` 的别名(参数语法不同)。要用的标准 curl: + +```powershell +curl.exe -s http://... # 显式 .exe 绕过别名 +``` + +cmd 里 `curl` 直接就是 `curl.exe`,无此问题。 diff --git a/scripts/ops/README.md b/scripts/ops/README.md new file mode 100644 index 0000000..b40a1db --- /dev/null +++ b/scripts/ops/README.md @@ -0,0 +1,150 @@ +# scripts/ops — NAS 运维脚本 + +NAS(测试 + 备份角色)的运维脚本。仅用标准库 / `set -euo pipefail` bash, +不引入第三方依赖,不硬编码密钥。 + +| 脚本 | 用途 | 运行位置 | +|------|------|----------| +| `nas_bridge_probe.py` | QMT bridge 连通性 / 健康探针 | NAS 容器内(容器无 curl,只能 python) | +| `nas_backup_vps.sh` | 生产 VPS → NAS 每日快照备份(模板) | NAS(ssh 免密拉取生产) | + +--- + +## 1. nas_bridge_probe.py — bridge 健康探针 + +**为何存在**:容器镜像没装 curl/wget,bridge 连通性只能用 python urllib 探。 +走 Mac tunnel `http://192.168.2.101:8765`(华为光猫拦 NAS→VPS 公网 80/443)。 + +**接口**: +- `GET /health`(免鉴权)→ `{"status":"ok","miniqmt_connected":true}` +- `GET /account`(需 `X-Bridge-Token`)→ `{"ok":true,"cash":..,"total":..}` + +**退出码**:`0`=健康(可达 + miniQMT 连接),`1`=不健康。 + +### 用法(容器内) + +```bash +# 进容器(Docker 不在 NAS PATH,须全路径) +/var/packages/Docker/target/usr/bin/docker exec -it sanguo_vnpy_v2 \ + python /app/scripts/ops/nas_bridge_probe.py + +# 额外探 /account(从环境读 token,绝不硬编码) +/var/packages/Docker/target/usr/bin/docker exec -it -e BRIDGE_TOKEN=xxx \ + sanguo_vnpy_v2 python /app/scripts/ops/nas_bridge_probe.py + +# 覆盖 bridge URL(换 tunnel 端点时) +BRIDGE_URL=http://192.168.2.101:8765 BRIDGE_TOKEN=xxx \ + python /app/scripts/ops/nas_bridge_probe.py +``` + +**环境变量**: +| 变量 | 默认 | 说明 | +|------|------|------| +| `BRIDGE_URL` | `http://192.168.2.101:8765` | bridge 基址(Mac tunnel 端点) | +| `BRIDGE_TOKEN` | — | 设了才探 `/account`;从环境读,绝不硬编码 | +| `BRIDGE_TIMEOUT` | `8` | 单请求超时秒数 | + +--- + +## 2. nas_backup_vps.sh — 生产→NAS 每日快照备份 + +**方向(铁律)**:生产 VPS → NAS 单向,**绝不反向写**(NAS 是测试/备份角色, +回灌会污染生产库)。 + +**策略**:每日 `YYYYMMDD/` 快照 + `--link-dest` 硬链接去重(7 份近乎 1 份体积) ++ 保留最近 N 份自动清旧。**模板**:源路径随生产拓扑填。 + +### 用法 + +```bash +# 生产拓扑就绪后(占位示例) +./nas_backup_vps.sh \ + --host sanguo-vps \ + --paths /var/lib/sanguo/data,/opt/sanguo/config \ + --dest /volume1/stock/backup/sanguo_vps \ + --keep 7 + +# 先 dry-run 看会做什么(不传输/不删除) +./nas_backup_vps.sh --host sanguo-vps --paths /a,/b --dest /volume1/.../x --dry-run +``` + +**参数**: +| 参数 | 必填 | 默认 | 说明 | +|------|------|------|------| +| `--host` | 是 | — | 生产源 ssh 别名(`~/.ssh/config`) | +| `--paths` | 是 | — | 生产侧目录列表(逗号分隔,绝对路径) | +| `--dest` | 是 | — | NAS 备份根目录(快照建在其下 `YYYYMMDD/`) | +| `--keep` | 否 | `7` | 保留最近 N 份快照 | +| `--dry-run` | 否 | — | 只打印,不传输/不删除 | + +**前置**:生产 host 别名加进 NAS 的 `~/.ssh/config` + key 免密。 +别名不存在时 preflight 会清晰报错并给 `~/.ssh/config` 占位片段。 + +--- + +## 3. dev→test→prod 流水线命令清单 + +### dev(Mac Mini 开发目录) + +```bash +SRC=~/.openclaw/sanguo_projects/sanguo_vnpy_v2/ +DEST=sanguo-nas:/volume1/homes/admin/.sanguo_projects/sanguo_vnpy_v2/ +DOCKER="/var/packages/Docker/target/usr/bin/docker" + +# (1) 同步代码到 NAS(test/staging)—— 注意每个 pattern 单独 --exclude +# ⚠️ 务必排除 data_cache/(见下方"已知坑"),否则 --delete 会删 NAS 上 +# 万级 staging parquet +rsync -avz --delete \ + --exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='.pytest_cache' \ + --exclude='data/' --exclude='data_cache/' --exclude='logs/' --exclude='temp/' \ + --exclude='*.log' --exclude='.DS_Store' \ + --exclude='.venv/' --exclude='venv/' --exclude='venv311/' \ + --exclude='node_modules/' --exclude='config/' --exclude='.superpowers/' \ + --exclude='entrypoint.sh' \ + -e ssh "$SRC" "$DEST" + +# (2) 重启容器(bind-mount,改代码即生效,重启重载进程) +ssh sanguo-nas "$DOCKER restart sanguo_vnpy_v2" + +# (3) 冒烟:看日志 + 探健康 +sleep 8 +ssh sanguo-nas "$DOCKER logs --tail 30 sanguo_vnpy_v2" +``` + +### test(NAS 容器内) + +```bash +# bridge 连通性(容器无 curl,用 python 探针) +/var/packages/Docker/target/usr/bin/docker exec -it sanguo_vnpy_v2 \ + python /app/scripts/ops/nas_bridge_probe.py +``` + +### prod(生产 VPS,部署时按 `docs/deployment/nas-deploy-plan.md` 第五节, +不要动 frpc/socat/Caddy 外网链路) + +> 生产方向数据备份:`nas_backup_vps.sh` 从 VPS 拉 → NAS,单向。 + +--- + +## 已知坑 + +1. **rsync `--exclude` 语法**:`--exclude='a' 'b' 'c'`(一个 `--exclude` 后跟多个 + bare pattern)是**错的**——rsync 会把 `b`/`c` 当源路径(报 `lstat` 错,排除不生效)。 + 正确写法:每个 pattern 单独一个 `--exclude=PATTERN`。`nas-deploy-plan.md` 第三节的 + 命令需修。 + +2. **`data_cache/` 必须排除**:dev↔NAS 同步是**代码**迭代,`data_cache/` 是本机下载缓存 + (dev 59k 文件 / NAS 69k 文件,差异万级 staging parquet)。带 `--delete` 不排除会删 NAS + 上大量 staging 文件。deploy-plan 排除列表漏了它,已在上文命令补 `--exclude='data_cache/'`。 + +3. **`venv/`(无点)必须排除**:dev 机有 `venv/`(Python 3.14)和 `venv311/`(Python 3.11), + deploy-plan 只排了 `.venv/`(带点)。不排会把 509 个 venv 文件(含 182MB 的 + `_polars_runtime.abi3.so`)推到 NAS 容器(容器是 Python 3.10,这堆二进制毫无用处)。 + 已在上文命令补 `--exclude='venv/' --exclude='venv311/'`。 + +4. **`entrypoint.sh` 位置不一致(会炸容器,已确认)**:容器 `Entrypoint=[/app/entrypoint.sh]` + (`docker inspect` 实证),但 dev 侧根目录已无 `entrypoint.sh`(移到 `docker/entrypoint.sh`)。 + 直接跑带 `--delete` 的 rsync 会删 NAS 的 `/app/entrypoint.sh` → 下次 `docker restart` + 启动失败(entrypoint not found)。**已在上文命令加 `--exclude='entrypoint.sh'` 保护**。 + 根治方案(二选一,部署时定):① dev 根目录恢复 `entrypoint.sh`(从 `docker/` 软链或拷贝); + ② 更新容器 entrypoint 指向 `/app/docker/entrypoint.sh`(需 `docker rm` + 重 `run`)。 diff --git a/scripts/ops/nas_backup_vps.sh b/scripts/ops/nas_backup_vps.sh new file mode 100755 index 0000000..29bba6a --- /dev/null +++ b/scripts/ops/nas_backup_vps.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# nas_backup_vps.sh — 从生产 VPS 拉取状态到 NAS 做每日快照备份(模板)。 +# +# 方向(铁律):生产 VPS → NAS 单向;**绝不反向写**(NAS 是测试/备份角色, +# 不向生产回灌数据,防止测试库污染生产库)。 +# +# 这是**模板**:源侧(VPS)的具体路径随生产拓扑填(--paths),host 别名随 +# ~/.ssh/config 配。脚本不硬编码任何 host / 路径 / 密钥。 +# +# 策略: +# - rsync over ssh,幂等(同一快照目录重复跑结果一致) +# - 每日快照子目录 $DEST/YYYYMMDD/,用 --link-dest 指向上一份做硬链接去重 +# (未变文件零额外占盘,7 份快照近乎 1 份体积) +# - 保留最近 N 份(--keep,默认 7),旧的自动删 +# - --dry-run 只看不动;preflight 检查源 host 可达 +# +# 用法: +# nas_backup_vps.sh \ +# --host sanguo-vps \ +# --paths /var/lib/sanguo/data,/opt/sanguo/config \ +# --dest /volume1/stock/backup/sanguo_vps \ +# --keep 7 +# nas_backup_vps.sh ... --dry-run # 只打印会做什么 +# +# 前置(生产拓扑就绪后做一次): +# 1) 在 NAS 的 ~/.ssh/config 加生产 host 别名 + key(免密) +# (现在别名可能还没有——preflight 会清晰报错并给出提示) +# 2) 确保 NAS 上 $DEST 根目录已创建并可写 +set -euo pipefail + +# ---------------------------------------------------------------------------- +# 参数解析 +# ---------------------------------------------------------------------------- +HOST="" +PATHS="" +DEST="" +KEEP=7 +DRY_RUN=0 + +usage() { + cat <<'EOF' +用法: nas_backup_vps.sh --host --paths --dest [--keep N] [--dry-run] + +必填: + --host 生产源 host 的 ssh 别名(~/.ssh/config) + --paths 生产侧要备份的目录列表(逗号分隔,绝对路径) + --dest NAS 上备份根目录(快照建在其下 YYYYMMDD/) +可选: + --keep N 保留最近 N 份快照(默认 7) + --dry-run 只打印会做什么,不实际传输/删除 + -h, --help 显示本帮助 +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --host) HOST="$2"; shift 2 ;; + --paths) PATHS="$2"; shift 2 ;; + --dest) DEST="$2"; shift 2 ;; + --keep) KEEP="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "未知参数: $1" >&2; usage >&2; exit 2 ;; + esac +done + +# 必填校验 +MISSING="" +[ -z "$HOST" ] && MISSING="$MISSING --host" +[ -z "$PATHS" ] && MISSING="$MISSING --paths" +[ -z "$DEST" ] && MISSING="$MISSING --dest" +if [ -n "$MISSING" ]; then + echo "[FAIL] 缺少必填参数:$MISSING" >&2 + usage >&2 + exit 2 +fi + +# KEEP 数值校验 +if ! [[ "$KEEP" =~ ^[0-9]+$ ]] || [ "$KEEP" -lt 1 ]; then + echo "[FAIL] --keep 必须是 >=1 的整数,得到: $KEEP" >&2 + exit 2 +fi + +RSYNC_FLAGS=(-a --delete --info=stats1) +LINK_DEST_OPT="" +if [ "$DRY_RUN" -eq 1 ]; then + RSYNC_FLAGS+=(-n) + echo "[MODE] DRY-RUN(不实际传输/删除)" +fi + +TODAY="$(date +%Y%m%d)" +SNAP="$DEST/$TODAY" +PREV="$(ls -1 "$DEST" 2>/dev/null | grep -E '^[0-9]{8}$' | sort | tail -1 || true)" + +echo "=== VPS→NAS 备份 ===" +echo " host = $HOST" +echo " paths = $PATHS" +echo " dest = $DEST" +echo " snap = $SNAP" +echo " keep = $KEEP" +echo " prev = ${PREV:-(无,首份快照)}" +echo " dryrun = $DRY_RUN" +echo + +# ---------------------------------------------------------------------------- +# Preflight:源 host 可达性 +# host 别名可能还不存在(生产拓扑未就绪)——给清晰提示而非晦涩的 ssh 报错。 +# ---------------------------------------------------------------------------- +echo "[preflight] 检查源 host 可达: $HOST" +if ! ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new \ + "$HOST" 'echo OK' >/dev/null 2>&1; then + cat >&2 < + User + IdentityFile ~/.ssh/<生产 key> + 2) key 是否免密?本脚本用 BatchMode(绝不交互输密码),须 key 认证。 + 3) 网络/防火墙是否放通 ssh 端口。 + 4) 先手动跑一次确认: ssh $HOST echo OK +EOF + exit 1 +fi +echo "[preflight] OK: $HOST 可达" +echo + +# ---------------------------------------------------------------------------- +# 备份根目录 +# ---------------------------------------------------------------------------- +mkdir -p "$SNAP" + +# --link-dest 指向上份快照(硬链接未变文件,省盘)。dry-run 下也安全。 +if [ -n "$PREV" ] && [ -d "$DEST/$PREV" ]; then + LINK_DEST_OPT="--link-dest=$DEST/$PREV" + echo "[link-dest] 硬链接基准: $DEST/$PREV" +fi +echo + +# ---------------------------------------------------------------------------- +# 逐路径 rsync 拉取(生产 → NAS 快照) +# ---------------------------------------------------------------------------- +# 把逗号分隔的 paths 转成数组(去空段) +IFS=',' read -ra PATH_ARR <<< "$PATHS" +for p in "${PATH_ARR[@]}"; do + p="${p%/}" # 去尾斜杠,保证语义一致 + [ -z "$p" ] && continue + name="$(basename "$p")" + target="$SNAP/$name" + mkdir -p "$target" + echo "[rsync] $HOST:$p/ → $target/" + # shellcheck disable=SC2086 # LINK_DEST_OPT 需要按词拆分 + rsync "${RSYNC_FLAGS[@]}" $LINK_DEST_OPT \ + "$HOST:$p/" "$target/" \ + || { echo "[FAIL] rsync 失败: $HOST:$p" >&2; exit 1; } + echo "[rsync] done: $name" + echo +done + +# ---------------------------------------------------------------------------- +# 保留策略:留最近 N 份,旧的删 +# 只识别 YYYYMMDD 命名的子目录,绝不碰其他名字(防止误删 dest 下非快照内容)。 +# ---------------------------------------------------------------------------- +echo "[retention] 保留最近 $KEEP 份快照" +mapfile -t SNAPSHOTS < <(ls -1 "$DEST" 2>/dev/null | grep -E '^[0-9]{8}$' | sort -r || true) +DELETED=0 +idx=0 +for s in "${SNAPSHOTS[@]}"; do + idx=$((idx + 1)) + if [ "$idx" -le "$KEEP" ]; then + echo " [keep] $s" + else + if [ "$DRY_RUN" -eq 1 ]; then + echo " [would-delete] $s" + else + rm -rf "${DEST:?}/$s" && echo " [deleted] $s" + fi + DELETED=$((DELETED + 1)) + fi +done +echo "[retention] 本次删除/拟删: $DELETED" + +echo +echo "=== 备份完成 ===" +echo " 快照: $SNAP" +[ "$DRY_RUN" -eq 1 ] && echo " (dry-run,未实际写入/删除)" diff --git a/scripts/ops/nas_bridge_probe.py b/scripts/ops/nas_bridge_probe.py new file mode 100755 index 0000000..f5ca84a --- /dev/null +++ b/scripts/ops/nas_bridge_probe.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""NAS 容器内 QMT bridge 健康探针(纯标准库 urllib)。 + +为何用 python 不用 curl: + sanguo_vnpy_v2 容器镜像(Python 3.10)**没有装 curl/wget**,NAS 上做 + bridge 连通性探测只能用 python urllib(标准库自带,零第三方依赖)。 + +为何走 Mac tunnel(默认 http://192.168.2.101:8765)不走 VPS 公网: + 华为光猫拦截 NAS→VPS 公网 80/443,bridge 只能通过 Mac Mini tunnel + 反向暴露给 NAS。默认探 http://192.168.2.101:8765/health。 + +接口契约(见 sanguo_qmt_bridge/README.md): + GET /health 免鉴权 → {"status":"ok","miniqmt_connected": true|false} + GET /account 需 header X-Bridge-Token → + {"ok":true,"cash":..,"frozen":..,"market_value":..,"total":..} + +退出码:0=健康(bridge 可达 且 status=ok 且 miniqmt_connected=true) + 1=不健康(bridge 不可达 / status 异常 / miniQMT 断开) + +容错风格:网络/解析失败不抛异常,记 warning 后视情况降级(与 +sanguo_trader.bridge_client 一致——探针失败不应崩溃,只报状态退出)。 + +用法(容器内执行): + python /app/scripts/ops/nas_bridge_probe.py + BRIDGE_TOKEN=xxx python /app/scripts/ops/nas_bridge_probe.py # 额外探 /account + BRIDGE_URL=http://192.168.2.101:8765 python .../nas_bridge_probe.py # 覆盖 URL + +环境变量: + BRIDGE_URL bridge 基址(默认 http://192.168.2.101:8765) + BRIDGE_TOKEN 鉴权 token;设了才探 /account(绝不硬编码,从环境读) + BRIDGE_TIMEOUT 单请求超时秒数(默认 8) +""" +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from typing import Any + +DEFAULT_BRIDGE_URL = "http://192.168.2.101:8765" +DEFAULT_TIMEOUT = 8 + + +def _get_json(url: str, token: str | None, timeout: float) -> tuple[dict | None, str | None]: + """GET JSON;失败返回 (None, 错误描述),绝不抛异常。 + + 与 bridge_client._get 同风格:URLError/Timeout/JSON/OSError 统一吞掉记 warning。 + """ + headers: dict[str, str] = {"Accept": "application/json"} + if token: + headers["X-Bridge-Token"] = token + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + return json.loads(body), None + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as e: + return None, f"{type(e).__name__}: {e}" + + +def _fmt_money(val: Any) -> str: + """资金字段友好打印;非数字原样 str。""" + try: + return f"{float(val):,.2f}" + except (TypeError, ValueError): + return str(val) + + +def main() -> int: + base = os.environ.get("BRIDGE_URL", DEFAULT_BRIDGE_URL).rstrip("/") + token = os.environ.get("BRIDGE_TOKEN") or None + timeout = float(os.environ.get("BRIDGE_TIMEOUT", str(DEFAULT_TIMEOUT))) + + print(f"bridge_url = {base}") + print(f"timeout = {timeout}s") + print(f"token = {'(set, will probe /account)' if token else '(not set, skip /account)'}") + print("-" * 48) + + # 1. /health(免鉴权) + health, err = _get_json(f"{base}/health", token=None, timeout=timeout) + if health is None: + print(f"[DOWN] bridge 不可达:{err}") + print(" 排查:Mac tunnel 是否在线 / 192.168.2.101:8765 是否监听 / 光猫拦截") + return 1 + + status = str(health.get("status", "")) + mini_connected = bool(health.get("miniqmt_connected", False)) + print(f"[/health] status={status or '?'} miniqmt_connected={mini_connected}") + + if status != "ok": + print(f"[WARN] bridge 可达但 status 异常: {status!r}(可能是降级态)") + return 1 + + if not mini_connected: + print("[DOWN] bridge UP 但 miniQMT 未连接(disconnected)——无法下单/查账户") + return 1 + + print("[UP] bridge 健康,miniQMT 已连接") + + # 2. /account(需 token;token 缺省则跳过,不报错) + if not token: + print("[SKIP] /account(未设 BRIDGE_TOKEN)") + return 0 + + acct, err = _get_json(f"{base}/account", token=token, timeout=timeout) + if acct is None: + print(f"[WARN] /account 探测失败:{err}(bridge 本身健康,鉴权/查询侧异常)") + # bridge 已确认健康,账户查询失败不改变整体健康判定 + return 0 + if not acct.get("ok"): + print(f"[/account] ok=false error={acct.get('error', '?')}") + return 0 + + print( + f"[/account] ok=true" + f" cash={_fmt_money(acct.get('cash'))}" + f" frozen={_fmt_money(acct.get('frozen'))}" + f" market_value={_fmt_money(acct.get('market_value'))}" + f" total={_fmt_money(acct.get('total'))}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())