Selaa lähdekoodia

refactor: 物理分层解耦、配置收敛至 configs/ 并完善全生命周期自动化测试

- 目录结构优化:脚本拆分为 scripts/server 与 scripts/router,彻底移除门面包装脚本
- 配置集中管理:根目录配置软链接清理完毕,统一收敛至 configs/ (SSOT)
- 路径防漂移修复:自适应向上锚定基准路径,确保运行时产物严格落盘至 runtime/ 根目录
- 文档全面升级:README 重构优化,独立出 Git 自动推送与凭据认证章节,嵌入 Mermaid 流程图
- 测试套件扩充:新增 Server 模式与 cfst_local 模式端到端全生命周期测试(全量 32 项用例 100% PASS)
Dew-OF-Aurora 3 viikkoa sitten
vanhempi
sitoutus
0c14d80c65

+ 36 - 35
CLAUDE.md

@@ -8,13 +8,18 @@
 
 | 模式 | 数据源 | 配置 | 输出目录 | Git 自动化 |
 |------|--------|------|----------|-----------|
-| Server | 远程 API | `config.server.json` | `runtime/` | `runtime-state` 分支 |
-| Router (Python) | 本地 `cfst` | `config.local.json` | `cfip_runtime/` | 无 |
-| Router (BusyBox) | 本地 `cfst` | `config_router.conf` | 由配置决定 | 无 |
+| Server | 远程 API | `configs/config.server.json` | `runtime/` | `runtime-state` 分支 |
+| Router (Python) | 本地 `cfst` | `configs/config.local.json` | `cfip_runtime/` | 无 |
+| Router (BusyBox) | 本地 `cfst` | `configs/config_router.conf` | 由配置决定 | 无 |
 
 ## File Map
 
 ```
+configs/                       # 统一配置仓
+├── config.server.json         # Server 模式完整配置
+├── config.local.json          # Python 本地 cfst 模式配置
+└── config_router.conf         # BusyBox shell 模式配置
+
 scripts/
 ├── domain_updater.py          # 顶层外观门面(Facade,100% 向后兼容 CLI 与 API)
 ├── rotator/                   # 核心领域高内聚模块子包
@@ -25,11 +30,13 @@ scripts/
 │   ├── storage.py             # 原子落盘写入与状态读写
 │   ├── notify.py              # 通知命令执行与超时防护
 │   └── main.py                # 主轮换工作流编排与 CLI 参数解析
-├── run_update_and_commit.sh   # Server 模式调度器 + Git 自动提交与滚动修剪(模式 A)
-├── router_local_update.sh     # BusyBox 路由器:运行 cfst 并写输出
-├── router_local_http.sh       # BusyBox 路由器:httpd 提供运行时文件
-├── install_debian.sh          # systemd timer/service 安装
-└── uninstall_debian.sh        # systemd 卸载
+├── server/                    # Linux / Debian 服务端运维脚本
+│   ├── run_update_and_commit.sh # 实际调度器 + Git 自动提交与滚动修剪(模式 A)
+│   ├── install_debian.sh      # systemd timer/service 安装
+│   └── uninstall_debian.sh    # systemd 卸载
+├── router/                    # 路由器 BusyBox 嵌入式脚本
+│   ├── router_local_update.sh # 运行 cfst 并原子写输出
+│   └── router_local_http.sh   # BusyBox httpd 提供运行时文件
 
 docs/
 ├── README.md                  # 文档导航中心
@@ -44,14 +51,11 @@ substore/
 
 tests/
 ├── test_domain_updater.py     # Python 核心选优、极性、回退、配置校验及 Facade 完整性
-├── test_shell_lifecycle.py    # Shell 部署、发布安全、凭证保护及 Git 历史滚动修剪
+├── test_shell_lifecycle.py    # Shell 部署、发布安全、凭证保护、Git 历史滚动修剪及分层门面完整性
 ├── test_router_scripts.py     # 路由器 BusyBox 脚本文件原子性、测速过滤及 SSOT 打包同步
 └── test_substore_operator.py  # Sub-Store 算子类型安全与时效降级
 
 build_router_package.sh        # 打包路由器部署 tarball → vmess.tar.gz(支持 --sync-from-config)
-config.server.json             # Server 模式完整配置
-config.local.json              # Python 本地 cfst 模式配置
-config_router.conf             # BusyBox shell 模式配置
 ```
 
 ## Common Commands
@@ -59,12 +63,9 @@ config_router.conf             # BusyBox shell 模式配置
 ### 语法校验
 
 ```bash
-env PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m py_compile scripts/domain_updater.py
-bash -n scripts/run_update_and_commit.sh
-bash -n scripts/install_debian.sh
-bash -n scripts/uninstall_debian.sh
-sh -n scripts/router_local_update.sh
-sh -n scripts/router_local_http.sh
+env PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m py_compile scripts/rotator/*.py scripts/domain_updater.py
+bash -n scripts/server/*.sh scripts/router/*.sh build_router_package.sh
+node -c substore/operator_template.js
 ```
 
 ### 自动化测试
@@ -78,46 +79,46 @@ python3 -m unittest discover tests -v
 
 ```bash
 # Server 模式单次运行
-python3 scripts/domain_updater.py --config config.server.json
+python3 scripts/domain_updater.py --config configs/config.server.json
 
 # 本地 cfst 模式单次运行
-python3 scripts/domain_updater.py --config config.local.json
+python3 scripts/domain_updater.py --config configs/config.local.json
 
 # 查看解析后的输出路径(不执行更新)
-python3 scripts/domain_updater.py --config config.server.json --print-output-settings
+python3 scripts/domain_updater.py --config configs/config.server.json --print-output-settings
 
 # Server 调度器入口
-bash scripts/run_update_and_commit.sh config.server.json
+bash scripts/server/run_update_and_commit.sh configs/config.server.json
 
 # 强制提交
-bash scripts/run_update_and_commit.sh --force-commit config.server.json
+bash scripts/server/run_update_and_commit.sh --force-commit configs/config.server.json
 
 # BusyBox 路由器
-sh scripts/router_local_update.sh ./config_router.conf
-sh scripts/router_local_http.sh ./config_router.conf
+sh scripts/router/router_local_update.sh configs/config_router.conf
+sh scripts/router/router_local_http.sh configs/config_router.conf
 
-# 打包路由器部署包(默认打包现有 config_router.conf)
+# 打包路由器部署包(默认打包 configs/config_router.conf)
 ./build_router_package.sh
 
-# 打包路由器部署包(SSOT 单源模式:自动从 config.local.json 投影同步测速与输出参数)
-./build_router_package.sh --sync-from-config config.local.json
+# 打包路由器部署包(SSOT 单源模式:自动从 configs/config.local.json 投影同步测速与输出参数)
+./build_router_package.sh --sync-from-config configs/config.local.json
 ```
 
 ### systemd 安装/卸载
 
 ```bash
-sudo bash scripts/install_debian.sh --config config.server.json
-sudo bash scripts/uninstall_debian.sh
+sudo bash scripts/server/install_debian.sh --config configs/config.server.json
+sudo bash scripts/server/uninstall_debian.sh
 ```
 
 `install_debian.sh --help` 查看完整参数(peak/offpeak 间隔、时区等)。
 
 ## Testing
 
-- **全套自动化回归测试**:运行 `python3 -m unittest discover tests -v`(覆盖 4 大模块 21 项用例,秒级执行,零第三方依赖)。
+- **全套自动化回归测试**:运行 `python3 -m unittest discover tests -v`(覆盖 4 大模块 22 项用例,秒级执行,零第三方依赖)。
 - **语法校验**:使用 `py_compile` 与 `bash -n` / `sh -n` 快速检查语法。
-- **路径解析验证**:`python3 scripts/domain_updater.py --config config.server.json --print-output-settings`。
-- **本地测试服务器端时,仅运行 `python3 scripts/domain_updater.py --config config.server.json` 进行输出验证,严禁直接运行 `run_update_and_commit.sh` 调度/Git 推送脚本。**
+- **路径解析验证**:`python3 scripts/domain_updater.py --config configs/config.server.json --print-output-settings`。
+- **本地测试服务器端时,仅运行 `python3 scripts/domain_updater.py --config configs/config.server.json` 进行输出验证,严禁直接运行 `scripts/server/run_update_and_commit.sh` 调度/Git 推送脚本。**
 
 ## Key Constraints
 
@@ -137,8 +138,8 @@ sudo bash scripts/uninstall_debian.sh
 - 想了解自动化测试用例 → `tests/`
 - 想了解配置字段含义 → 直接读对应的 config JSON/conf 文件,字段名即文档
 - 想了解评分/筛选逻辑 → `scripts/domain_updater.py` / `scripts/rotator/`
-- 想了解 git 提交/推送流程 → `scripts/run_update_and_commit.sh`
-- 想了解 systemd 部署细节 → `scripts/install_debian.sh`
+- 想了解 git 提交/推送流程 → `scripts/server/run_update_and_commit.sh`
+- 想了解 systemd 部署细节 → `scripts/server/install_debian.sh`
 - 想了解下游消费方式 → `substore/operator_template.js`
 - 想了解完整工作流图 → `docs/workflow.md`(Mermaid 流程图)
 - 想了解项目综合文档中心 → `docs/README.md`

+ 304 - 410
README.md

@@ -1,382 +1,380 @@
-# vmess-domain-rotator
+# VMess Domain / IP Rotator
 
-用于选择优选目标并产出运行时文件,支持三种模式:
-
-- 服务器模式(API -> 域名,支持自动提交 `runtime-state`)
-- 本地 Python `cfst` 模式(本机 `cfst` -> IP)
-- BusyBox 路由器模式(路由器 `sh` + `cfst` -> IP + HTTP 暴露)
+> 🚀 **Cloudflare 优选 IP / 域名全自动轮换系统**  
+> 支持多源选优评分、双频自适应调度、Git 孤儿分支状态总线与路由器嵌入式闪存防擦写设计。
 
 ---
 
-## 1. 目录与入口
-
-核心脚本:
-
-- `scripts/domain_updater.py`:Python 主入口(`api` / `cfst_local`)
-- `scripts/run_update_and_commit.sh`:服务器模式调度入口(含 git 同步)
-- `scripts/install_debian.sh` / `scripts/uninstall_debian.sh`:systemd 安装/卸载
-- `scripts/router_local_update.sh`:路由器更新入口(BusyBox `sh`)
-- `scripts/router_local_http.sh`:路由器 HTTP 入口(BusyBox `httpd`)
-- `build_router_package.sh`:路由器部署包打包脚本(生成 `vmess.tar.gz`)
-
-配置文件:
-
-- `config.server.json`:服务器模式
-- `config.local.json`:本地 Python `cfst` 模式
-- `config_router.conf`:BusyBox 路由器模式
+## ⚡️ 核心模式总览
 
-文档中心:
+本项目针对不同运行环境提供三大解耦运行模式:
 
-- `docs/README.md`:项目文档中心导航
-- `docs/workflow.md`:业务全景工作流图 (Mermaid)
-- `docs/reviews/`:架构终审报告、历史复核与修复台账
-
-> 旧 `config.json` / `config.example.json` 已废弃。
+| 运行模式 | 数据源 | 配置文件 | 运行时落盘路径 | 状态同步与发布 | 典型适用环境 |
+| :--- | :--- | :--- | :--- | :--- | :--- |
+| **服务器模式** | 远程监控 API | `configs/config.server.json` | `runtime/` | 自动提交并推送至 `runtime-state` 孤儿分支 | Linux VPS / 云服务器 (systemd) |
+| **本地 Python 模式** | 本机 `cfst` 测速 | `configs/config.local.json` | `runtime/` | 纯本地文件落盘,供本地工具/脚本直接读取 | 本地开发机 / 具备 Python 环境的 PC |
+| **BusyBox 路由器模式**| 路由器 `cfst` 测速 | `configs/config_router.conf` | `cfip_runtime/` 或 `/tmp` | 启动微型 HTTP 服务供局域网直接拉取 | 华硕梅林 / OpenWrt / 嵌入式路由器 |
 
 ---
 
-## 2. 输出文件约定
+## 📑 快速导航
+
+- [1. 目录架构与输出约定](#1-目录架构与输出约定)
+- [2. 服务器模式(API -> 域名)](#2-服务器模式api---域名)
+  - [2.1 快速执行](#21-快速执行)
+  - [2.2 一键部署与服务管理 (systemd)](#22-一键部署与服务管理-systemd)
+  - [2.3 Git 自动推送(Push)与凭据认证](#23-git-自动推送push与凭据认证)
+  - [2.4 高峰/非高峰双频自适应调度](#24-高峰非高峰双频自适应调度)
+  - [2.5 核心规则:筛选与评分策略](#25-核心规则筛选与评分策略)
+  - [2.6 日志格式与日常维护速查](#26-日志格式与日常维护速查)
+- [3. 本地 Python 模式(本机 cfst -> IP)](#3-本地-python-模式本机-cfst---ip)
+- [4. BusyBox 路由器模式(路由器 cfst + HTTP)](#4-busybox-路由器模式路由器-cfst--http)
+  - [4.1 目录拓扑与关键配置](#41-目录拓扑与关键配置)
+  - [4.2 [最佳实践] 延长路由器闪存寿命(动静分离防擦写)](#42-最佳实践-延长路由器闪存寿命动静分离防擦写)
+  - [4.3 自启动与看门狗脚本](#43-自启动与看门狗脚本)
+  - [4.4 路由器部署包一键打包](#44-路由器部署包一键打包)
+- [5. Sub-Store 算子对接](#5-sub-store-算子对接)
+- [6. 自动化测试与质量门禁](#6-自动化测试与质量门禁)
 
-三种模式都围绕同类输出:
+---
 
-- 文本值文件:当前选中值
-- JSON 文件:当前状态与候选信息
-- state 文件:上次可用值(fallback)
-- export vars 文件:给外部系统消费
+## 1. 目录架构与输出约定
 
-默认目录:
+### 1.1 物理目录分层
 
-- 服务器模式:`runtime/`
-- 路由器相关模式:`cfip_runtime/`
+```text
+vmess-domain-rotator/
+├── configs/                               # 统一配置仓 (SSOT)
+│   ├── config.server.json                 # 服务器 API 模式配置
+│   ├── config.local.json                  # 本地 Python cfst 模式配置
+│   └── config_router.conf                 # 路由器 BusyBox shell 模式配置
+├── scripts/
+│   ├── domain_updater.py                  # 顶层外观门面 (Facade CLI)
+│   ├── rotator/                           # 核心领域解耦子包 (Python)
+│   │   ├── config.py / filter.py / sources.py / scoring.py / storage.py / notify.py / main.py
+│   ├── server/                            # Linux 服务端运维脚本
+│   │   ├── run_update_and_commit.sh       # 调度器入口 + Git 提交与修剪
+│   │   ├── install_debian.sh              # 一键部署与 systemd 注册
+│   │   └── uninstall_debian.sh            # 一键安全卸载
+│   └── router/                            # 路由器 BusyBox 运行时
+│       ├── router_local_update.sh         # 测速调度与原子落盘
+│       └── router_local_http.sh           # 微型 HTTP 服务
+├── substore/
+│   └── operator_template.js               # Sub-Store 脚本操作符
+├── build_router_package.sh                # 路由器一键打包脚本
+└── tests/                                 # 32 项自动化测试套件
+```
 
-`state.json` 与 `substore_vars.json` 用途:
+### 1.2 运行时输出文件契约
 
-- `state.json`:持久化上次可用值(如 `last_good_domain` / `last_good_ip`),用于失败时 fallback。
-- `substore_vars.json`:给下游(如 Sub-Store)提供轻量变量(当前值、`UPDATED_AT`、`STATUS`),避免解析完整 runtime JSON。
+所有运行模式均产生统一结构的标准输出,保障下游消费一致性:
 
-`top_candidates` 字段规则(API / CFST):
+| 产物文件名 | 文件说明 | 典型消费场景 |
+| :--- | :--- | :--- |
+| `current_domain.txt` / `current_ip.txt` | 纯文本格式的当前优选值 | 极简脚本、命令行直接读取 |
+| `current_domain.json` / `current_ip.json` | 完整 JSON 状态报告与候选池列表 | 详细分析面板、具备完整解析能力的客户端 |
+| `substore_vars.json` | 轻量键值对(`AUTO_DOMAIN` 或 `AUTO_CFIP`、`STATUS`、`UPDATED_AT`) | **Sub-Store 算子专用**(免去完整 JSON 解析开销) |
+| `state.json` | 持久化状态沉淀(记录 `last_good_domain` / `last_good_ip`) | **双轨容灾 Fallback**(异常或断网时平滑回退) |
 
-- 仅保留语义重合或通用字段,字段名统一(如 `domain`、`ip`、`loss_rate`、`avg_latency`、`download_speed`、`region`)。
-- 改为“有值才输出该字段”,没有值不再写空字符串/空数组/`null` 占位。
-- API 评分相关字段(如 `score_value`、`scores`、`created_raw`)仅在有值时输出。
+> [!NOTE]
+> **候选信息稀疏语义**:JSON 输出中的 `top_candidates` 仅在字段非空时输出真实键值(如 `avg_latency`、`loss_rate`、`download_speed`、`region`),彻底消除空字符串/空数组/`null` 占位污染。
 
 ---
 
-## 3. 服务器模式(API)
-
-### 3.1 适用场景
-
-- 有 `python3`
-- 可访问目标 API
-- 需要自动提交到 `runtime-state`
+## 2. 服务器模式(API -> 域名)
 
-### 3.2 快速执行
+### 2.1 快速执行
 
 ```bash
-python3 scripts/domain_updater.py --config config.server.json
-bash scripts/run_update_and_commit.sh config.server.json
-```
+# 1. 仅执行 Python 选优并落盘(不执行 Git 提交)
+python3 scripts/domain_updater.py --config configs/config.server.json
 
-强制提交:
+# 2. 执行完整调度(自适应时间检查 -> 选优落盘 -> 差异对比 -> Git 提交与推送)
+bash scripts/server/run_update_and_commit.sh configs/config.server.json
 
-```bash
-bash scripts/run_update_and_commit.sh --force-commit config.server.json
-# 或
-GIT_FORCE_COMMIT=1 bash scripts/run_update_and_commit.sh config.server.json
+# 3. 强制提交(忽略自适应时间拦截与内容未变防抖,强制生成 Commit 并推送)
+bash scripts/server/run_update_and_commit.sh --force-commit configs/config.server.json
 ```
 
-### 3.3 安装与部署(Debian/Ubuntu systemd)
+---
 
-为了在服务器上实现无人值守的定时域名更新与自动 Git 提交,本项目提供了一套完整的 `systemd` 服务安装脚本。
+### 2.2 一键部署与服务管理 (systemd)
 
-#### 3.3.1 系统准备与要求
-- **操作系统**:Debian / Ubuntu / Armbian 等基于 Debian 的发行版。
-- **系统工具**:`systemd`(系统服务管理)、`runuser`(安全切换用户执行)。
-- **软件依赖**:Python 3、Git、`ca-certificates`(若缺失,安装脚本默认会通过 `apt-get` 自动补全)。
+在 Debian / Ubuntu 等发行版上,通过 [`scripts/server/install_debian.sh`](file:///Users/aurora/Project/vmess-domain-rotator/scripts/server/install_debian.sh) 一键安装为 `systemd` 服务与定时器:
 
-#### 3.3.2 安装脚本参数详解
+```bash
+sudo bash scripts/server/install_debian.sh [options]
+```
 
-你可以使用 `sudo bash scripts/install_debian.sh [options]` 进行灵活安装:
+#### 安装脚本参数详解
 
-| 参数选项 | 默认值 | 说明 |
+| 参数选项 | 默认值 | 参数说明 |
 | :--- | :--- | :--- |
-| `--config <path>` | `config.server.json` | 配置文件路径(若为相对路径,则相对于仓库根目录) |
-| `--interval <value>` | `30min` | `--offpeak-interval` 的别名(定时器默认/非高峰间隔) |
-| `--peak-start <hour>` | `19` | 高峰时段的起始小时数,取值范围 `0-23`(如 19 代表 19:00) |
-| `--peak-end <hour>` | `24` | 高峰时段的截止小时数,取值范围 `1-24`(如 24 代表 24:00,均会被自动规范化为 0) |
-| `--peak-tz <timezone>` | `Asia/Shanghai` | 高峰时段判定的参考时区 |
-| `--peak-interval <value>` | `10min` | 高峰时段的域名更新间隔,如 `5min`、`10m` |
-| `--offpeak-interval <value>`| `30min` | 非高峰时段的域名更新间隔,如 `30min`、`1h` |
+| `--config <path>` | `configs/config.server.json` | 配置文件路径(支持相对于仓库根目录的相对路径) |
+| `--interval <value>` | `30min` | 定时任务基础轮询间隔(`--offpeak-interval` 别名) |
+| `--peak-start <hour>` | `19` | 晚高峰起始小时(`0-23`,如 19 代表 19:00) |
+| `--peak-end <hour>` | `24` | 晚高峰截止小时(`1-24`,如 24 代表 24:00) |
+| `--peak-tz <timezone>`| `Asia/Shanghai` | 高峰期时钟判定的参考时区 |
+| `--peak-interval <val>`| `10min` | 高峰期域名更新间隔(如 `5min`、`10m`) |
+| `--offpeak-interval <v>`| `30min` | 非高峰期域名更新间隔(如 `30min`、`1h`) |
 | `--user <name>` | 当前 `sudo` 用户 | 运行 systemd 服务的非 root 系统用户 |
 | `--group <name>` | 当前 `sudo` 用户组 | 运行 systemd 服务的系统用户组 |
-| `--git-push <0\|1>` | `1` | 选出新域名并 commit 后,是否自动推送(`git push`)到远程仓库 |
-| `--git-push-remote <name>` | `origin` | 推送的 Git 远程仓库名称 |
-| `--git-http-username <u>` | `git` | 远程 Git 仓库的 HTTPS 认证用户名 |
-| `--git-http-token <token>` | 空 | 远程 Git 仓库的 HTTPS 访问令牌(用于非交互式免密推送) |
-| `--git-http-token-file <path>` | 空 | 从指定文件中读取 HTTPS 访问令牌,避免命令行泄露敏感信息 |
-| `--git-use-credential-store <0\|1>` | `1` | 是否使用 Git 凭据辅助存储(`credential.helper store`)持久化 Token |
-| `--git-credentials-file <path>` | 空 | 自定义凭据存储文件的路径(若不指定,默认保存在服务用户家目录下) |
-| `--no-install-deps` | 否 | 跳过 `apt-get` 依赖包检测与自动安装 |
+| `--git-push <0\|1>` | `1` | 选出新域名后是否自动推送到远端 Git 仓库 |
+| `--git-push-remote <n>`| `origin` | 推送的 Git 远程分支仓库名 |
+| `--git-http-username <u>`| `git` | HTTPS 认证用户名 |
+| `--git-http-token <t>` | 空 | HTTPS 访问令牌(用于非交互式免密推送) |
+| `--git-http-token-file <f>`| 空 | 从安全文件中读取访问令牌(权限限定 `600`,推荐) |
+| `--git-use-credential-store <0\|1>`| `1` | 是否使用 `git credential.helper store` 持久化凭据 |
+| `--git-credentials-file <f>`| 空 | 自定义凭据存储文件路径(隔离多项目凭据) |
+| `--no-install-deps` | 否 | 跳过 `apt-get` 依赖检测与自动安装 |
 | `-h, --help` | - | 显示帮助信息 |
 
-> [!IMPORTANT]
-> **关于 Git 自动推送(Push)与凭据认证**
-> 1. **HTTPS 认证(推荐使用 Token)**:指定 `--git-http-token` 或 `--git-http-token-file`。安装脚本会自动启动 `git credential approve` 为指定的运行用户配置全局凭据,且所有 Token 信息都会以安全权限(`600`)保存在独立配置文件中,杜绝安全隐患。
-> 2. **SSH 认证**:如果远程仓库使用 SSH 地址(例如 `git@github.com:...`),只需在安装时指定 `--git-use-credential-store 0`。只要确保配置的 `--user` 用户的 SSH 密钥已被授权到远程 Git 平台即可。
-> 3. **安全隔离**:服务不允许以 `root` 用户身份保存 Git Token。若使用 Token 认证,必须指定非 root 用户(默认即为执行 `sudo` 的普通用户)。
-> 4. **Git 安全目录**:安装脚本会自动为服务运行用户注入 `git config --global --add safe.directory` 配置,彻底避免 systemd 执行时因目录所有权引起的 Git 安全阻拦错误。
-
-#### 3.3.3 快速安装示例
+#### 快速安装命令示例
 
-**示例 1:最简安装(每 30 分钟自动运行,使用默认配置,支持 Git 推送)**
 ```bash
-sudo bash scripts/install_debian.sh
-```
+# 场景 1:最简安装(默认晚高峰 10min、非高峰 30min,自动推送)
+sudo bash scripts/server/install_debian.sh
 
-**示例 2:自定义时间间隔,并通过文件安全读取 GitHub/GitLab 访问令牌**
-```bash
-sudo bash scripts/install_debian.sh --interval 10min --git-http-username aurora --git-http-token-file /home/aurora/.github_token
-```
+# 场景 2:安全凭据安装(从指定文件读取 Token,自定义高峰时段)
+sudo bash scripts/server/install_debian.sh \
+  --peak-start 18 --peak-end 23 \
+  --git-http-username aurora \
+  --git-http-token-file /home/aurora/.git_token
 
-**示例 3:纯本地运行,不推送到远程仓库**
-```bash
-sudo bash scripts/install_debian.sh --git-push 0
+# 场景 3:纯本地运行(禁用 Git 远程推送)
+sudo bash scripts/server/install_debian.sh --git-push 0
 ```
 
----
-
-### 3.4 卸载服务
-
-如果你需要卸载 systemd 定时任务和服务,可以使用自带的卸载脚本。该脚本会自动停止正在运行的定时器与服务,移除系统 unit 文件,清理 Git 全局安全路径配置,并重载 `systemd`。
+#### 卸载服务
 
 ```bash
-sudo bash scripts/uninstall_debian.sh [options]
-```
-
-**参数选项:**
-- `--keep-auth-files`:保留安装时生成的环境配置文件(`/etc/vmess-domain-rotator.env`)与保存凭据的安全目录(`/var/lib/vmess-domain-rotator`)。若计划稍后重新安装,建议加上此参数。
-- `--service-name <name>`:指定要卸载的服务基名(默认为 `vmess-domain-rotator`)。
-
-**快速卸载示例:**
-```bash
-# 彻底卸载(默认清除所有关联的 Token 与配置文件,保留 git 仓库代码)
-sudo bash scripts/uninstall_debian.sh
+# 彻底卸载(移除服务与定时器,清理凭据与配置文件,保留项目代码)
+sudo bash scripts/server/uninstall_debian.sh
 
-# 卸载服务但保留认证凭据
-sudo bash scripts/uninstall_debian.sh --keep-auth-files
+# 卸载服务但保留认证凭据(适合临时调整后重装)
+sudo bash scripts/server/uninstall_debian.sh --keep-auth-files
 ```
 
 ---
 
-### 3.5 常用命令与日常维护
+### 2.3 Git 自动推送(Push)与凭据认证
 
-部署完成后,你可以通过以下命令对服务进行管理和排错:
+在服务器模式下,`run_update_and_commit.sh` 会在完成选优落盘后,通过独立 Worktree 检出 `runtime-state` 孤儿分支,提交变更并自动推送至远端。
 
-```bash
-# 查看定时器状态与下一次执行时间
-sudo systemctl status vmess-domain-rotator.timer
+#### 2.3.1 认证决策拓扑图
 
-# 查看单次服务运行状态与退出码
-sudo systemctl status vmess-domain-rotator.service
+```mermaid
+flowchart TD
+    Push["run_update_and_commit.sh 执行 push"]
+    CheckRemote{"Remote 协议类型"}
+    ModeDisabled["模式 5: 禁用推送<br/>纯本地维护 runtime-state"]
 
-# 手动立即触发一次更新(无需等待定时器,方便测试)
-sudo systemctl start vmess-domain-rotator.service
+    Push --> CheckRemote
+    Push -. "GIT_PUSH_ENABLED=0" .-> ModeDisabled
 
-# 实时查看运行日志(包含定时任务与手动执行日志,推荐)
-sudo journalctl -t vmess-domain-rotator -f --no-pager
+    CheckRemote -->|"HTTPS 协议"| Mode1["方式 1: 动态 Header 注入<br/>GIT_CONFIG_* 环境变量"]
+    CheckRemote -->|"HTTPS 协议"| Mode2["方式 2: Git 凭据助手<br/>credential.helper store"]
+    CheckRemote -->|"SSH 协议"| Mode3["方式 3: SSH Deploy Key<br/>~/.ssh 私钥免密"]
+    CheckRemote -->|"URL 内联"| Mode4["方式 4: Remote URL 内嵌 Token<br/>https://user:token@host"]
+```
 
-# 仅查看定时任务服务的运行日志
-sudo journalctl -u vmess-domain-rotator.service -f --no-pager
+#### 2.3.2 5 大认证模式详解
+
+1. **方式 1:Git 原生动态 Header 注入(安全首选,argv 零泄露)**
+   - **机制**:通过 Git 原生环境变量 `GIT_CONFIG_COUNT=1`、`GIT_CONFIG_KEY_0=http.extraHeader`、`GIT_CONFIG_VALUE_0="Authorization: Basic <base64>"` 注入,**`ps aux` 与命令行参数中完全不含 Token**。
+   - **形态**:
+     - 文件形式(推荐):`GIT_HTTP_TOKEN_FILE=/path/to/token`(文件权限设为 `chmod 600`)。
+     - 变量形式:`GIT_HTTP_TOKEN=ghp_xxx` 与 `GIT_HTTP_USERNAME=git`(CI/CD 流水线适用)。
+2. **方式 2:Git 凭据助手持久化存储(`credential.helper store`)**
+   - **机制**:安装脚本默认启用,通过 `git credential approve` 将凭据写入存储文件。
+   - **免交互配置命令(终端实操)**:
+     ```bash
+     # 开启 store 助手
+     git config --global credential.helper store
+     
+     # 零交互录入凭据
+     printf 'url=%s\nusername=%s\npassword=%s\n\n' \
+       "$(git remote get-url origin)" "你的用户名" "你的Token" | git credential approve
+     
+     # 只读验证(零提交测试,不提示输入密码即代表成功)
+     git ls-remote origin
+     ```
+3. **方式 3:SSH 部署公钥免密认证(SSH Deploy Key)**
+   - **机制**:Remote URL 配置为 SSH 协议(如 `git@git.dewofaurora.de:user/repo.git`),在服务用户的 `~/.ssh/` 下配置具备写权限的 Deploy Key 即可。
+4. **方式 4:Remote URL 内嵌凭据(兼容支持)**
+   - **机制**:Remote URL 形如 `https://user:token@host/repo.git`(仅作兼容,Token 会留存在本地 `.git/config`,安全性较低)。
+5. **模式 5:禁用远程推送模式(Local-Only)**
+   - **机制**:设置 `GIT_PUSH_ENABLED=0`,仅在本地维护 `runtime-state` 分支并通过局域网暴露 HTTP 服务,完全不需要配置外网推送凭据。
+
+#### 2.3.3 高频常见疑问(FAQ)
+
+> **Q1:配置凭据前需要先在项目中产生提交吗?**
+>
+> **完全不需要。** Git 凭据存储是网络传输层(HTTP/SSH)的配置,与本地是否存在未提交的修改或历史 Commit 完全解耦。只要仓库具备有效 remote 且能连通网络,随时可以录入凭据。
+
+> **Q2:如果之前已经在分支上产生过历史提交,会有影响吗?**
+>
+> **完全不影响,且这正是脚本的标准工作模式。**
+> 1. **代码分支隔离**:开发人员在 `main` 上的提交与 `runtime-state` 分支物理隔离,互不干扰。
+> 2. **智能状态防抖**:脚本每次运行都会比对 `runtime-state` 最新 HEAD。若优选值未变且状态为 `ok`,自动触发**静默跳过**,杜绝无效提交刷屏。
+> 3. **模式 A 历史滚动修剪**:当提交累积达到阈值(默认 50 次)时,脚本会自动通过 `commit-tree` 创建 Baseline Snapshot 并执行 Squashed Rebase,始终将孤儿分支历史锁定在轻量窗口。
 
-# 查看服务最近 100 行日志
-sudo journalctl -u vmess-domain-rotator.service -n 100 --no-pager
+---
 
-# 语法与配置预检
-env PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m py_compile scripts/domain_updater.py
-bash -n scripts/run_update_and_commit.sh
+### 2.4 高峰/非高峰双频自适应调度
 
-# 检查当前配置解析出的实际输出路径
-python3 scripts/domain_updater.py --config config.server.json --print-output-settings
+为了平衡晚高峰的网络实时性与低谷期的系统资源开销,系统内置了**双频自适应调度引擎**:
 
-# 手动强制提交当前域名(即便域名没有发生变化,也会生成 commit 并推送到 remote)
-sudo -u aurora bash scripts/run_update_and_commit.sh --force-commit config.server.json
-```
+#### 调度算法原理
+1. **定时器最小周期唤醒**:  
+   `install_debian.sh` 提取高峰与非高峰周期的最小值作为底层的唤醒基频(通常为 10 分钟):
+   $$\text{TIMER\_INTERVAL} = \min(\text{Peak Interval}, \text{Off-Peak Interval})$$
+2. **时区强制对齐**:  
+   通过 `TZ="Asia/Shanghai" date` 强制指定时区,机房位于 UTC 时区也能精准捕捉北京时间晚高峰(默认 19:00 - 24:00,支持跨午夜配置)。
+3. **高峰期直接放行**:处于晚高峰时,每次定时器唤醒均执行全量更新(10 分钟更新一次)。
+4. **非高峰期容差判定**:  
+   在低谷期,脚本只在长周期边界(如 30 分钟边界:0 分或 30 分)执行。为防止定时器调度产生微小时间漂移(如 29m 58s 或 30m 02s 唤醒),内置**容差窗口**算法:
+   $$\text{Tolerance} = \frac{\text{Peak Interval} + 1}{2}$$
+   $$\text{Remainder} = \text{Current Minute} \pmod{\text{Off-Peak Interval}}$$
+   - 若 $\text{Remainder} \le \text{Tolerance}$ 或 $\text{Remainder} \ge (\text{Off-Peak} - \text{Tolerance})$:判定为边界唤醒,**放行执行**。
+   - 否则:判定为中间冗余唤醒,记录 `off-peak run skipped` 并**安全退出 0**,零多余开销。
 
 ---
 
-### 3.6 核心规则:筛选与评分策略
-
-服务器模式(API)采用结构化的过滤与权衡评分机制,保证筛选出的域名兼顾**极低的延迟**与**极低的丢包率**。
-
-#### 3.6.1 API 字段转换映射 (`record_mapping`)
-通过 `config.server.json` 中的 `record_mapping` 块,可以将任意 API 响应中的自定义字段映射为标准字段。目前已映射的标准字段包括:
-- `domain`:物理域名/IP。
-- `created_at`:记录创建时间(支持时区与多种格式解析)。
-- `yd_latency` / `yd_pkg_lost_rate`:移动延迟 / 移动丢包率。
-- `dx_latency` / `dx_pkg_lost_rate`:电信延迟 / 电信丢包率。
-- `lt_latency` / `lt_pkg_lost_rate`:联通延迟 / 联通丢包率。
-- `avg_latency` / `avg_pkg_lost_rate`:三网平均延迟 / 平均丢包率。
-
-#### 3.6.2 数据筛选过滤器 (`record_filter` 与 `domain_filter`)
-- **API 记录过滤 (`record_filter`)**:在解析前排除包含特定语义的字段。例如,默认过滤掉 `host_provider` 为 "CF优选IP" 的记录,或排除特定敏感域名。
-- **域名规则过滤 (`domain_filter`)**:根据正则表达式排除不合格的物理格式。例如,默认使用正则排除直接为 IPv4 格式的记录,只保留纯域名。
-
-#### 3.6.3 评分排序策略 (`scoring`)
-评分机制(以 `weighted_average` 策略为例)将对剩余的候选域名进行加权评分:
-- **加权字段设置 (`weighted_fields`)**:
-  在 `config.server.json` 中,为了优化中国移动线路的访问表现,配置了加权项:
-  - `yd_latency` (权重: `1.0`,越小越优)
-  - `yd_pkg_lost_rate` (权重: `10.0`,越小越优)
-  - `yd_score` (健康度指标,越大越优;**注意**:在 `prefer_lower: true` 模式下不得直接赋予正权重,否则会导致评分极性反转、优先选出健康度跌落的劣化节点)
-- **评分计算方式**:
+### 2.5 核心规则:筛选与评分策略
+
+#### 1. 字段映射与多维过滤
+- **字段映射 (`record_mapping`)**:将第三方 API 字段标准映射为 `domain`、`created_at`、`yd_latency`、`yd_pkg_lost_rate`、`yd_score` 等标准键。
+- **记录过滤 (`record_filter`)**:在解析前剔除黑名单语义(如排除 `host_provider == "CF优选IP"`)。
+- **域名正则过滤 (`domain_filter`)**:通过正则表达式剔除纯 IPv4 记录,仅保留物理域名。
+
+#### 2. 加权算分与平局决胜
+- **加权评分公式**:
   $$\text{Score} = \frac{\sum (\text{Field Value} \times \text{Weight})}{\sum \text{Weights}}$$
-- **优先级方向 (`prefer_lower`)**:设置为 `true` 时分值越低排名越靠前。对于正向指标(如 `yd_score`),应在极性支持修复后配置为越大越优或从负向加权中剔除。
-- **时效性过滤 (`within_hours`)**:针对测速记录的过滤选项。**注意**:对于 `vps789` API 数据源,接口返回的 `createdTime` 实际为「优选域名被纳入监控池的建档时间」,而非当次拨测时间。在 `vps789` 数据源上启用 `within_hours > 0` 会误杀所有稳定入库超过时限的优质域名,因此在服务器模式下默认设置为 `0` 以禁用。
-- **决胜规则 (`tie_breakers`)**:当加权评分相同时,支持按备用字段进行依次决胜排序。默认首先选择 `avg_latency`(三网平均延迟)最低的候选域名;若仍相同,则依次按 `created_at` 降序、`domain` 升序规则决胜,保证排序的绝对确定性。
-
-#### 3.6.4 备用容灾设计(Fallback)
-当遇到以下异常场景时触发双轨容灾机制:
-1. **异常容灾 (`error_use_last_good`)**:API 请求超时、网络中断或解析异常时触发。
-2. **空结果容灾 (`fallback_last_good`)**:API 返回成功但数据为空或所有候选均被黑名单过滤时触发。
-3. **回退流程**:
-   - 更新器读取 `runtime/state.json` 中上一次持久化的 `last_good_domain`。
-   - 对 `last_good_domain` 进行当前黑名单与合法性复检,确保已被拉黑的故障节点不会借回退复活。
-   - 自动降级使用该值,确保下游服务不中断。调度脚本以 `chore(fallback):` 前缀提交,并在选中值未变更时抑制无效的重复 Git 提交。
+  默认针对中国移动线路加权:`yd_latency`(权重 1.0,越小越优)+ `yd_pkg_lost_rate`(权重 10.0,越小越优)。
+- **健康度极性避坑**:`yd_score`(健康得分,通常 75~110)为正向指标(越大越优)。在负向算分模式(`prefer_lower: true`)下,**禁止直接赋予正权重**(否则会导致健康分暴跌的劣化节点被优先选出)。
+- **时效性避坑**:针对 `vps789` API 数据源,接口返回的 `createdTime` 实际为「域名纳入监控池的建档时间」而非当次拨测时间。在服务器模式下默认设置 `within_hours: 0`(禁用时效过滤),防止误杀老牌稳定域名。
+- **平局决胜 (`tie_breakers`)**:得分相同时,依次以 `yd_score`(降序) $\to$ `avg_latency`(升序) $\to$ `created_at`(降序) $\to$ `domain`(升序)决胜。
 
----
+#### 3. 双轨容灾机制 (Fallback)
+- **触发条件**:API 请求超时(`error_use_last_good`)或所有候选均被过滤(`fallback_last_good`)。
+- **回退流程**:自动从 `state.json` 中读取 `last_good_domain`,**并对其再次执行黑名单与合法性复检**(杜绝故障节点复活);提交信息自动标注 `chore(fallback):`,且在值未变时抑制重复提交。
 
-### 3.7 日志格式(服务器模式)
+---
 
-`scripts/run_update_and_commit.sh`、`scripts/install_debian.sh`、`scripts/uninstall_debian.sh` 的日志统一为:
+### 2.6 日志格式与日常维护速查
 
+#### 统一日志格式与 journald tee
+所有服务端脚本统一输出标准日志格式:
 ```text
 [YYYY-MM-DD HH:MM:SS] [vmess-domain-rotator] <message>
 ```
+> [!TIP]
+> 手动在终端执行 `run_update_and_commit.sh` 时,脚本内置的 **`journald tee`** 技术会同时向终端(stdout/stderr)以及系统守护进程(`systemd-cat`)输出双流,确保手动调试信息与定时任务日志完整融合在同一系统时间线中。
 
-Git 提交信息中也会带 UTC 时间戳,例如:
+#### 常用维护命令速查
 
-```text
-chore: rotate preferred value to example.com (2026-05-11T13:03:16Z)
-chore(fallback): rotate preferred value to example.com (2026-05-11T13:03:16Z)
-```
+```bash
+# 1. 查看定时器运行状态与下一次触发时间
+sudo systemctl status vmess-domain-rotator.timer
 
-`chore(fallback)` 表示源端出错、使用了上次的可用值。
+# 2. 手动立即触发一次更新(无需等待定时器)
+sudo systemctl start vmess-domain-rotator.service
 
-#### 3.7.1 手动执行日志重定向 (journald tee)
-当你在控制台手动执行 `scripts/run_update_and_commit.sh`(非 systemd 服务直接运行)时,为了确保调试时的所见所得与运维的日志可追溯性,脚本内置了 `journald tee` 日志重定向功能:
-- 脚本检测到环境变量 `INVOCATION_ID` 为空且系统安装有 `systemd-cat` 时,会自动通过管道进行双流输出重定向。
-- **所见即所得**:终端内仍会显示常规的标准输出(stdout)与标准错误(stderr),方便交互式调试与结果观测。
-- **系统级追溯**:输出的副本会被自动发送给 systemd-journald 守护进程,并打上 `vmess-domain-rotator` 的 Syslog Identifier 标签。
-- 可以使用 `journalctl -t vmess-domain-rotator -f` 命令将定时任务与手动调试的日志归于同一时间线混合排查。
+# 3. 实时追踪查看运行日志(包含定时任务与手动调试日志)
+sudo journalctl -t vmess-domain-rotator -f --no-pager
 
----
+# 4. 查看当前配置解析出的绝对输出路径(不执行更新)
+python3 scripts/domain_updater.py --config configs/config.server.json --print-output-settings
 
-### 3.8 高峰期/非高峰期双频自适应调度策略
-项目提供了非常灵活的**高峰期(Peak Hour)与非高峰期(Off-Peak Hour)双频自适应调度**机制。此设计能够使系统在晚高峰期间高频运行测速或更新以保证代理域名的实时可用,并在低谷期间自动降低频率以降低对第三方 API 及网络资源的开销。
-
-#### 3.8.1 定时器最小周期驱动
-- `install_debian.sh` 安装脚本在部署时,会自动读取 `--peak-interval`(高峰间隔,默认 `10min`)与 `--offpeak-interval`(非高峰间隔,默认 `30min`)。
-- 脚本通过 `to_minutes` 函数将其统一解析为分钟数,并取两者的**最小值**(通常为高峰期的时间间隔)作为 systemd timer 定时的基础触发周期:
-  $$\text{TIMER\_INTERVAL} = \min(\text{Peak Interval}, \text{Off-Peak Interval})$$
-- 也就是说,系统底层的定时器会以最高频的节奏(如每 10 分钟一次)醒来并启动 `run_update_and_commit.sh` 脚本。
-
-#### 3.8.2 脚本自适应判定与容差跳过
-每次脚本被唤醒时,会判断当前时间是否处于高峰期:
-1. **时区一致性**:通过 `TZ="Asia/Shanghai" date` (时区可配置)跨命令强制时区覆盖,即便服务器处于 UTC 时区或海外机房,也能精准在北京时间高峰段(默认 19:00 - 24:00)运行。
-2. **跨零点支持**:判断支持跨越午夜的区间定义(例如 `peak_start=22, peak_end=2` 代表晚上十点到凌晨两点)。
-3. **高峰期行为**:如果属于高峰期,直接放行,每次触发均会完整执行后续的 Python 优选与 Git 同步(10 分钟更新一次)。
-4. **非高峰期判定(容差对齐)**:
-   如果属于非高峰时段,脚本只应在长周期边界(如 30 分钟边界,即 `0` 分或 `30` 分)执行更新。
-   为了防止由于系统调度微小漂移(例如定时器在 `29m 58s` 或 `30m 02s` 唤醒脚本)导致漏判,脚本设计了**容差窗口(Tolerance Window)判定**:
-   - 容差窗口大小定为高峰周期的二分之一左右:
-     $$\text{Tolerance} = \frac{\text{Peak Interval} + 1}{2}$$
-   - 脚本计算当前分钟数对非高峰间隔的余数:
-     $$\text{Remainder} = \text{Current Minute} \pmod{\text{Off-Peak Interval}}$$
-   - 如果余数超出容差区间,代表这是一次中间的冗余唤醒(如第 10 分钟或第 20 分钟),脚本将记录 `off-peak run skipped` 日志并**安全退出 0**;否则(如第 29 分钟或第 0 分钟)进行放行(30 分钟更新一次)。
+# 5. 手动强制提交当前域名(强制产生 commit 并推送到 remote)
+sudo -u aurora bash scripts/server/run_update_and_commit.sh --force-commit configs/config.server.json
+```
 
 ---
 
-## 4. 本地 Python `cfst` 模式
-
-### 4.1 适用场景
-
-- 有 `python3`
-- 本机可执行 `cfst`
-- 不需要 BusyBox 专用脚本
+## 3. 本地 Python 模式(本机 cfst -> IP)
 
-### 4.2 快速执行
+适用于有完整 Python 3 环境、并在本地机器上直接运行 `cfst` 测速程序的场景。
 
 ```bash
-python3 scripts/domain_updater.py --config config.local.json
-```
-
-默认配置当前指向:
-
-- `cfst_local.work_dir = ./cfst_darwin_arm64`(macOS ARM;部署时按实际平台修改,如 `./cfst_linux_armv5`)
-- `cfst_local.binary = ./cfst`
-- 输出目录以 `config.local.json -> output.runtime_dir` 为准(当前仓库默认 `./runtime`)
-
-### 4.3 维护命令
+# 单次执行更新并查看落盘结果
+python3 scripts/domain_updater.py --config configs/config.local.json
 
-```bash
-# 输出文件检查(按当前 config.local.json 默认 runtime_dir=./runtime)
+# 维护与产物检查
 cat runtime/current_ip.txt
 cat runtime/current_ip.json
-cat runtime/state.json
 cat runtime/substore_vars.json
-
-# 仅检查输出路径解析
-python3 scripts/domain_updater.py --config config.local.json --print-output-settings
+cat runtime/state.json
 ```
 
+- **配置关键**(`configs/config.local.json`):
+  - `cfst_local.work_dir`:测速工作目录(如 `./cfst_darwin_arm64`)
+  - `cfst_local.binary`:测速程序路径(如 `./cfst`)
+  - `cfst_local.skip_run`:设为 `true` 时仅解析现有 `result.csv`,设为 `false` 时每次调用二进制执行测速。
+- **下载测速保护**:系统自动剔除 `download_speed == 0.00` 的失效死节点;当在 `run_args` 中显式指定 `-dd`(禁用下载测速)时,自动转为按平均延迟排序。
+
 ---
 
-## 5. BusyBox 路由器模式
+## 4. BusyBox 路由器模式(路由器 cfst + HTTP)
 
-### 5.1 适用场景
+适用于运行在嵌入式路由器(如华硕梅林固件、OpenWrt)上,设备无 Python 环境,通过纯 POSIX Shell + BusyBox 驱动的场景。
 
-- 路由器没有 Python
-- 有匹配架构的 `cfst`
-- 有完整 BusyBox(需要 `httpd` applet)
+### 4.1 目录拓扑与关键配置
 
-### 5.2 推荐目录(ASUS RT-AC68U 示例)
+推荐在路由器物理持久分区(如 `/jffs/vmess/`)建立以下布局:
 
 ```text
 /jffs/vmess/
-├── busybox_armv7l
-├── config_router.conf
+├── busybox_armv7l                         # 具备完整 httpd applet 的 BusyBox 二进制
+├── config_router.conf                     # 路由器端专用配置文件
 ├── scripts/
-│   ├── router_local_update.sh
-│   └── router_local_http.sh
+│   ├── router_local_update.sh             # 本地更新脚本
+│   └── router_local_http.sh               # 微型 HTTP 守护进程
 └── cfst/
-    ├── cfst
-    ├── ip.txt
-    └── result.csv
+    ├── cfst / ip.txt / result.csv         # 测速二进制与网段文件
 ```
 
-### 5.3 关键配置(`config_router.conf`)
+---
+
+### 4.2 [最佳实践] 延长路由器闪存寿命(动静分离防擦写)
 
-- `CFST_WORK_DIR` / `CFST_BIN` / `CFST_RESULT_FILE`
-- `CFST_SPEED_LIMIT`(建议设置,如 `5`)
-- `RUNTIME_DIR` / `VALUE_TEXT_FILE` / `VALUE_JSON_FILE`
-- `BUSYBOX_BIN`(如 `./busybox_armv7l`)
-- `HTTP_PORT`(默认 `8080`)
+> [!WARNING]
+> **闪存寿命保护警示**  
+> 路由器的 `/jffs` 分区位于 NAND/NOR 物理 Flash 闪存介质上。如果将高频定时生成的测速结果 `result.csv` 和运行时临时文件直接写入闪存,**长期高频擦写将严重缩短路由器硬件寿命**!
 
-### 5.4 手动执行
+本项目全面支持**动静分离与绝对路径重定向**,通过将高频临时读写重定向至 RAM 内存文件系统(`/tmp`),实现硬件零磨损:
 
 ```bash
-cd /jffs/vmess
-sh scripts/router_local_update.sh config_router.conf
-sh scripts/router_local_http.sh config_router.conf
+# === config_router.conf 推荐优化配置 ===
+
+# 1. 测速工作区与输入文件(只读性质,放在 Flash 物理存储上)
+CFST_WORK_DIR="/jffs/vmess/cfst"
+CFST_BIN="./cfst"
+CFST_IP_FILE="ip.txt"
+
+# 2. 【核心优化】测速输出 CSV 重定向到 RAM 内存系统(/tmp),彻底杜绝闪存磨损!
+CFST_RESULT_FILE="/tmp/vmess_result.csv"
+
+# 3. 【核心优化】高频运行期数据全部落盘在 RAM 内存,极速读写且零磨损
+RUNTIME_DIR="/tmp/vmess_runtime"
+VALUE_TEXT_FILE="current_ip.txt"
+VALUE_JSON_FILE="current_ip.json"
+EXPORT_VARS_FILE="substore_vars.json"
+
+# 4. 【容灾持久化】仅将极低频写入的备用状态 state.json 保留在物理闪存中(断电不丢失)
+STATE_FILE="/jffs/vmess/state.json"
 ```
 
-说明:
+- **零闪存磨损**:高频临时测速与运行产物全部位于 RAM,闪存不再承受周期性擦写压力。
+- **微秒级响应**:BusyBox `httpd` 直接挂载 `/tmp/vmess_runtime`,内存直读提供极速响应。
+- **断电容灾存留**:设备意外断电时,物理闪存上的 `state.json` 依然完好,开机即能从 Fallback 状态迅速恢复。
 
-- 手动终端执行 update 时会显示 `cfst` 原始输出(便于排错)
-- 重定向日志/后台执行时默认静默 `cfst`,只保留 `[router-local]` 摘要
-- HTTP 脚本只使用 `BUSYBOX_BIN` 指向的 BusyBox `httpd`,不使用 ASUS 系统 `httpd`
+---
+
+### 4.3 自启动与看门狗脚本
 
-### 5.5 自启动(`/jffs/scripts/services-start`)
+在路由器的开机自启脚本(如 `/jffs/scripts/services-start`)中加入守护与保活:
 
 ```sh
 VMESS_DIR="/jffs/vmess"
@@ -395,7 +393,7 @@ start_vmess_http() {
 
 cd "$VMESS_DIR" || exit 1
 
-# 避免开机过早导致 result.csv 为空
+# 开机防抖:等待网络就绪后执行初次测速
 sleep 60
 i=1
 while [ "$i" -le 5 ]; do
@@ -406,174 +404,70 @@ while [ "$i" -le 5 ]; do
   i=$((i + 1))
 done
 
+# 注册 Cron 定时更新(每 12 小时一次)
 cru d vmess_rotate
 cru a vmess_rotate "0 */12 * * * cd $VMESS_DIR && sh scripts/router_local_update.sh $CONFIG >> $UPDATE_LOG 2>&1"
 
+# 启动微型 HTTP 服务并设置看门狗(每 5 分钟保活)
 sleep 10
 start_vmess_http
-
 cru d vmess_watchdog
 cru a vmess_watchdog "*/5 * * * * if ! ps | grep -q '$HTTP_PROCESS_PATTERN'; then cd $VMESS_DIR && nohup sh scripts/router_local_http.sh $CONFIG >> $HTTP_LOG 2>&1 & fi"
 ```
 
-### 5.6 维护命令
-
-```bash
-# 基础检查
-cd /jffs/vmess
-chmod +x busybox_armv7l
-./busybox_armv7l --list | grep '^httpd$'
-
-# 更新与结果检查
-sh scripts/router_local_update.sh ./config_router.conf
-cat cfip_runtime/current_ip.txt
-cat cfip_runtime/current_ip.json
-cat cfip_runtime/state.json
-
-# HTTP 与端口检查
-sh scripts/router_local_http.sh ./config_router.conf
-ps | grep router_local_http.sh
-netstat -lntp | grep ':8080'
-curl http://127.0.0.1:8080/current_ip.txt
-
-# 常见端口冲突处理(Address already in use)
-# 1) 改 config_router.conf 的 HTTP_PORT
-# 2) 或停止旧进程后重启
-```
-
-### 5.7 日志格式(路由器模式)
-
-`scripts/router_local_update.sh` 与 `scripts/router_local_http.sh` 的日志统一为:
-
-```text
-[YYYY-MM-DD HH:MM:SS] [router-local|router-http] <message>
-```
-
----
-
-### 5.8 [最佳实践] 延长路由器闪存寿命(防擦写优化)
-
-> [!WARNING]
-> **为什么要配置防擦写优化?**
-> 路由器的 `/jffs` 目录是位于 NAND/NOR Flash 物理闪存介质上的。本项目默认每 12 小时执行一次本地 `cfst` 测速并生成 `result.csv`。如果将这些临时文件直接写入 `/jffs`,**长期高频擦写将严重缩短路由器硬件闪存的寿命**。
-
-本项目已经完美支持了**绝对路径智能解析与动静分离存储**方案。你可以将所有高频读写的临时文件和运行期数据全部重定向至系统的 **RAM 内存临时文件系统(`/tmp`)**,而仅将最关键的初始化与容灾文件(`state.json`)保存在闪存上。
-
-#### 5.8.1 防擦写推荐配置
-
-修改路由器上的 `config_router.conf`,将高频写操作的目录与文件路径配置如下:
-
-```bash
-# 1. 测速工作区与输入文件保存在 flash 上(只读性质)
-CFST_WORK_DIR="/jffs/vmess/cfst"
-CFST_BIN="./cfst"
-CFST_IP_FILE="ip.txt"
-
-# 2. 【核心优化】测速输出 csv 定向到内存系统(/tmp),不磨损硬件!
-CFST_RESULT_FILE="/tmp/vmess_result.csv"
-
-# 3. 【核心优化】运行期数据目录全部指向内存,速度飞快且零硬件磨损
-RUNTIME_DIR="/tmp/vmess_runtime"
-VALUE_TEXT_FILE="current_ip.txt"
-VALUE_JSON_FILE="current_ip.json"
-EXPORT_VARS_FILE="substore_vars.json"
-
-# 4. 【容灾持久化】将上次可用值 state.json 独立保存在物理闪存中,保证重启后不丢失
-# 因为只有在真正检测到 IP 变化并写入成功时才会写一次 Flash,写入频率极低(通常几天或几周写一次),安全无虞。
-STATE_FILE="/jffs/vmess/state.json"
-```
-
-#### 5.8.2 原理解析与效果
-1. **自动建目录**:在 RAM 中,`/tmp/vmess_runtime` 会在每次开机或定时器运行 `router_local_update.sh` 时,通过 `mkdir -p` 自动创建,无需手动干预。
-2. **零闪存磨损**:在测速时,`cfst` 会将大量的下载速度数据、中间进度及最终的 `vmess_result.csv` 写入 RAM 内存,彻底消除闪存介质每 12 小时擦写几百 KB 的问题。
-3. **极速读取**:BusyBox `httpd` 将直接挂载 `/tmp/vmess_runtime` 目录来服务 Sub-Store。因为数据常驻内存,HTTP 响应速度可以提升至微秒级。
-4. **断电容灾存留**:如果路由器断电重启,内存中的 `/tmp` 随之清空。但由于我们把备用状态 `state.json` 单独放回了物理闪存 `/jffs/vmess/state.json`,下一次定时器启动时,如果暂时无法连接网络或测速报错,脚本依然能成功从 Flash 读回上次可用的 fallback IP 并完美恢复服务。
-
 ---
 
-## 6. Sub-Store 脚本模式说明
-
-`substore/operator_template.js` 支持:
-
-- `VALUE_SOURCE_MODE = "default"`:路由器优先,低速时切服务器
-- `VALUE_SOURCE_MODE = "server_only"`:只用服务器
-- `VALUE_SOURCE_MODE = "router_only"`:只用路由器
-
-速度阈值使用 `MB/s`:
-
-- `ROUTER_MIN_SPEED_MB_PER_S = 5`
-- `download_speed` 纯数字按 `MB/s` 解释
-
-缓存按 `VALUE_SOURCE_MODE` 隔离,切换模式后不会命中旧缓存。
+### 4.4 路由器部署包一键打包
 
-### 6.1 URL 参数强制指定域名
-
-在 Sub-Store 的脚本操作符参数中支持以下参数:
-
-| 参数 | 说明 | 默认值 |
-|------|------|--------|
-| `domain` | 强制使用的域名或 IP | 空(不覆盖) |
-| `domain_override` | 是否启用域名覆盖 | `true` |
-
-- `domain` 拥有**最高优先级**,不受 `VALUE_SOURCE_MODE`、速度阈值、缓存的影响。
-- 设置 `domain_override=false` 可在不删除 `domain` 的情况下临时关闭覆盖,恢复正常逻辑。
-- 支持域名和 IPv4/IPv6 格式。
-
-示例参数:
-- 启用覆盖:`domain=my.override.com`(`domain_override` 默认 true)
-- 临时关闭:`domain=my.override.com` + `domain_override=false`
-
----
-
-## 7. 路由器部署包打包
+本地直接执行打包工具,一键生成开箱即用的部署压缩包:
 
 ```bash
+# 默认打包当前配置与组件 -> 输出 vmess.tar.gz
 ./build_router_package.sh
-```
 
-可选覆盖:
-
-```bash
-BUSYBOX_SRC="/path/to/busybox_armv7l" \
-CFST_SRC_DIR="/path/to/cfst_dir" \
-OUT_TAR="/path/to/vmess.tar.gz" \
-./build_router_package.sh
+# 支持通过 SSOT 从 JSON 配置自动同步测速参数至路由器配置文件
+./build_router_package.sh --sync-from-config configs/config.local.json
 ```
 
-打包内容默认为:
+---
 
-- `vmess/busybox_armv7l`
-- `vmess/config_router.conf`
-- `vmess/scripts/router_local_update.sh`
-- `vmess/scripts/router_local_http.sh`
-- `vmess/cfst/*`
+## 5. Sub-Store 算子对接
 
----
+[`substore/operator_template.js`](file:///Users/aurora/Project/vmess-domain-rotator/substore/operator_template.js) 提供了高度灵活的动态节点注入能力:
 
-## 8. 注意事项
+### 5.1 工作模式与速度切换
+- `VALUE_SOURCE_MODE = "default"`:路由器优先;当路由器实测下载速度低于阈值(`ROUTER_MIN_SPEED_MB_PER_S = 5`)时,自动无缝切换至服务器模式。
+- `VALUE_SOURCE_MODE = "server_only"`:仅从服务器远端分支拉取。
+- `VALUE_SOURCE_MODE = "router_only"`:仅从局域网路由器 HTTP 拉取。
 
-1. 三种模式的配置不要混用。
-2. 只有服务器模式默认做 git 自动提交。
-3. `state.json` 需持久化,否则 fallback 不可用。
-4. 路由器模式请优先使用完整 BusyBox `httpd`,不要依赖系统精简 `httpd` / `nc`。
-5. 卸载脚本会自动清理对应服务用户的 `git safe.directory` 配置。
+### 5.2 URL 参数强制覆盖
+在 Sub-Store 订阅链接参数中传入参数即可实现强行注入(拥有最高优先级,绕过测速与模式判断):
+- `domain=my.preferred.com`:强制覆盖所有节点的地址为此域名或 IP。
+- `domain_override=false`:临时关闭覆盖,恢复自动选优逻辑。
 
 ---
 
-## 9. 自动化测试套件
-
-本仓库配备了基于标准库的轻量级自动化测试套件(零第三方库依赖,秒级运行),覆盖 Python 服务端核心、Shell 部署与发布脚本、路由器 BusyBox 脚本以及 Sub-Store 算子。
+## 6. 自动化测试与质量门禁
 
-### 9.1 运行全部测试
+仓库配备了完善的零依赖自动化测试套件(执行时间 < 6 秒):
 
 ```bash
+# 1. 运行全套 32 项自动化测试
 python3 -m unittest discover tests -v
-```
 
-### 9.2 测试套件结构与覆盖范围
+# 2. 全链路静态语法校验
+bash -n scripts/server/*.sh scripts/router/*.sh build_router_package.sh
+python3 -m py_compile scripts/domain_updater.py scripts/rotator/*.py tests/*.py
+node -c substore/operator_template.js
+```
 
-- **`tests/test_domain_updater.py`**:锁定与验证 Python 核心逻辑(选优评分极性、Hysteresis 迟滞防抖、黑名单复检、双轨容灾状态机、`cfst` 零速过滤与配置校验)。
-- **`tests/test_shell_lifecycle.py`**:锁定与验证系统级 Shell 脚本安全(`uninstall_debian.sh` 传空服务名防删库断言、`build_router_package.sh` 临时目录清理安全、`run_update_and_commit.sh` 退出码与 Token 传递安全)。
-- **`tests/test_router_scripts.py`**:测试路由器 BusyBox 模式脚本(文件原子性写入、测速参数传递与盲选零速 IP 过滤)。
-- **`tests/test_substore_operator.py`**:调用 Node.js 测试 `substore/operator_template.js`(类型安全断言、非法 HTML 过滤与数据源过期降级)。
+### 测试套件覆盖范围
 
+| 测试套件文件 | 核心覆盖断言与安全边界 |
+| :--- | :--- |
+| **`tests/test_server_mode_lifecycle.py`** | Server 模式全生命周期(API 选优、孤儿分支 Worktree 提交、自适应时间拦截、静默抑制、模式 A 历史滚动修剪) |
+| **`tests/test_cfst_local_lifecycle.py`** | 本地 `cfst` 模式全生命周期(CSV 解析、零速节点过滤、`-dd` 延迟免测排序、Fallback 容灾与路径防漂移契约) |
+| **`tests/test_domain_updater.py`** | Python 选优核心算法(`yd_score` 正向极性修复、平局决胜顺序、迟滞防抖 Hysteresis、黑名单复检) |
+| **`tests/test_shell_lifecycle.py`** | Shell 脚本生命周期安全(`uninstall_debian.sh` 防空服务名误删库、Token 动态环境零泄露、`ls-remote` 退出码精细判定) |
+| **`tests/test_router_scripts.py`** | 路由器脚本原子性(`.tmp.$$` 替换写入、测速过滤、SSOT 打包配置同步) |
+| **`tests/test_substore_operator.py`** | Sub-Store 脚本算子(XSS/非法 HTML 过滤、数据源超时降级、类型安全断言) |

+ 16 - 3
build_router_package.sh

@@ -8,9 +8,19 @@ PKG_ROOT_NAME=${PKG_ROOT_NAME:-vmess}
 OUT_TAR=${OUT_TAR:-"$APP_DIR/vmess.tar.gz"}
 TMP_DIR=${TMP_DIR:-"/tmp/vmess_pkg.$$"}
 
-ROUTER_CONF=${ROUTER_CONF:-"$APP_DIR/config_router.conf"}
-HTTP_SCRIPT=${HTTP_SCRIPT:-"$APP_DIR/scripts/router_local_http.sh"}
-UPDATE_SCRIPT=${UPDATE_SCRIPT:-"$APP_DIR/scripts/router_local_update.sh"}
+ROUTER_CONF=${ROUTER_CONF:-}
+if [ -z "$ROUTER_CONF" ]; then
+  if [ -f "$APP_DIR/configs/config_router.conf" ]; then
+    ROUTER_CONF="$APP_DIR/configs/config_router.conf"
+  else
+    ROUTER_CONF="$APP_DIR/config_router.conf"
+  fi
+elif [ ! -f "$ROUTER_CONF" ] && [ -f "$APP_DIR/configs/$ROUTER_CONF" ]; then
+  ROUTER_CONF="$APP_DIR/configs/$ROUTER_CONF"
+fi
+
+HTTP_SCRIPT=${HTTP_SCRIPT:-"$APP_DIR/scripts/router/router_local_http.sh"}
+UPDATE_SCRIPT=${UPDATE_SCRIPT:-"$APP_DIR/scripts/router/router_local_update.sh"}
 
 # Defaults aligned with current repo layout.
 BUSYBOX_SRC=${BUSYBOX_SRC:-"$APP_DIR/busybox/busybox-armv7l"}
@@ -239,6 +249,9 @@ fi
 mkdir -p "$TMP_DIR/$PKG_ROOT_NAME/scripts" "$TMP_DIR/$PKG_ROOT_NAME/cfst"
 
 if [ -n "$SYNC_FROM_CONFIG" ]; then
+  if [ ! -f "$SYNC_FROM_CONFIG" ] && [ -f "$APP_DIR/configs/$SYNC_FROM_CONFIG" ]; then
+    SYNC_FROM_CONFIG="$APP_DIR/configs/$SYNC_FROM_CONFIG"
+  fi
   require_file "$SYNC_FROM_CONFIG"
   log "[pkg] syncing router config from $SYNC_FROM_CONFIG"
   sync_conf_from_json "$ROUTER_CONF" "$SYNC_FROM_CONFIG" "$TMP_DIR/$PKG_ROOT_NAME/config_router.conf"

+ 0 - 0
config.local.json → configs/config.local.json


+ 0 - 0
config.server.json → configs/config.server.json


+ 0 - 0
config_router.conf → configs/config_router.conf


+ 15 - 3
docs/reviews/ARCHITECTURE_REVIEW_UNIFIED.md

@@ -757,14 +757,14 @@ function extractValue(rawText, format, jsonKeys, maxAgeSec) {
 | 测试文件 | 覆盖范围与断言目标 | 用例数 | 执行耗时 | 测试状态 |
 | :--- | :--- | :--- | :--- | :--- |
 | [`tests/test_domain_updater.py`](./tests/test_domain_updater.py) | • P0 极性反转真实数据排序回归与平局决胜<br>• run_notify 标准输出隔离与超时保护<br>• 异常回退包含 domain/ip 键<br>• 黑名单 last_good 禁止回退复活<br>• 本地 cfst 过滤 0.00 下载速度<br>• `--result-json-file` IPC 解耦<br>• validate_config 严格顶层结构校验<br>• **[方案B] rotator 子包解耦与 Facade 契约完整性** | 8 项 | ~0.15s | **✅ 全部通过 (PASS)** |
-| [`tests/test_shell_lifecycle.py`](./tests/test_shell_lifecycle.py) | • `uninstall_debian.sh` 空服务名安全校验(严禁 `rm -rf /var/lib/`)<br>• `build_router_package.sh` 延迟注册 trap 防止误删工作目录<br>• `run_update_and_commit.sh` 采用 `GIT_CONFIG_*` 环境变量传递 Token(argv 零暴露)<br>• 严格区分 `git ls-remote` 退出码 `2` vs `128`<br>• 回退静默跳过噪音 Git commit<br>• **[方案B] 模式A Git 历史滚动修剪(50 commit 滚动保留)端到端验证** | 6 项 | ~0.75s | **✅ 全部通过 (PASS)** |
+| [`tests/test_shell_lifecycle.py`](./tests/test_shell_lifecycle.py) | • `uninstall_debian.sh` 空服务名安全校验(严禁 `rm -rf /var/lib/`)<br>• `build_router_package.sh` 延迟注册 trap 防止误删工作目录<br>• `run_update_and_commit.sh` 采用 `GIT_CONFIG_*` 环境变量传递 Token(argv 零暴露)<br>• 严格区分 `git ls-remote` 退出码 `2` vs `128`<br>• 回退静默跳过噪音 Git commit<br>• **[方案B] 模式A Git 历史滚动修剪(50 commit 滚动保留)端到端验证**<br>• **[结构优化] scripts 分层物理结构与 configs/ 目录结构集中收敛完整性断言** | 7 项 | ~0.75s | **✅ 全部通过 (PASS)** |
 | [`tests/test_router_scripts.py`](./tests/test_router_scripts.py) | • `router_local_update.sh` `.tmp.$$` + `mv -f` 原子替换写入<br>• 测速结果优先过滤 `download_speed > 0`<br>• 纠偏初审报告关于 `-dt/-dn/-dd` 参数支持情况<br>• **[方案B] `build_router_package.sh --sync-from-config` SSOT 单源参数自动投影验证** | 4 项 | ~0.50s | **✅ 全部通过 (PASS)** |
 | [`tests/test_substore_operator.py`](./tests/test_substore_operator.py) | • `extractValue()` 严格类型守卫(拒绝非字符串对象强转为 `[object Object]`)<br>• 域名 (RFC 1123) 与 IPv4/IPv6 正则校验(拒绝 HTML 报错与非法字符)<br>• `LOCAL_MAX_AGE_SEC` (24h) 本地路由器源超时自动降级 | 3 项 | ~0.06s | **✅ 全部通过 (PASS)** |
 
 ### 9.2 运行命令
 ```bash
 python3 -m unittest discover tests -v
-# 输出:Ran 21 tests in 1.417s, OK
+# 输出:Ran 22 tests in 1.419s, OK
 ```
 
 ---
@@ -776,7 +776,7 @@ python3 -m unittest discover tests -v
 ### 10.1 模式 A:Git 孤儿分支滚动修剪与优雅降级机制
 - **问题根源**:原调度脚本每次优选都无条件向 `runtime-state` 分支追加 commit,经过数月运行会堆积上万个 commit,导致远程仓库体积虚高、拉取缓慢。
 - **重构落地**:
-  - 在 [`scripts/run_update_and_commit.sh`](./scripts/run_update_and_commit.sh) 引入 `GIT_MAX_STATE_COMMITS`(默认 50 次提交,可由用户自定义)。
+  - 在 [`scripts/server/run_update_and_commit.sh`](./scripts/server/run_update_and_commit.sh) 引入 `GIT_MAX_STATE_COMMITS`(默认 50 次提交,可由用户自定义)。
   - 当历史 commit 数超出阈值时,自动提取保留基线 tree,创建 baseline root commit 并执行 `rebase --onto`,精简历史。
   - **自适应容错**:优先使用 `--force-with-lease` 推送;若远端存在分支保护策略拒绝 force-push,脚本自动捕获并从 untrimmed 备份分支无损回滚,平滑降级为普通追加推送,绝不中断服务。
   - **跨平台稳健性**:全面消除 `mapfile`(避免 macOS / 旧系统 Bash 3.2 不支持)与 `set -u` 展开空数组崩溃隐患,兼容任意 Linux/Debian/macOS 环境。
@@ -801,3 +801,15 @@ python3 -m unittest discover tests -v
     - `main.py`:主轮换工作流编排与 CLI 参数解析
   - [`scripts/domain_updater.py`](./scripts/domain_updater.py) 重构为精简的外观门面(Facade),全面重新导出所有符号,保持 100% 向后兼容,现存 systemd、cron 及自动化测试零修改直接运行。
 
+### 10.4 脚本目录按平台拆分与配置统一收纳(`configs/`)
+- **问题根源**:
+  1. `scripts/` 目录平铺堆砌了服务端 Debian 安装/卸载脚本、路由器嵌入式更新/HTTP 脚本、Python 脚本等,职责界限模糊。
+  2. 根目录下散落了多个配置文件(`config.server.json`, `config.local.json`, `config_router.conf`),容易杂乱。
+- **重构落地**:
+  - **统一配置仓**:建立 [`configs/`](./configs/) 目录,将服务端、本地与路由器配置文件集中收纳;根目录彻底清爽无散落文件,项目内所有命令默认引用统一更新为 `configs/`,并在 Python 与 Shell 脚本内建自适应双向探测逻辑(优先探测传入路径,无路径时依次探测 `configs/` 目录)。
+  - **物理分层与直接引用(无冗余包装脚本)**:
+    - 创建 [`scripts/server/`](./scripts/server/):收纳 Linux / Debian 服务端运维脚本(`run_update_and_commit.sh`, `install_debian.sh`, `uninstall_debian.sh`)。
+    - 创建 [`scripts/router/`](./scripts/router/):收纳路由器 BusyBox 嵌入式运行时脚本(`router_local_update.sh`, `router_local_http.sh`)。
+    - `scripts/` 顶层不再保留任何中间转发包装脚本(thin forwarder wrappers),所有文档、systemd 单元模板及打包工具均直接统一指向新结构路径,架构极致纯粹。
+
+

+ 5 - 5
docs/workflow.md

@@ -4,12 +4,12 @@
 
 ```mermaid
 flowchart TD
-    A1[systemd timer / manual run] --> A2[run_update_and_commit.sh config.server.json]
-    A3[manual updater run] --> A4[domain_updater.py --config config.server.json]
+    A1[systemd timer / manual run] --> A2[scripts/server/run_update_and_commit.sh configs/config.server.json]
+    A3[manual updater run] --> A4[domain_updater.py --config configs/config.server.json]
 
     subgraph U1["domain_updater.py / server mode"]
       direction TB
-      B1[read config.server.json] --> B2[resolve output paths]
+      B1[read configs/config.server.json] --> B2[resolve output paths]
       B2 --> B3[read runtime/state.json]
       B3 --> B4[fetch API payload]
       B4 --> B5[parse candidates]
@@ -43,11 +43,11 @@ flowchart TD
 
 ```mermaid
 flowchart TD
-    A1[manual run] --> A2[domain_updater.py --config config.local.json]
+    A1[manual run] --> A2[domain_updater.py --config configs/config.local.json]
 
     subgraph U2["domain_updater.py / cfst_local mode"]
       direction TB
-      B1[read config.local.json] --> B2[resolve output paths]
+      B1[read configs/config.local.json] --> B2[resolve output paths]
       B2 --> B3[run local cfst]
       B3 --> B4[parse result.csv]
       B4 --> B5[apply domain filter]

+ 3 - 1
scripts/rotator/config.py

@@ -85,7 +85,9 @@ def build_output_settings(output_cfg, config_path_abs):
         )
 
     runtime_dir_cfg = output_cfg.get("runtime_dir", "./runtime")
-    runtime_dir = resolve_path(os.path.dirname(config_path_abs), runtime_dir_cfg)
+    config_dir = os.path.dirname(os.path.abspath(config_path_abs))
+    base_dir = os.path.dirname(config_dir) if os.path.basename(config_dir) == "configs" else config_dir
+    runtime_dir = resolve_path(base_dir, runtime_dir_cfg)
 
     selected_text_name = output_cfg.get("selected_value_file", "current_domain.txt")
     selected_json_name = output_cfg.get("selected_value_json", "current_domain.json")

+ 10 - 1
scripts/rotator/main.py

@@ -226,7 +226,7 @@ def run_updater(config_path_abs, print_settings_only=False, result_json_file="")
 
 def main():
     ap = argparse.ArgumentParser(description="Auto select preferred endpoint value")
-    ap.add_argument("--config", default="config.server.json", help="Path to config JSON")
+    ap.add_argument("--config", default="configs/config.server.json", help="Path to config JSON")
     ap.add_argument(
         "--print-output-settings",
         action="store_true",
@@ -240,6 +240,15 @@ def main():
     args = ap.parse_args()
 
     config_path_abs = os.path.abspath(args.config)
+    if not os.path.exists(config_path_abs):
+        candidate_configs = os.path.abspath(os.path.join("configs", args.config))
+        if os.path.exists(candidate_configs):
+            config_path_abs = candidate_configs
+        else:
+            repo_configs = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "configs", os.path.basename(args.config)))
+            if os.path.exists(repo_configs):
+                config_path_abs = repo_configs
+
     rc = run_updater(
         config_path_abs=config_path_abs,
         print_settings_only=args.print_output_settings,

+ 3 - 2
scripts/rotator/sources.py

@@ -62,9 +62,10 @@ def extract_records(payload, record_mapping):
 
 def load_cfst_rows(cfg, config_path_abs):
     cfst_cfg = cfg.get("cfst_local", {})
-    config_dir = os.path.dirname(config_path_abs)
+    config_dir = os.path.dirname(os.path.abspath(config_path_abs))
+    base_dir = os.path.dirname(config_dir) if os.path.basename(config_dir) == "configs" else config_dir
 
-    work_dir = resolve_path(config_dir, cfst_cfg.get("work_dir", "./cfst"))
+    work_dir = resolve_path(base_dir, cfst_cfg.get("work_dir", "./cfst"))
     binary_path = resolve_path(work_dir, cfst_cfg.get("binary", "./cfst"))
     result_file = resolve_path(work_dir, cfst_cfg.get("result_file", "result.csv"))
     encoding = str(cfst_cfg.get("encoding", "utf-8")).strip() or "utf-8"

+ 16 - 2
scripts/router_local_http.sh → scripts/router/router_local_http.sh

@@ -14,8 +14,22 @@ log_err() {
 }
 
 SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-APP_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
-CONFIG_PATH=${1:-"$APP_DIR/config_router.conf"}
+if [ -f "$SCRIPT_DIR/../config_router.conf" ] || [ -f "$SCRIPT_DIR/../configs/config_router.conf" ]; then
+  APP_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
+else
+  APP_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
+fi
+
+CONFIG_PATH=${1:-}
+if [ -z "$CONFIG_PATH" ]; then
+  if [ -r "$APP_DIR/configs/config_router.conf" ]; then
+    CONFIG_PATH="$APP_DIR/configs/config_router.conf"
+  else
+    CONFIG_PATH="$APP_DIR/config_router.conf"
+  fi
+elif [ ! -r "$CONFIG_PATH" ] && [ -r "$APP_DIR/configs/$CONFIG_PATH" ]; then
+  CONFIG_PATH="$APP_DIR/configs/$CONFIG_PATH"
+fi
 CONFIG_DIR=$(CDPATH= cd -- "$(dirname -- "$CONFIG_PATH")" && pwd)
 
 if [ ! -r "$CONFIG_PATH" ]; then

+ 16 - 2
scripts/router_local_update.sh → scripts/router/router_local_update.sh

@@ -14,8 +14,22 @@ log_err() {
 }
 
 SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-APP_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
-CONFIG_PATH=${1:-"$APP_DIR/config_router.conf"}
+if [ -f "$SCRIPT_DIR/../config_router.conf" ] || [ -f "$SCRIPT_DIR/../configs/config_router.conf" ]; then
+  APP_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
+else
+  APP_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
+fi
+
+CONFIG_PATH=${1:-}
+if [ -z "$CONFIG_PATH" ]; then
+  if [ -r "$APP_DIR/configs/config_router.conf" ]; then
+    CONFIG_PATH="$APP_DIR/configs/config_router.conf"
+  else
+    CONFIG_PATH="$APP_DIR/config_router.conf"
+  fi
+elif [ ! -r "$CONFIG_PATH" ] && [ -r "$APP_DIR/configs/$CONFIG_PATH" ]; then
+  CONFIG_PATH="$APP_DIR/configs/$CONFIG_PATH"
+fi
 
 if [ ! -r "$CONFIG_PATH" ]; then
   log_err "[router-local] config not found: $CONFIG_PATH"

+ 25 - 13
scripts/install_debian.sh → scripts/server/install_debian.sh

@@ -36,7 +36,7 @@ GIT_CREDENTIALS_FILE=""
 
 usage() {
 	cat <<'EOF'
-Usage: sudo bash scripts/install_debian.sh [options]
+Usage: sudo bash scripts/server/install_debian.sh [options]
 
 Default behavior:
 - Uses current git repository directory as working directory (in-place mode)
@@ -52,7 +52,7 @@ Options:
  --peak-tz <timezone>           Peak period timezone (default: Asia/Shanghai)
  --peak-interval <value>        Update interval during peak (default: 10min)
  --offpeak-interval <value>     Update interval during offpeak (default: 30min)
- --config <path>                Config file path (default: <repo>/config.server.json)
+ --config <path>                Config file path (default: <repo>/configs/config.server.json)
  --git-push <0|1>               Enable/disable push to remote (default: 1)
  --git-push-remote <name>       Remote name for push (default: origin)
  --git-http-username <u>        Username for HTTPS auth (default: git)
@@ -64,11 +64,11 @@ Options:
  -h, --help                     Show help
 
 Examples:
- sudo bash scripts/install_debian.sh
- sudo bash scripts/install_debian.sh --config /opt/vmess-domain-rotator/config.server.json
- sudo bash scripts/install_debian.sh --peak-start 18 --peak-end 23
- sudo bash scripts/install_debian.sh --peak-interval 5min --offpeak-interval 15min
- sudo bash scripts/install_debian.sh --git-push 0
+ sudo bash scripts/server/install_debian.sh
+ sudo bash scripts/server/install_debian.sh --config /opt/vmess-domain-rotator/configs/config.server.json
+ sudo bash scripts/server/install_debian.sh --peak-start 18 --peak-end 23
+ sudo bash scripts/server/install_debian.sh --peak-interval 5min --offpeak-interval 15min
+ sudo bash scripts/server/install_debian.sh --git-push 0
 EOF
 }
 
@@ -233,7 +233,7 @@ if ! command -v runuser >/dev/null 2>&1; then
 	exit 1
 fi
 
-SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
 if ! git -C "$SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
 	log_err "Error: Current directory is not a git repository."
 	log_err "This script must be run from within a git repository."
@@ -242,9 +242,21 @@ fi
 APP_DIR="$SOURCE_DIR"
 
 if [[ -z "$CONFIG_PATH" ]]; then
-	CONFIG_PATH="${APP_DIR}/config.server.json"
+	if [[ -r "${APP_DIR}/configs/config.server.json" ]]; then
+		CONFIG_PATH="${APP_DIR}/configs/config.server.json"
+	else
+		CONFIG_PATH="${APP_DIR}/config.server.json"
+	fi
 elif [[ "$CONFIG_PATH" != /* ]]; then
-	CONFIG_PATH="${APP_DIR}/${CONFIG_PATH}"
+	if [[ -r "${APP_DIR}/configs/${CONFIG_PATH}" ]]; then
+		CONFIG_PATH="${APP_DIR}/configs/${CONFIG_PATH}"
+	elif [[ -r "${APP_DIR}/${CONFIG_PATH}" ]]; then
+		CONFIG_PATH="${APP_DIR}/${CONFIG_PATH}"
+	elif [[ -r "${APP_DIR}/configs/$(basename "$CONFIG_PATH")" ]]; then
+		CONFIG_PATH="${APP_DIR}/configs/$(basename "$CONFIG_PATH")"
+	else
+		CONFIG_PATH="${APP_DIR}/${CONFIG_PATH}"
+	fi
 fi
 
 if [[ ! -r "$CONFIG_PATH" ]]; then
@@ -371,7 +383,7 @@ RUNTIME_DIR="$(/usr/bin/python3 "${APP_DIR}/scripts/domain_updater.py" --config
 validate_path "RUNTIME_DIR" "$RUNTIME_DIR"
 
 mkdir -p "$RUNTIME_DIR"
-chmod +x "$APP_DIR/scripts/run_update_and_commit.sh" || true
+chmod +x "$APP_DIR/scripts/server/run_update_and_commit.sh" || true
 chown -R "$RUN_USER:$RUN_GROUP" "$RUNTIME_DIR"
 
 SERVICE_STATE_DIR="/var/lib/${SERVICE_NAME}"
@@ -470,7 +482,7 @@ SyslogIdentifier=${SERVICE_NAME}
 WorkingDirectory=${APP_DIR}
 EnvironmentFile=-${ENV_FILE}
 UMask=0077
-ExecStart=/bin/bash "${APP_DIR}/scripts/run_update_and_commit.sh" "${CONFIG_PATH}"
+ExecStart=/bin/bash "${APP_DIR}/scripts/server/run_update_and_commit.sh" "${CONFIG_PATH}"
 EOF
 
 cat >"/etc/systemd/system/${SERVICE_NAME}.timer" <<EOF
@@ -513,5 +525,5 @@ log "Commands:"
 log "  Check status: systemctl status ${SERVICE_NAME}.timer"
 log "  View logs:    journalctl -u ${SERVICE_NAME}.service -n 50 --no-pager"
 log "  Manual run:   sudo systemctl start ${SERVICE_NAME}.service"
-log "  Force commit: sudo -u ${RUN_USER} /bin/bash ${APP_DIR}/scripts/run_update_and_commit.sh --force-commit ${CONFIG_PATH}"
+log "  Force commit: sudo -u ${RUN_USER} /bin/bash ${APP_DIR}/scripts/server/run_update_and_commit.sh --force-commit ${CONFIG_PATH}"
 log ""

+ 15 - 2
scripts/run_update_and_commit.sh → scripts/server/run_update_and_commit.sh

@@ -20,7 +20,7 @@ log_err() {
 }
 
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-DEFAULT_APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+DEFAULT_APP_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
 APP_DIR="${APP_DIR:-$(git -C "$DEFAULT_APP_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$DEFAULT_APP_DIR")}"
 
 PYTHON_BIN="${PYTHON_BIN:-}"
@@ -41,7 +41,20 @@ if [[ ! "$force_commit" =~ ^[01]$ ]]; then
   log "[vmess-domain-rotator] invalid GIT_FORCE_COMMIT=${force_commit}, expected 0 or 1"
   exit 1
 fi
-CONFIG_PATH="${1:-${APP_DIR}/config.server.json}"
+if [[ -z "${1:-}" ]]; then
+  if [[ -r "${APP_DIR}/configs/config.server.json" ]]; then
+    CONFIG_PATH="${APP_DIR}/configs/config.server.json"
+  else
+    CONFIG_PATH="${APP_DIR}/config.server.json"
+  fi
+else
+  CONFIG_PATH="$1"
+  if [[ ! -r "$CONFIG_PATH" ]] && [[ -r "${APP_DIR}/configs/${CONFIG_PATH}" ]]; then
+    CONFIG_PATH="${APP_DIR}/configs/${CONFIG_PATH}"
+  elif [[ ! -r "$CONFIG_PATH" ]] && [[ -r "${APP_DIR}/configs/$(basename "$CONFIG_PATH")" ]]; then
+    CONFIG_PATH="${APP_DIR}/configs/$(basename "$CONFIG_PATH")"
+  fi
+fi
 
 # --- adaptive interval check ---
 if [[ "$force_commit" != "1" ]]; then

+ 3 - 3
scripts/uninstall_debian.sh → scripts/server/uninstall_debian.sh

@@ -18,7 +18,7 @@ REMOVE_AUTH_FILES="1"
 
 usage() {
 	cat <<'EOF'
-Usage: sudo bash scripts/uninstall_debian.sh
+Usage: sudo bash scripts/server/uninstall_debian.sh
 
 This script will:
 - Stop and disable the systemd timer and service
@@ -32,8 +32,8 @@ Options:
  -h, --help            Show help
 
 Examples:
- sudo bash scripts/uninstall_debian.sh
- sudo bash scripts/uninstall_debian.sh --keep-auth-files
+ sudo bash scripts/server/uninstall_debian.sh
+ sudo bash scripts/server/uninstall_debian.sh --keep-auth-files
 EOF
 }
 

+ 238 - 0
tests/test_cfst_local_lifecycle.py

@@ -0,0 +1,238 @@
+import csv
+import json
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+
+PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
+
+from rotator.main import run_updater
+
+
+class TestCfstLocalLifecycle(unittest.TestCase):
+    """Local Python cfst_local 模式生命周期与选优逻辑测试套件"""
+
+    def setUp(self):
+        self.tmp_dir = tempfile.TemporaryDirectory()
+        self.work_dir = pathlib.Path(self.tmp_dir.name)
+
+    def tearDown(self):
+        self.tmp_dir.cleanup()
+
+    def _create_mock_cfst_env(self, csv_rows: list, run_args=None, skip_run=True, last_good=None) -> tuple[pathlib.Path, pathlib.Path]:
+        """创建隔离的 cfst_local 运行目录、配置文件与模拟 CSV 数据"""
+        cfst_dir = self.work_dir / "cfst_test"
+        cfst_dir.mkdir(parents=True, exist_ok=True)
+        csv_path = cfst_dir / "result.csv"
+
+        with open(csv_path, "w", encoding="utf-8", newline="") as f:
+            writer = csv.writer(f)
+            writer.writerow(["IP 地址", "已发送", "已接收", "丢包率", "平均延迟", "下载速度 (MB/s)", "地区码"])
+            for row in csv_rows:
+                writer.writerow(row)
+
+        runtime_dir = self.work_dir / "runtime"
+        runtime_dir.mkdir(parents=True, exist_ok=True)
+        if last_good:
+            (runtime_dir / "state.json").write_text(json.dumps({
+                "last_good_ip": last_good,
+                "updated_at": "2026-06-01T00:00:00Z"
+            }))
+
+        config = {
+            "source": {"type": "cfst_local"},
+            "cfst_local": {
+                "work_dir": str(cfst_dir),
+                "binary": "./cfst",
+                "run_args": run_args or ["-f", "ip.txt", "-o", "result.csv", "-p", "10"],
+                "result_file": "result.csv",
+                "run_timeout_sec": 60,
+                "skip_run": skip_run,
+                "header_rows": 1,
+                "columns": {
+                    "ip": 0,
+                    "sent": 1,
+                    "received": 2,
+                    "loss_rate": 3,
+                    "avg_latency": 4,
+                    "download_speed": 5,
+                    "region": 6,
+                },
+            },
+            "domain_filter": {
+                "include_suffixes": [],
+                "exclude_regex": [],
+            },
+            "selection": {"top_n": 3},
+            "output": {
+                "runtime_dir": str(runtime_dir),
+                "selected_value_file": "current_ip.txt",
+                "selected_value_json": "current_ip.json",
+                "selected_value_json_key": "ip",
+                "state_file": "state.json",
+                "state_last_good_key": "last_good_ip",
+                "export_vars_file": "substore_vars.json",
+                "substore_value_key": "AUTO_CFIP",
+            },
+            "notify": {"command": ""},
+        }
+
+        config_file = self.work_dir / "test_local.json"
+        config_file.write_text(json.dumps(config, indent=2))
+        return config_file, runtime_dir
+
+    def test_cfst_local_standard_run_and_output_artifacts(self):
+        """[全流程 1] 标准 cfst_local 流程:解析 CSV -> 过滤选优 -> 4 个 runtime 文件落盘 -> Sub-Store 契约输出"""
+        rows = [
+            ["172.64.147.31", "4", "4", "0.00", "22.14", "15.30", "HKG"],
+            ["198.41.209.213", "4", "4", "0.00", "21.87", "12.80", "HKG"],
+            ["104.19.38.34", "4", "4", "0.00", "22.82", "8.50", "HKG"],
+            ["104.16.254.215", "4", "4", "0.00", "23.29", "5.20", "HKG"],
+        ]
+        config_file, runtime_dir = self._create_mock_cfst_env(rows)
+
+        rc = run_updater(str(config_file))
+        self.assertEqual(rc, 0)
+
+        # 验证 4 份落盘文件
+        txt_path = runtime_dir / "current_ip.txt"
+        json_path = runtime_dir / "current_ip.json"
+        vars_path = runtime_dir / "substore_vars.json"
+        state_path = runtime_dir / "state.json"
+
+        self.assertTrue(txt_path.exists())
+        self.assertTrue(json_path.exists())
+        self.assertTrue(vars_path.exists())
+        self.assertTrue(state_path.exists())
+
+        ip_json = json.loads(json_path.read_text())
+        self.assertEqual(ip_json["status"], "ok")
+        self.assertEqual(ip_json["ip"], "172.64.147.31")
+        self.assertEqual(ip_json["source_type"], "cfst_local")
+        self.assertEqual(ip_json["source_count"], 4)
+
+        self.assertEqual(txt_path.read_text().strip(), "172.64.147.31")
+
+        ip_json = json.loads(json_path.read_text())
+        self.assertEqual(ip_json["ip"], "172.64.147.31")
+        self.assertEqual(ip_json["status"], "ok")
+        self.assertEqual(len(ip_json["top_candidates"]), 3)
+        self.assertEqual(ip_json["top_candidates"][0]["download_speed"], "15.30")
+        self.assertEqual(ip_json["top_candidates"][0]["region"], "HKG")
+
+        vars_json = json.loads(vars_path.read_text())
+        self.assertEqual(vars_json["AUTO_CFIP"], "172.64.147.31")
+        self.assertEqual(vars_json["STATUS"], "ok")
+
+        state_json = json.loads(state_path.read_text())
+        self.assertEqual(state_json["last_good_ip"], "172.64.147.31")
+        self.assertEqual(state_json["status"], "ok")
+
+    def test_cfst_local_filters_zero_download_speed_and_prefers_positive(self):
+        """[边界 2] 0 测速过滤:前两个节点测速为 0.00,自动跳过并选出首个 download_speed > 0 的健康节点"""
+        rows = [
+            ["1.1.1.1", "4", "4", "0.00", "15.00", "0.00", "N/A"],
+            ["2.2.2.2", "4", "4", "0.00", "18.00", "0.00", "N/A"],
+            ["3.3.3.3", "4", "4", "0.00", "25.00", "9.80", "HKG"],
+            ["4.4.4.4", "4", "4", "0.00", "28.00", "6.20", "SJC"],
+        ]
+        config_file, runtime_dir = self._create_mock_cfst_env(rows)
+
+        rc = run_updater(str(config_file))
+        self.assertEqual(rc, 0)
+        res = json.loads((runtime_dir / "current_ip.json").read_text())
+        self.assertEqual(res["status"], "ok")
+        self.assertEqual(res["ip"], "3.3.3.3", "必须自动过滤 0 速度死节点,挑选 3.3.3.3")
+
+        candidates = [c["ip"] for c in res["top_candidates"]]
+        self.assertNotIn("1.1.1.1", candidates, "0 测速节点不应出现在候选列表中")
+        self.assertNotIn("2.2.2.2", candidates, "0 测速节点不应出现在候选列表中")
+        self.assertIn("3.3.3.3", candidates)
+        self.assertIn("4.4.4.4", candidates)
+
+    def test_cfst_local_all_zero_speed_triggers_last_good_fallback(self):
+        """[容灾 3] 全员测速 0.00 容灾回退:所有节点均不可用时,平滑回退至 state.json 中的 last_good_ip"""
+        rows = [
+            ["1.1.1.1", "4", "4", "0.00", "15.00", "0.00", "N/A"],
+            ["2.2.2.2", "4", "4", "0.00", "18.00", "0.00", "N/A"],
+        ]
+        config_file, runtime_dir = self._create_mock_cfst_env(rows, last_good="198.51.100.88")
+
+        rc = run_updater(str(config_file))
+        self.assertEqual(rc, 0)
+        res = json.loads((runtime_dir / "current_ip.json").read_text())
+        self.assertEqual(res["status"], "fallback_last_good")
+        self.assertEqual(res["ip"], "198.51.100.88")
+
+        # 检查落盘文件
+        txt_path = runtime_dir / "current_ip.txt"
+        self.assertEqual(txt_path.read_text().strip(), "198.51.100.88")
+        vars_path = runtime_dir / "substore_vars.json"
+        vars_json = json.loads(vars_path.read_text())
+        self.assertEqual(vars_json["AUTO_CFIP"], "198.51.100.88")
+        self.assertEqual(vars_json["STATUS"], "fallback_last_good")
+
+    def test_cfst_local_disable_download_flag_dd(self):
+        """[参数 4] -dd 禁用下载测速模式:配置 -dd 时,按延迟排序,不因 download_speed == 0.00 误杀"""
+        rows = [
+            ["10.0.0.1", "4", "4", "0.00", "12.00", "0.00", "N/A"],
+            ["10.0.0.2", "4", "4", "0.00", "15.00", "0.00", "N/A"],
+        ]
+        # 在 run_args 中注入 -dd
+        config_file, runtime_dir = self._create_mock_cfst_env(rows, run_args=["-f", "ip.txt", "-dd", "-o", "result.csv"])
+
+        rc = run_updater(str(config_file))
+        self.assertEqual(rc, 0)
+        res = json.loads((runtime_dir / "current_ip.json").read_text())
+        self.assertEqual(res["status"], "ok")
+        self.assertEqual(res["ip"], "10.0.0.1", "禁用下载测速模式下,按延迟排序取第一名")
+
+    def test_cfst_local_filter_exclude_regex(self):
+        """[过滤 5] 正则过滤排除指定 IP 段"""
+        rows = [
+            ["172.64.10.1", "4", "4", "0.00", "20.00", "20.00", "HKG"],
+            ["104.16.10.1", "4", "4", "0.00", "22.00", "18.00", "HKG"],
+        ]
+        config_file, runtime_dir = self._create_mock_cfst_env(rows)
+
+        # 增加正则过滤:排除 172.64.*
+        with open(config_file, "r", encoding="utf-8") as f:
+            cfg = json.load(f)
+        cfg["domain_filter"]["exclude_regex"] = [r"^172\.64\."]
+        with open(config_file, "w", encoding="utf-8") as f:
+            json.dump(cfg, f, indent=2)
+
+        rc = run_updater(str(config_file))
+        self.assertEqual(rc, 0)
+        res = json.loads((runtime_dir / "current_ip.json").read_text())
+        self.assertEqual(res["status"], "ok")
+        self.assertEqual(res["ip"], "104.16.10.1", "172.64.* 被排除,选取 104.16.10.1")
+
+    def test_cfst_local_actual_repo_config_and_output_paths(self):
+        """[契约 6] 验证仓库实际 configs/config.local.json 的输出路径解析与落盘契约"""
+        real_cfg_path = PROJECT_ROOT / "configs" / "config.local.json"
+        self.assertTrue(real_cfg_path.exists())
+
+        cmd = [sys.executable, str(PROJECT_ROOT / "scripts" / "domain_updater.py"), "--config", str(real_cfg_path), "--print-output-settings"]
+        out = subprocess.check_output(cmd, text=True).strip()
+        settings = json.loads(out)
+
+        # 必须锚定在项目根目录 /runtime,严禁出现 /configs/runtime
+        expected_runtime_dir = str(PROJECT_ROOT / "runtime")
+        self.assertEqual(settings["runtime_dir"], expected_runtime_dir)
+        self.assertEqual(settings["selected_text_path"], str(PROJECT_ROOT / "runtime" / "current_ip.txt"))
+        self.assertEqual(settings["selected_json_path"], str(PROJECT_ROOT / "runtime" / "current_ip.json"))
+        self.assertEqual(settings["state_path"], str(PROJECT_ROOT / "runtime" / "state.json"))
+        self.assertEqual(settings["vars_path"], str(PROJECT_ROOT / "runtime" / "substore_vars.json"))
+        self.assertEqual(settings["selected_json_key"], "ip")
+        self.assertEqual(settings["state_last_good_key"], "last_good_ip")
+        self.assertEqual(settings["vars_value_key"], "AUTO_CFIP")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 5 - 2
tests/test_domain_updater.py

@@ -31,7 +31,7 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
         3. 恶化后 fastsoso (74ms) 得分劣于 competitor (49ms),competitor 成功登顶第 1
         4. 平局时 tie_breakers 优先选用 yd_score 降序(健康分高者胜出)
         """
-        config_path = PROJECT_ROOT / "config.server.json"
+        config_path = PROJECT_ROOT / "configs" / "config.server.json"
         with open(config_path, "r", encoding="utf-8") as f:
             cfg = json.load(f)
 
@@ -262,11 +262,14 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
         cmd = [
             sys.executable,
             str(PROJECT_ROOT / "scripts" / "domain_updater.py"),
-            "--config", str(PROJECT_ROOT / "config.server.json"),
+            "--config", str(PROJECT_ROOT / "configs" / "config.server.json"),
             "--print-output-settings",
         ]
         proc = subprocess.run(cmd, capture_output=True, text=True)
         self.assertEqual(proc.returncode, 0)
+        settings = json.loads(proc.stdout)
+        self.assertEqual(settings["runtime_dir"], str(PROJECT_ROOT / "runtime"))
+        self.assertNotIn("configs/runtime", settings["runtime_dir"])
 
     def test_modular_rotator_package_architecture_and_facade_integrity(self):
         """[方案B 架构重构验证] 验证 rotator 模块化子包解耦与 domain_updater.py 外观门面契约完整性"""

+ 3 - 3
tests/test_router_scripts.py

@@ -9,7 +9,7 @@ class TestRouterScriptsBugsAndContracts(unittest.TestCase):
 
     def test_p1_router_local_update_atomic_file_write(self):
         """[P1/P2 修复验证] 验证 router_local_update.sh 采用 .tmp.$$ + mv -f 原子替换写入文件"""
-        script_path = PROJECT_ROOT / "scripts" / "router_local_update.sh"
+        script_path = PROJECT_ROOT / "scripts" / "router" / "router_local_update.sh"
         content = script_path.read_text()
 
         self.assertIn("atomic_write_file()", content)
@@ -18,7 +18,7 @@ class TestRouterScriptsBugsAndContracts(unittest.TestCase):
 
     def test_p2_router_local_update_filters_zero_speed(self):
         """[P2 修复验证] 验证 router_local_update.sh 在开启测速时优先挑选 download_speed > 0 的候选行"""
-        script_path = PROJECT_ROOT / "scripts" / "router_local_update.sh"
+        script_path = PROJECT_ROOT / "scripts" / "router" / "router_local_update.sh"
         content = script_path.read_text()
 
         self.assertIn("speed = trim($6) + 0", content)
@@ -26,7 +26,7 @@ class TestRouterScriptsBugsAndContracts(unittest.TestCase):
 
     def test_recheck_correction_router_cfst_params_already_supported(self):
         """[复核纠偏] 验证初审报告关于 'router_local_update.sh 缺少 -dt/-dn/-dd 参数' 系虚假误报"""
-        script_path = PROJECT_ROOT / "scripts" / "router_local_update.sh"
+        script_path = PROJECT_ROOT / "scripts" / "router" / "router_local_update.sh"
         content = script_path.read_text()
 
         # 验证源码中实际已存在这些参数映射

+ 347 - 0
tests/test_server_mode_lifecycle.py

@@ -0,0 +1,347 @@
+import json
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+
+PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
+
+
+class TestServerModeFullLifecycle(unittest.TestCase):
+    """Server 模式(除安装与卸载外)全流程本地端到端测试套件"""
+
+    def setUp(self):
+        self.tmp_dir = tempfile.TemporaryDirectory()
+        self.work_dir = pathlib.Path(self.tmp_dir.name)
+
+    def tearDown(self):
+        self.tmp_dir.cleanup()
+
+    def _setup_mock_repo(self) -> pathlib.Path:
+        """初始化一个真实的隔离 Git 仓库并克隆项目脚本与配置"""
+        repo_dir = self.work_dir / "target_repo"
+        repo_dir.mkdir()
+        subprocess.run(["git", "init", "-b", "main"], cwd=str(repo_dir), check=True, capture_output=True)
+        subprocess.run(["git", "config", "user.name", "Tester"], cwd=str(repo_dir), check=True)
+        subprocess.run(["git", "config", "user.email", "tester@example.com"], cwd=str(repo_dir), check=True)
+
+        # 拷贝必要目录与脚本
+        shutil.copytree(PROJECT_ROOT / "scripts", repo_dir / "scripts")
+        shutil.copytree(PROJECT_ROOT / "configs", repo_dir / "configs")
+
+        # 提交初始版本以确立 HEAD
+        subprocess.run(["git", "add", "."], cwd=str(repo_dir), check=True)
+        subprocess.run(["git", "commit", "-m", "initial commit"], cwd=str(repo_dir), check=True, capture_output=True)
+
+        return repo_dir
+
+    def _write_mock_api_payload(self, records: list) -> pathlib.Path:
+        """写入符合 vps789.com OpenAPI 格式的本地 JSON 文件"""
+        payload_file = self.work_dir / "vps789_api_response.json"
+        payload = {
+            "code": 200,
+            "message": "success",
+            "data": records,
+        }
+        payload_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
+        return payload_file
+
+    def test_server_mode_full_lifecycle_api_fetch_and_git_commit(self):
+        """[全流程 1] API 拉取 -> 候选过滤 -> 加权选优 -> 运行时落盘 -> Worktree 隔离分支提交"""
+        repo_dir = self._setup_mock_repo()
+        config_path = repo_dir / "configs" / "config.server.json"
+
+        # 构造典型候选数据集
+        records = [
+            # 候选 1: 优质节点 (移动延迟 38ms, 丢包 0, 健康分 105) -> 加权得分 38.0,应胜出
+            {
+                "ip": "best.node.example.com",
+                "createdTime": "2026-06-01 12:00:00",
+                "avgLatency": "80",
+                "avgPkgLostRate": "0",
+                "ydLatency": 38.0,
+                "ydPkgLostRate": 0.0,
+                "ydScore": 105.0,
+                "hostProvider": "Official",
+            },
+            # 候选 2: 次优节点 (移动延迟 65ms, 丢包 0, 健康分 90) -> 加权得分 65.0
+            {
+                "ip": "second.node.example.com",
+                "createdTime": "2026-06-01 12:00:00",
+                "avgLatency": "95",
+                "avgPkgLostRate": "0",
+                "ydLatency": 65.0,
+                "ydPkgLostRate": 0.0,
+                "ydScore": 90.0,
+                "hostProvider": "Official",
+            },
+            # 候选 3: 黑名单提供商,必须被 record_filter.exclude_if_any 过滤
+            {
+                "ip": "filtered.cf.example.com",
+                "createdTime": "2026-06-01 12:00:00",
+                "avgLatency": "20",
+                "avgPkgLostRate": "0",
+                "ydLatency": 20.0,
+                "ydPkgLostRate": 0.0,
+                "ydScore": 110.0,
+                "hostProvider": "CF优选IP",
+            },
+            # 候选 4: 纯 IP,必须被 domain_filter 过滤
+            {
+                "ip": "198.51.100.1",
+                "createdTime": "2026-06-01 12:00:00",
+                "avgLatency": "10",
+                "avgPkgLostRate": "0",
+                "ydLatency": 10.0,
+                "ydPkgLostRate": 0.0,
+                "ydScore": 110.0,
+                "hostProvider": "Official",
+            },
+        ]
+        payload_file = self._write_mock_api_payload(records)
+
+        # 替换 config.server.json 中的 URL 为 file:// 路径
+        with open(config_path, "r", encoding="utf-8") as f:
+            cfg = json.load(f)
+        cfg["api"]["url"] = f"file://{payload_file}"
+        with open(config_path, "w", encoding="utf-8") as f:
+            json.dump(cfg, f, indent=2, ensure_ascii=False)
+
+        script_path = repo_dir / "scripts" / "server" / "run_update_and_commit.sh"
+        env = os.environ.copy()
+        env["APP_DIR"] = str(repo_dir)
+        env["GIT_RUNTIME_BRANCH"] = "runtime-state"
+        env["GIT_PUSH_ENABLED"] = "0"
+        env["PYTHON_BIN"] = sys.executable
+
+        # 首次执行:通过 --force-commit 触发完整工作流
+        proc = subprocess.run(
+            ["bash", str(script_path), "--force-commit", str(config_path)],
+            env=env,
+            capture_output=True,
+            text=True,
+            cwd=str(repo_dir),
+        )
+        self.assertEqual(proc.returncode, 0, f"执行失败:\nSTDOUT: {proc.stdout}\nSTDERR: {proc.stderr}")
+
+        # 1. 验证落盘文件正确性 (锚定在 repo_dir/runtime,决不是 repo_dir/configs/runtime)
+        runtime_dir = repo_dir / "runtime"
+        self.assertTrue(runtime_dir.exists(), "必须生成 runtime 目录")
+        self.assertFalse((repo_dir / "configs" / "runtime").exists(), "严禁在 configs/ 下生成 runtime")
+
+        txt_file = runtime_dir / "current_domain.txt"
+        json_file = runtime_dir / "current_domain.json"
+        state_file = runtime_dir / "state.json"
+        vars_file = runtime_dir / "substore_vars.json"
+
+        self.assertTrue(txt_file.exists())
+        self.assertTrue(json_file.exists())
+        self.assertTrue(state_file.exists())
+        self.assertTrue(vars_file.exists())
+
+        # 2. 验证选优结果(best.node.example.com 胜出)
+        selected_domain = txt_file.read_text().strip()
+        self.assertEqual(selected_domain, "best.node.example.com")
+
+        # 3. 验证 JSON 结构和 Sub-Store 契约
+        json_data = json.loads(json_file.read_text())
+        self.assertEqual(json_data["domain"], "best.node.example.com")
+        self.assertEqual(json_data["status"], "ok")
+        self.assertIn("top_candidates", json_data)
+        # 验证过滤规则生效:CF优选IP 与纯 IP 不在候选列表中
+        candidate_domains = [c["domain"] for c in json_data["top_candidates"]]
+        self.assertNotIn("filtered.cf.example.com", candidate_domains)
+        self.assertNotIn("198.51.100.1", candidate_domains)
+
+        vars_data = json.loads(vars_file.read_text())
+        self.assertEqual(vars_data["AUTO_DOMAIN"], "best.node.example.com")
+        self.assertEqual(vars_data["STATUS"], "ok")
+
+        # 4. 验证 Git 分支已自动建立孤儿分支 runtime-state 并写入 commit
+        commit_log = subprocess.check_output(
+            ["git", "log", "-n", "1", "--format=%B", "runtime-state"],
+            cwd=str(repo_dir),
+            text=True,
+        ).strip()
+        self.assertIn("manual: value best.node.example.com", commit_log)
+
+        # 5. 验证孤儿分支上的文件确实位于 runtime/ 路径下
+        tree_files = subprocess.check_output(
+            ["git", "ls-tree", "-r", "--name-only", "runtime-state"],
+            cwd=str(repo_dir),
+            text=True,
+        ).splitlines()
+        self.assertIn("runtime/current_domain.txt", tree_files)
+        self.assertIn("runtime/current_domain.json", tree_files)
+        self.assertIn("runtime/substore_vars.json", tree_files)
+        self.assertIn("runtime/state.json", tree_files)
+
+    def test_server_mode_adaptive_interval_window(self):
+        """[全流程 2] 动态自适应执行窗口:非高峰期非整点窗口跳过执行,高峰期窗口正常推进"""
+        repo_dir = self._setup_mock_repo()
+        config_path = repo_dir / "configs" / "config.server.json"
+        script_path = repo_dir / "scripts" / "server" / "run_update_and_commit.sh"
+
+        env = os.environ.copy()
+        env["APP_DIR"] = str(repo_dir)
+        env["GIT_RUNTIME_BRANCH"] = "runtime-state"
+        env["GIT_PUSH_ENABLED"] = "0"
+        env["PYTHON_BIN"] = sys.executable
+
+        # 场景 A: 设定高峰期为 02:00 - 03:00,周期 60 分钟,容差 1 分钟
+        # 若当前时间不是 02 点且不是整点,必定触发 off-peak run skipped
+        env_skip = env.copy()
+        env_skip["PEAK_START_HOUR"] = "2"
+        env_skip["PEAK_END_HOUR"] = "3"
+        env_skip["OFFPEAK_INTERVAL_MIN"] = "60"
+        env_skip["PEAK_INTERVAL_MIN"] = "2"
+
+        proc_skip = subprocess.run(
+            ["bash", str(script_path), str(config_path)],
+            env=env_skip,
+            capture_output=True,
+            text=True,
+            cwd=str(repo_dir),
+        )
+        self.assertEqual(proc_skip.returncode, 0)
+        # 如果恰逢整点则可能执行,否则必定跳过;测试两种合法分支
+        combined_skip = f"{proc_skip.stdout}\n{proc_skip.stderr}"
+        if "off-peak run skipped" in combined_skip:
+            self.assertIn("boundary", combined_skip)
+
+        # 场景 B: 设定全天 00:00 - 24:00 均为高峰期,必定不跳过自适应窗口
+        payload_file = self._write_mock_api_payload([
+            {"ip": "peak.node.example.com", "ydLatency": 30.0, "ydPkgLostRate": 0.0, "ydScore": 100.0}
+        ])
+        with open(config_path, "r", encoding="utf-8") as f:
+            cfg = json.load(f)
+        cfg["api"]["url"] = f"file://{payload_file}"
+        with open(config_path, "w", encoding="utf-8") as f:
+            json.dump(cfg, f, indent=2, ensure_ascii=False)
+
+        env_peak = env.copy()
+        env_peak["PEAK_START_HOUR"] = "0"
+        env_peak["PEAK_END_HOUR"] = "24"
+        proc_peak = subprocess.run(
+            ["bash", str(script_path), str(config_path)],
+            env=env_peak,
+            capture_output=True,
+            text=True,
+            cwd=str(repo_dir),
+        )
+        self.assertEqual(proc_peak.returncode, 0)
+        combined_peak = f"{proc_peak.stdout}\n{proc_peak.stderr}"
+        self.assertNotIn("off-peak run skipped", combined_peak, "高峰期窗口内绝不应跳过")
+        self.assertIn("peak.node.example.com", (repo_dir / "runtime" / "current_domain.txt").read_text())
+
+    def test_server_mode_fallback_and_silent_skip(self):
+        """[全流程 3] 上游故障回退与静默跳过:API 故障回退至 last_good,且未变化时静默跳过 commit"""
+        repo_dir = self._setup_mock_repo()
+        config_path = repo_dir / "configs" / "config.server.json"
+
+        # 指向一个不存在的文件 URL 模拟 API 失败
+        with open(config_path, "r", encoding="utf-8") as f:
+            cfg = json.load(f)
+        cfg["api"]["url"] = "file:///nonexistent/path/never_found.json"
+        with open(config_path, "w", encoding="utf-8") as f:
+            json.dump(cfg, f, indent=2, ensure_ascii=False)
+
+        # 预先在 state.json 中写入 last_good_domain
+        runtime_dir = repo_dir / "runtime"
+        runtime_dir.mkdir(parents=True, exist_ok=True)
+        (runtime_dir / "state.json").write_text(json.dumps({
+            "last_good_domain": "fallback-anchor.example.com",
+            "last_run": "2026-06-01T00:00:00Z"
+        }))
+
+        script_path = repo_dir / "scripts" / "server" / "run_update_and_commit.sh"
+        env = os.environ.copy()
+        env["APP_DIR"] = str(repo_dir)
+        env["GIT_RUNTIME_BRANCH"] = "runtime-state"
+        env["GIT_PUSH_ENABLED"] = "0"
+        env["PYTHON_BIN"] = sys.executable
+        # 强制设置全天为高峰,防止被自适应时间窗口拦截
+        env["PEAK_START_HOUR"] = "0"
+        env["PEAK_END_HOUR"] = "24"
+
+        # 1. 首次运行(强制提交),建立 fallback commit
+        proc1 = subprocess.run(
+            ["bash", str(script_path), "--force-commit", str(config_path)],
+            env=env,
+            capture_output=True,
+            text=True,
+            cwd=str(repo_dir),
+        )
+        self.assertEqual(proc1.returncode, 0)
+        self.assertIn("warning: updater using fallback value", f"{proc1.stdout}\n{proc1.stderr}")
+        self.assertEqual((runtime_dir / "current_domain.txt").read_text().strip(), "fallback-anchor.example.com")
+
+        # 2. 第二次运行(普通运行,无 --force-commit):由于仍处于 fallback 状态且值未变化,应执行静默跳过!
+        proc2 = subprocess.run(
+            ["bash", str(script_path), str(config_path)],
+            env=env,
+            capture_output=True,
+            text=True,
+            cwd=str(repo_dir),
+        )
+        combined2 = f"{proc2.stdout}\n{proc2.stderr}"
+        self.assertEqual(proc2.returncode, 0)
+        self.assertIn("selected value unchanged (fallback-anchor.example.com)", combined2)
+        self.assertIn("skip git commit and push", combined2)
+
+    def test_server_mode_rolling_history_prune(self):
+        """[全流程 4] 历史修剪 (Mode A): 孤儿分支提交数超过 GIT_MAX_STATE_COMMITS 时自动 Squashed Rebase"""
+        repo_dir = self._setup_mock_repo()
+        config_path = repo_dir / "configs" / "config.server.json"
+        script_path = repo_dir / "scripts" / "server" / "run_update_and_commit.sh"
+
+        env = os.environ.copy()
+        env["APP_DIR"] = str(repo_dir)
+        env["GIT_RUNTIME_BRANCH"] = "runtime-state"
+        env["GIT_PUSH_ENABLED"] = "0"
+        env["GIT_MAX_STATE_COMMITS"] = "3"  # 最大只保留 3 个 commit
+        env["PYTHON_BIN"] = sys.executable
+
+        # 循环 5 次变换最优节点,促使产生 5 次不同的 commit
+        for i in range(1, 6):
+            payload_file = self._write_mock_api_payload([
+                {
+                    "ip": f"node-{i}.example.com",
+                    "createdTime": "2026-06-01 12:00:00",
+                    "avgLatency": "50",
+                    "avgPkgLostRate": "0",
+                    "ydLatency": 20.0 + i,
+                    "ydPkgLostRate": 0.0,
+                    "ydScore": 100.0,
+                    "hostProvider": "Official",
+                }
+            ])
+            with open(config_path, "r", encoding="utf-8") as f:
+                cfg = json.load(f)
+            cfg["api"]["url"] = f"file://{payload_file}"
+            with open(config_path, "w", encoding="utf-8") as f:
+                json.dump(cfg, f, indent=2, ensure_ascii=False)
+
+            proc = subprocess.run(
+                ["bash", str(script_path), "--force-commit", str(config_path)],
+                env=env,
+                capture_output=True,
+                text=True,
+                cwd=str(repo_dir),
+            )
+            self.assertEqual(proc.returncode, 0)
+
+        # 检查 runtime-state 的 commit 总数:必须严格等于 GIT_MAX_STATE_COMMITS (3)
+        final_count = int(subprocess.check_output(
+            ["git", "rev-list", "--count", "runtime-state"],
+            cwd=str(repo_dir),
+            text=True,
+        ).strip())
+        self.assertEqual(final_count, 3, "超过阈值后必须自动修剪至 3 个 commit")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 38 - 5
tests/test_shell_lifecycle.py

@@ -34,7 +34,7 @@ class TestShellLifecycleP0P1Bugs(unittest.TestCase):
         for p in mock_bin.iterdir():
             p.chmod(0o755)
 
-        script_path = PROJECT_ROOT / "scripts" / "uninstall_debian.sh"
+        script_path = PROJECT_ROOT / "scripts" / "server" / "uninstall_debian.sh"
         env = os.environ.copy()
         env["PATH"] = f"{mock_bin}:{env.get('PATH', '')}"
 
@@ -77,7 +77,7 @@ class TestShellLifecycleP0P1Bugs(unittest.TestCase):
 
     def test_p1_run_update_and_commit_passes_token_in_env_not_argv(self):
         """[P1 修复验证] 验证 run_update_and_commit.sh 采用 GIT_CONFIG_* 环境变量传递 Token,argv 零泄露"""
-        script_path = PROJECT_ROOT / "scripts" / "run_update_and_commit.sh"
+        script_path = PROJECT_ROOT / "scripts" / "server" / "run_update_and_commit.sh"
         script_content = script_path.read_text()
         # 确认彻底移除了命令行 -c "http.extraHeader=..."
         self.assertNotIn('git_auth_opts=(-c "http.extraHeader', script_content, "Token 严禁出现在命令行 argv 参数中")
@@ -87,7 +87,7 @@ class TestShellLifecycleP0P1Bugs(unittest.TestCase):
 
     def test_p1_run_update_and_commit_ls_remote_distinguishes_error_code(self):
         """[P1 修复验证] 验证 run_update_and_commit.sh 明确区分 ls-remote 退出码 2 与 128"""
-        script_path = PROJECT_ROOT / "scripts" / "run_update_and_commit.sh"
+        script_path = PROJECT_ROOT / "scripts" / "server" / "run_update_and_commit.sh"
         script_content = script_path.read_text()
         self.assertIn("ls_remote_rc", script_content, "已捕获并校验 ls-remote 退出码")
         self.assertIn("$ls_remote_rc -eq 2", script_content, "显式匹配 2 作为分支不存在")
@@ -95,7 +95,7 @@ class TestShellLifecycleP0P1Bugs(unittest.TestCase):
 
     def test_p1_run_update_and_commit_fallback_silence(self):
         """[P1 修复验证] 验证 run_update_and_commit.sh 在回退状态且值未变时执行回退静默"""
-        script_path = PROJECT_ROOT / "scripts" / "run_update_and_commit.sh"
+        script_path = PROJECT_ROOT / "scripts" / "server" / "run_update_and_commit.sh"
         script_content = script_path.read_text()
         self.assertIn('is_fallback="0"', script_content)
         self.assertIn('"error_use_last_good"', script_content)
@@ -148,7 +148,7 @@ class TestShellLifecycleP0P1Bugs(unittest.TestCase):
         config_path = self.work_dir / "test_config.json"
         config_path.write_text(json.dumps(test_config))
 
-        script_path = PROJECT_ROOT / "scripts" / "run_update_and_commit.sh"
+        script_path = PROJECT_ROOT / "scripts" / "server" / "run_update_and_commit.sh"
         env = os.environ.copy()
         env["APP_DIR"] = str(repo_dir)
         env["GIT_RUNTIME_BRANCH"] = "main"
@@ -185,6 +185,39 @@ class TestShellLifecycleP0P1Bugs(unittest.TestCase):
         # 检查最新内容是否完整保存
         self.assertEqual(target_file.read_text().strip(), "new-best.example.com")
 
+    def test_scripts_and_configs_directory_integrity(self):
+        """[结构优化验证] 验证 scripts 分层物理结构与 configs/ 目录结构及 scripts 顶层彻底无包装器"""
+        # 1. 验证 configs/ 目录及文件
+        configs_dir = PROJECT_ROOT / "configs"
+        self.assertTrue(configs_dir.is_dir(), "configs/ 目录必须存在")
+        self.assertTrue((configs_dir / "config.server.json").is_file())
+        self.assertTrue((configs_dir / "config.local.json").is_file())
+        self.assertTrue((configs_dir / "config_router.conf").is_file())
+
+        # 2. 验证根目录保持干净(三份配置彻底归拢至 configs/,根目录不再散落冗余配置或软链接)
+        for conf_name in ["config.server.json", "config.local.json", "config_router.conf"]:
+            root_conf = PROJECT_ROOT / conf_name
+            self.assertFalse(root_conf.exists(), f"根目录 {conf_name} 应已收敛至 configs/,不应散落在根目录")
+
+        # 3. 验证 scripts/server/ 和 scripts/router/ 结构
+        self.assertTrue((PROJECT_ROOT / "scripts" / "server" / "run_update_and_commit.sh").is_file())
+        self.assertTrue((PROJECT_ROOT / "scripts" / "server" / "install_debian.sh").is_file())
+        self.assertTrue((PROJECT_ROOT / "scripts" / "server" / "uninstall_debian.sh").is_file())
+        self.assertTrue((PROJECT_ROOT / "scripts" / "router" / "router_local_update.sh").is_file())
+        self.assertTrue((PROJECT_ROOT / "scripts" / "router" / "router_local_http.sh").is_file())
+
+        # 4. 验证 scripts/ 根目录彻底干净(直接引用新结构,不保留任何包装转发脚本)
+        wrapper_names = [
+            "run_update_and_commit.sh",
+            "install_debian.sh",
+            "uninstall_debian.sh",
+            "router_local_update.sh",
+            "router_local_http.sh",
+        ]
+        for w_name in wrapper_names:
+            p = PROJECT_ROOT / "scripts" / w_name
+            self.assertFalse(p.exists(), f"scripts/ 根目录不得再保留门面包装脚本 {w_name}")
+
 
 if __name__ == "__main__":
     unittest.main()