ARCHITECTURE_REVIEW.md 46 KB

全仓库业务流程 / 架构 / 代码审查报告(合并版)

[!IMPORTANT] 终审与修复状态更新(2026-10-13):
本报告的所有发现、与复核报告的分歧裁定以及最新查漏补缺已全部整合进唯一权威终审报告:ARCHITECTURE_REVIEW_UNIFIED.md。
全仓所有 P0 / P1 / P2 问题均已全部修复完毕 ✅,并在 tests/ 目录下建立了端到端自动化测试套件(18 项用例 100% 通过)。最新修复状态与代码台账请以终审报告为准。

审查范围:业务代码(Python / Bash / POSIX sh / Sub-Store JS)、三套配置、运行时输出、服务端 Git 发布链路、systemd 安装、BusyBox 路由器部署与 HTTP 暴露、下游消费。

审查日期:2026-10-09 审查方式:完整阅读源码与配置;对全部脚本做语法检查;通过临时目录、模拟 API 响应、模拟 cfst 结果和 JS 运行环境离线复现关键场景。本轮为只读审查,未修改业务代码,未执行真实 API、cfst 测速、systemd 安装卸载或 Git 推送。

本文件来源:由仓库内 BUSINESS_FLOW_REVIEW.md(原文档)与另一轮独立审查合并而成。每条结论标注来源:

  • [原] — 原文档已有
  • [补] — 本次合并新增
  • [合] — 两轮审查一致(原文档已提出,本次补充了实现细节、量化数据或额外验证)

原文档在状态语义、发布正确性、BusyBox 健壮性和离线复现方面更深入;本次补充集中在密钥泄露、跨实现一致性、可维护性、构建与运维四个方面。两轮结论无相互矛盾之处,分歧仅在于深浅与覆盖面。


一、总体结论

项目已经形成一条完整的动态优选链路:

API / 本地 cfst / BusyBox cfst
        ↓
解析候选
        ↓
过滤与评分
        ↓
选择当前值
        ↓
写入运行时文件和 fallback 状态
        ↓
服务器提交 runtime-state 或路由器 HTTP 暴露
        ↓
Sub-Store 获取并替换 VMess 节点

功能的完整性不是问题。核心问题是没有单一的事实来源(single source of truth),导致三层语义各自漂移:

  1. 没有单一的"输出契约":同一批运行时文件由 三份独立实现 生产 —— Python(domain_updater.py)、BusyBox awk(router_local_update.sh)、Sub-Store JS(operator_template.js)消费。三者对"字段存在性"、"状态含义"、"指标单位"的理解不一致,且没有任何文件固化契约。
  2. 没有单一的"状态语义":updated_at 同时承担尝试时间、成功时间、发布时间;error_use_last_good 在下游可能被当作健康;本地写入 ≠ 远端已发布。
  3. 没有单一的"实例边界":Server 与 Local 模式共用 runtime/;多进程/多调度器可同时写同一批文件;无锁、无实例标识。

这些漂移会产生三类后果([原]):

  1. 错误数据被标记为正常并覆盖 fallback(错误成功)。
  2. 真实故障继续使用过期或错误的目标。
  3. 结果已改变,但 Git / HTTP / 下游没有可靠获得(静默未发布)。

重构主线:统一输出契约 + 实例隔离 + 原子发布 + 可重试发布([原]),并叠加 消除重复实现 + 合并数据管线([补])。

纲领性建议([补]):新增 docs/OUTPUT_CONTRACT.md 作为唯一契约文件,声明 schema 版本、必需/可选字段、状态枚举、单位与时效语义。Python、shell、JS 三者都引用它,并各自带一条契约校验。这是把后面 P1/P2 从"逐个修 bug"变成"结构性消除"的关键。


二、S0:密钥安全(立即处理)

S0-1. API Token 已进入 Git 历史 [补]

位置:config.server.json:6

api.url 明文内嵌 token=UR6SYEEATJGVOPIST6QHCPFNAJ8233S8。已确认该值存在于 commit ea38258,且该 commit 是 main 的祖先 —— 因此不是历史深处或野分支的问题,任何 clone 都能拿到,并会随仓库推送到 git.dewofaurora.de。

影响:token 等同于公开,可被第三方消耗你的 API 配额、伪造请求,或(若该 API 有写能力)篡改你依赖的候选数据。

建议:

  1. 立即在 vps789.com 轮换该 token —— 删除历史无法召回已泄露的凭据,只能作废重签。
  2. 复用仓库里已有的成熟模式:install_debian.sh 已经支持 --git-http-token-file + chmod 600(install_debian.sh:420-424)。给 API 加同样的入口:
    • 配置项 api.token_file,或
    • 环境变量 AUTODOMAIN_API_TOKEN,在 build_url 阶段注入。
  3. 配置文件中只保留 token_env / token_file 的引用,不保留字面值。
  4. (可选)在 .gitignore / CI 中加一条 secret 扫描,防止复发。

三、P1:正确性缺陷

判定标准:会导致「错误成功」、远端永久分叉、灾备失效或数据损坏。

P1-1. API 错误响应被解析成有效域名 [原](已独立复现)

位置:scripts/domain_updater.py:271-295(兜底正则在 L283-286)

当配置的结构化字段路径解析不到任何候选时,parse_domains() 会退化为对整个 JSON 文本执行正则搜索。本次独立复现:

err = {"code": 401, "message": "unauthorized, see https://status.example.com/help for details"}
get_values_by_path(err, "data[].ip")   # -> []
parse_domains(err, {"field_paths": ["data[].ip"]})
# -> ['status.example.com']

即:API 返回 401/403 错误页,更新器把 status.example.com 当作候选,以 status: ok 写入输出,fallback 不触发,错误目标覆盖上次正常值。

建议:

  • HTTP 状态码非 2xx 直接失败(当前 urllib 对 4xx/5xx 会抛异常,但业务层错误码如 {"code": 401} 不会)。
  • 增加显式的业务成功校验(api.success_path / success_equals)。
  • 正则兜底改为显式开关且默认关闭(parser.regex_fallback: false)。
  • 候选进入排序前必须经过统一校验。

P1-2. Server 与 Local 模式默认互相覆盖状态 [合]

位置:config.server.json:103-111、config.local.json:37-46、scripts/domain_updater.py:1101

两份 Python 配置都用 ./runtime,都用 state.json / substore_vars.json。但二者的 state_last_good_key 分别是 last_good_domain / last_good_ip,互不兼容。

影响(原文档已复现,本次补充一条):

  • Server 保存 last_good_domain → Local 写入 last_good_ip 覆盖同一文件 → Server 故障时读不到自己的 fallback。
  • substore_vars.json 的键在 AUTO_DOMAIN / AUTO_CFIP 之间翻转,下游读到的键名取决于"谁最后跑的"。
  • [补] runtime/state.json 与 runtime/substore_vars.json 正是 runtime-state 分支所跟踪的文件(已确认分支内容:current_domain.txt/json、state.json、substore_vars.json)。因此本地手工跑一次 config.local.json,下一次服务端提交就会把 IP 值推到 runtime-state,污染下游。

建议:

  • config.local.json 的 runtime_dir 改为 ./cfip_runtime(这与 README §2「路由器相关模式:cfip_runtime/」一致,只有 §4.3 文档写错)。
  • 配置加载时检查实例间输出文件是否冲突。
  • 每个实例增加 instance_id;状态文件带独立 schema 与实例标识,拒绝读取其他模式的状态。

P1-3. Git push 失败后可能永久不重试 [原]

位置:scripts/run_update_and_commit.sh:287

跳过判断在 push 之前,且 before 取自本地 runtime-state 分支:

before="$(git -C "$work_dir" show "HEAD:${selected_rel}" ...)"
# ...
if [[ "$force_commit" != "1" ]] && [[ -n "$before" ]] && [[ "$after" == "$before" ]] && [[ "$status_unchanged" == "1" ]]; then
  log "... skip git commit and push"
  exit 0
fi

work_dir 用的是本地分支(L222-L223 仅在本地分支不存在时才 fetch 远端)。因此:本地 commit 成功但 push 失败 → 下一轮本地 HEAD 已有该值 → after == before → 提前退出 → 永远不推。远端与本地永久分叉,Sub-Store 持续读旧域名,只能靠强制提交或下一次值变化恢复。

建议:拆成两个独立判断 ——

本地内容是否变化?       是 → 提交
远端是否包含当前本地提交? 否 → 重试推送

推送状态需持久化,或每轮比对远端 ref 与本地 HEAD。值不变 ≠ 发布成功。

P1-4. Sub-Store 可能优先使用路由器过期 fallback [原]

位置:substore/operator_template.js:118、substore/operator_template.js:159

default 模式下,selectValueRouterFirst 只判断路由器 JSON 能否取到、能否解析、是否有值(telemetry.router.ok)。status: error_use_last_good 只进 telemetry,不影响来源资格。

更关键的是速度闸门被绕过:parseRouterSpeedMBps 读 top_candidates[0].download_speed(L61-L62),而 fallback 路径写出的 JSON 不含 top_candidates,于是 speedParse === "missing",speedParse === "ok" && < 阈值 不成立,低速拦截失效。

结果:路由器断网/测速失败时,Sub-Store 仍选一个不可用的旧 IP,覆盖健康的服务器结果。

建议:

  • 只有 status: ok 且未过期的来源才参与正常优先级决策。
  • fallback 必须带 last_success_at / valid_until。
  • 速度字段缺失必须按策略显式拒绝或降级,不能隐式绕过阈值。
  • 缓存保存来源、状态、测量时间、有效期。

P1-5. BusyBox 原地写入状态与 HTTP 文件 [原]

位置:scripts/router_local_update.sh:148、scripts/router_local_update.sh:161

BusyBox 脚本用 cat > file 直接覆盖 JSON,而 HTTP 服务同时在读这些文件;state.json 还是路由器重启后唯一的 fallback 来源。

Python 侧已有 _atomic_write(domain_updater.py:37-69,temp + fsync + os.replace),BusyBox 侧完全没有对应实现 —— 能力不对等。

影响:HTTP 请求读到空文件/半截 JSON;更新中断、断电或写失败导致 state 被截断,重启后 fallback 丢失。

建议:同目录 temp → 写全 → fsync → rename。所有 HTTP 可见文件与持久化 state 共用同一套原子发布函数;更新过程加实例锁。


四、P2:健壮性与一致性

P2-1. 缺失评分字段的记录仍可能被选中 [原]

位置:scripts/domain_updater.py:660、scripts/domain_updater.py:749

加权评分遇缺失字段时 score_value 留 None,排序靠后;但若全部记录都缺失,仍保留候选并选第一条,状态照样 ok。

建议定义候选资格:必需指标、缺失是否淘汰、取值范围与单位、无有效候选时是否 fallback(而非输出 ok)。

P2-2. cfst 结果缺少地址校验 [合]

位置:scripts/domain_updater.py:194、scripts/router_local_update.sh:337

两侧都可能接受任意非空字符串。原文档已复现 BusyBox 把 999.999.999.999 写入 current_ip.json 与 state.json —— 因为 BEST_IP 直接来自 sed -n '2p' | cut -d',' -f1,没有任何 IP 校验(Python 侧虽有 IPV4_RE,但 cfst 分支未用于校验选中值)。

[合] 补充:BusyBox 固定取第二行作选中值,而候选解析会跳过空行。若第二行首列为空、第三行有效,则选中值与候选列表不一致(选中值来自第 2 行,candidate_1 来自第 3 行)。

建议:统一 IPv4/IPv6 校验;过滤重复表头、空行、损坏行;选中值、候选列表、source count 必须来自同一次 CSV 解析。

P2-3. 旧 CSV 可能被当成本次成功结果 [原]

位置:scripts/router_local_update.sh:315、scripts/router_local_update.sh:327

运行前不清理旧结果,也不验证结果由本次运行产生。原文档离线复现了"命令成功但未生成新结果时读取旧 CSV"。

建议:每轮唯一临时结果路径 → 成功后 move 为正式结果;记录开始/结束时间或 inode/mtime 验证归属;无新结果时进入 fallback,不得返回 ok。

P2-4. run_cfst 的 cd 失败后可能继续执行 [原]

位置:scripts/router_local_update.sh:220

run_cfst 以 if ! run_cfst 调用,set -e 不保证 cd 失败后停止(最小 shell 示例已复现)。后果:在错误目录执行另一个同名 cfst,或读到错误输入。

建议:cd "$CFST_WORK_DIR" || return 1;并显式检查 cfst 可执行文件、输入文件、结果目录。

P2-5. 更新器没有互斥锁 [原]

位置:scripts/router_local_update.sh:315、scripts/run_update_and_commit.sh:161

手工运行 / cron / watchdog / systemd 可同时执行,共享结果 CSV、runtime 文件、state 文件。可能出现:A 读到 B 生成的 CSV;选中值来自一次运行而候选来自另一次;四个输出文件来自不同运行;Git 发布读到未完成快照。

建议:每实例锁文件(或 mkdir 锁),带超时与持有者信息;发布先整快照后一次性替换;服务器更新器与 Git 发布器共用同一把锁。

P2-6. 发布同步检查不完整 [原]

位置:scripts/run_update_and_commit.sh:270-285

只要有一个可解析且匹配的 status 就判定"状态未变化"。若另一个 JSON 缺失或损坏,会提前跳过,无法自我修复缺失输出(原文档已复现 selected JSON 正常但 vars 缺失,及反向缺失)。

建议:统一发布文件清单;逐文件检查存在性、可解析性、schema、内容一致性;只有完整快照与远端一致才跳过。

P2-7. [补] 非高峰调度对长间隔支持不正确(并修复 start == end 静默失效)

位置:scripts/run_update_and_commit.sh:36-75

[原] 调度用小时内分钟数取模(curr_min % offpeak_int):2h 会在每小时边界运行而非每两小时;45min 产生不规则的小时内边界。

[补] 另有一处静默失效:当 peak_start == peak_end(如 --peak-start 19 --peak-end 19)时,L49-L51 把 in_peak 强制为 0,即视为"无高峰窗口",全部落入非高峰逻辑;而 install_debian.sh 的 validate_hour_range 会放行这个输入,不报错。

根源是:用一个固定周期 timer 模拟两个周期,再在脚本里用算术反推 —— 需要 40 行代码 + 文档专节(README §3.8.2)解释 Remainder / Tolerance 公式,这本身就是可维护性信号。

建议([补],比"修算术"更彻底):改用 两个 OnCalendar timer(systemd ≥ v235 支持时区后缀),把复杂度交给 systemd:

# vmess-domain-rotator-peak.timer
OnCalendar=*-*-* 19..23:0/10:00 Asia/Shanghai
# vmess-domain-rotator-offpeak.timer
OnCalendar=*-*-* 0..18:0/30:00 Asia/Shanghai

连带收益:run_update_and_commit.sh 删掉整个 peak 判定块与 PEAK_* 环境变量;TIMER_INTERVAL = min(...) 计算消失;README §3.8.2 整节删除。PEAK_* 从"每轮重新求值的逻辑"降级为"生成 timer 时的输入"。

P2-8. 通知命令不应影响已完成的选优事务 [合]

位置:scripts/domain_updater.py:919、scripts/domain_updater.py:1110

[原] 通知在输出写入后执行,但仍在主 try 内:通知失败会让整个运行进入异常分支,用旧值重新生成错误输出(把已成功的选优事务回滚了)。通知的 stdout 还会混入调度器解析的 JSON,使 updater_status 解析失败。

[补] 这条其实是 run_update_and_commit.sh 与 domain_updater.py 之间stdout JSON RPC 问题的实例。整条 shell ↔ Python 协议目前有三处:

调用 位置 用途
--print-output-settings L110-L118 取 5 个输出路径
updater stdout →json_field status L144-L159 取status
repo_relpath 内联 heredoc L126-L142 路径转换(每个文件各启一次解释器)

这是没有契约的进程间协议。具体隐患:只要 updater 往 stdout 多打一行日志,json_field status 返回空串 → L272 status_unchanged 保持 0 → 每次都强制提交。此降级是静默的。

建议([合]):

  • 通知失败只记日志,不回滚已发布的选择结果。
  • 通知 stdout/stderr 单独捕获。
  • updater 支持 --result-file <path>,把 {selected, status, paths, ...} 写成文件;shell 只读文件,不再解析 stdout。
  • 最终形态:Python 单一入口 + 子命令(update / commit),run_update_and_commit.sh 退化为薄薄的 git 封装。
  • 明确退出码区分:采集失败 / 回退成功 / 发布失败 / 通知失败。

P2-9. BusyBox 配置路径可能加载失败 [合]

位置:scripts/router_local_update.sh:18、scripts/router_local_http.sh:18

[原] 脚本直接 . "$CONFIG_PATH"。部分 BusyBox ash 中,不含 / 的路径只搜 PATH,不搜当前目录 —— 而 README §5.4 的手动命令正是这种形式(sh scripts/router_local_update.sh config_router.conf)。

[补] 这属于下面 P2-10「路径解析三实现不统一」的一个表现。建议:配置路径先规范化为绝对路径(或补 ./)。

P2-10. [补] 三个"路径解析"实现语义各不相同

实现 相对基准 规范化
domain_updater.py:92-98 配置文件所在目录 normpath
router_local_update.sh:63-70 当前工作目录(CWD) 无
router_local_http.sh:44-50 名义上配置文件目录,但RUNTIME_DIR 实走 CWD 无

router_local_update.sh 的 resolve_path 只把文件名接到目录后;目录本身若为相对路径就保留为 CWD 相对 —— 即依赖调用者先 cd $VMESS_DIR。README §5.5 的 cron 恰好这么写,所以"恰好能用"。router_local_http.sh 里 resolve_path_from_config 只用于 BUSYBOX_BIN,RUNTIME_DIR 走另一条路。

风险:httpd -h 的目标目录与 update 的输出目录之间没有任何构造性保证,只靠约定。一旦调用方式变化(systemd、绝对路径调用、从别处 cd),就静默错位。

建议:统一为"所有相对路径相对配置文件所在目录"(Python 侧已是此语义),两脚本共用同一函数。

P2-11. CRLF 配置处理不一致 [原]

位置:scripts/router_local_update.sh:25、scripts/router_local_http.sh:34

HTTP 脚本在 source 后修剪部分变量的 CR,更新脚本完全没处理。Windows / Web UI 编辑的配置可能携带回车,导致目录、二进制、state 路径异常。

建议:读取配置前统一转换或拒绝 CRLF;两脚本共用同一套加载 + 清洗逻辑;在校验阶段报出具体变量名,而不是等 cfst 执行失败。

P2-12. BusyBox 路径错误时会静默选择其他二进制 [原]

位置:scripts/router_local_http.sh:52-77

resolve_busybox() 在配置路径失效后还会依次尝试工作目录、打包目录、PATH。这掩盖配置错误,也违反 CLAUDE.md 的约定("使用配置中的 BusyBox 二进制的 httpd applet,不使用系统 httpd")。

建议:配置了 BUSYBOX_BIN 时只解析该路径;不存在/不可执行/无 httpd 直接失败;仅在未配置时才允许默认路径。

P2-13. [补] BusyBox 路径的 schema 与 Python 不一致,且忽略 domain_filter

位置:scripts/router_local_update.sh:123

awk 输出:

"location_country":"","location_city":"","host_provider":"","score_value":null,"scores":[],"created_raw":""

而 README §2 明确写着「有值才输出该字段,不再写空字符串/空数组/null 占位」,Python 侧 set_if_nonempty_text 也确实这么做了。同一个文件契约,两条实现路径产出两种 schema。

另有:BusyBox 路径完全没有实现 domain_filter(include_suffixes / exclude_regex 都没用到),只是 sed -n '2p' 取第一行。

建议:把这条并入 P1-5 / P2-2 的统一修复 —— 由共享契约与共享校验函数消除,而不是在两处分别对齐。

P2-14. [补] router_local_update.sh 的 60 行 if 链

位置:scripts/router_local_update.sh:220-281

每个 cfst 参数都要写一段 if [ -n "$CFST_X" ]; then set -- "$@" -flag "$CFST_X"; fi。当前 15 个参数 ≈ 60 行,且每加一个参数要改 3 处(config_router.conf、默认值表、这条 if 链)。

同时,Python 侧用 run_args 数组(config.local.json:8-14),shell 侧用散装变量 —— 同一件事两种表达。

建议:表驱动 + 与 Python 对齐。

# 变量名:cfst 参数:是否布尔开关
CFST_FLAG_MAP="
CFST_THREADS:-n:0
CFST_TEST_COUNT:-t:0
CFST_PORT:-tp:0
CFST_HTTPING:-httping:1
CFST_LATENCY_LIMIT:-tl:0
"

一个循环吃掉全部;加参数 = 表里加一行。进一步可让 config_router.conf 也用 CFST_RUN_ARGS,两模式共享同一份参数拼装规则。

P2-15. [补] 更新器非零退出时无日志

位置:scripts/run_update_and_commit.sh:161

set -euo pipefail 下,updater_output="$(...)" 若 Python 非零退出,脚本直接终止,没有任何 log_err 说明原因(stderr 里有 Python 的 JSON error,但在 journal 里不与调度日志关联)。建议包一层捕获,记录退出码与 stderr。


五、路由器部署与打包 [原]

P2-16. 打包不保证 cfst 可执行

位置:build_router_package.sh:115 — 修复了 BusyBox 与两个 shell 脚本的权限,没有修 cfst/cfst;最终校验只检查文件名存在。

建议:检查 cfst/cfst 是目标架构可执行文件;打包前 chmod +x 或权限不对直接失败;校验包内权限而非仅文件名。

P2-17. 打包路径与运行配置可能不一致

位置:build_router_package.sh:109 — 支持自定义 PKG_ROOT_NAME,但配置原样复制,默认配置仍引用 /jffs/vmess/cfst、/jffs/vmess/state.json。包解压到其他目录后可能执行包外的旧安装。

建议:构建时生成与包根匹配的配置(或改为相对配置目录);包内加 manifest(版本、架构、配置根、schema 版本)。

P2-18. 正式归档应采用原子替换

位置:build_router_package.sh:120 — 先删旧归档再生成新归档;磁盘满/中断/校验失败会丢失原本可用的部署包。

建议:temp 生成 → 校验内容与权限 → 原子 rename 到 OUT_TAR。

P2-19. [补] 构建不可复现

位置:.gitignore

.gitignore 忽略 cfst*/、busybox*/,但 build_router_package.sh:16-17 的默认 BUSYBOX_SRC / CFST_SRC_DIR 正指向这两个被忽略的目录。新 clone 无法打包 —— 仓库里没有任何说明如何获取这些二进制,构建不可复现,也意味着 CI 无法验证打包脚本。

建议:二选一并写进文档 —— ①提供下载/获取步骤(带校验和与架构说明);②把二进制纳入版本控制(体积大但可靠,当前 vmess.tar.gz 已入库,说明体积可接受)。

P2-20. [补] index.html 的维护逻辑在两条脚本里各写一遍

位置:router_local_http.sh:118-125(ensure_index)与 router_local_update.sh:134-139(write_text_value 里 rm -f "$INDEX_PATH"; cp ... "$INDEX_PATH")

两处都在维护同一个 index.html 派生文件,逻辑重复且语义微妙(一个仅在文件缺失时补,一个每次覆盖)。建议并入统一发布函数,让 index.html 成为快照的一部分。


六、架构重构建议

6.1 统一业务模型 [原]

将 Python 更新器拆分:

模块 职责
sources/ API、Python cfst、BusyBox 结果的采集适配器
models.py 候选、观测、决策、发布状态的数据结构
policy.py 资格过滤、去重、评分、排序、切换与回退策略
state.py 实例隔离、锁、最后成功值、原子持久化
publishers/ 本地快照、HTTP 文件发布、Git 提交与推送
cli.py 配置校验、命令参数、退出码、日志格式

BusyBox 版可保留精简 shell 实现,但必须遵循同一份输出契约(状态、时间、指标单位)。

6.2 [补] 合并 cfst 与 api 到同一条管线

当前 main() 是两个近乎独立的分支(domain_updater.py:1031-1070):cfst 分支完全跳过 record_filter / scoring / rank_scored_records,直接信任 cfst 自身的输出顺序。

但这套机制对 cfst 数据完全可用 —— to_float_or_none 能处理 "0.00" / "23.75" 这类字符串,loss_rate / avg_latency / download_speed 已在 load_cfst_rows 返回的 dict 里。

建议:让 cfst 也产出标准 record(field_map 为恒等映射),走同一条 filter → record_filter → score → rank → select 管线。

收益:

  • config.local.json 从此可声明"先看丢包率再看延迟",而不再依赖 cfst 内部排序这个隐式契约 —— 这是真实的能力缺口(目前无法表达"丢包率优先")。
  • main() 两分支塌缩为一;choose_domain / choose_top_candidate_domains 的防御性 if ranked_scored: ... else: ... 一并消失。
  • build_top_candidates 里 cfst/api 两套 row_map / record_map 逻辑合并。
  • 顺带消化 P2-13 的一半(schema 不一致):因为只有一个生产者。

6.3 明确状态字段 [原]

updated_at 语义过宽。建议至少区分:

  • attempted_at — 最近一次尝试采集
  • last_success_at — 最近一次取得有效观测
  • selected_at — 当前值被选中
  • published_at — 当前快照发布
  • valid_until — 消费者允许使用该观测的截止时间
  • status — ok / degraded / error / unavailable
  • reason — api_timeout / last_good_fallback / invalid_payload
  • generation — 快照批次号
  • schema_version — 输出契约版本

失败回退不得刷新 last_success_at,否则过期数据看起来像刚刚成功测量。

6.4 统一指标单位 [原]

download_speed 可能是带单位的字符串,导致 Sub-Store 需要自行猜测 MB/s / Mbps / KB/s(见 operator_template.js:61-109 那段约 50 行的单位嗅探)。

建议输出统一数值字段:latency_ms、loss_ratio、download_mbps、score_value、measured_at。展示文本可另行保留,但业务决策只用数值字段。

6.5 重新定义选优规则 [原]

分三层:候选资格 → 排序评分 → 是否值得切换。

  • 候选资格:合法域名/IP;必需指标存在且可转数值;指标在允许范围内;测量未过期;API 记录通过业务成功校验。
  • 排序:上游字段的含义、单位和计算公式以数据源定义为准,不能仅凭字段名猜测。yd_score 映射上游 ydScore;当前配置不将它用于加权,仅在平局决胜中以 order: "asc" 排序。字段权重与方向属于本项目的自定义策略,可在核对上游定义后调整。
  • 切换阶段:最小改善幅度、最短保持时间、连续多次胜出才切换。

6.6 [合] 迟滞(hysteresis):当前切换过于频繁

原文档 §5.4 已提出"最小改善幅度"等概念。本次补充量化数据(来自 runtime-state 分支历史):

  • 约 4 个月 581 次提交;
  • 20+ 个域名轮流当选,分布相当均匀(每个 35–60 次);
  • 即平均每 ~3 小时切换一次。

而每次切换的代价链很长:新值 → git commit → push → Git 服务器 → Sub-Store 拉取(5 分钟 TTL)→ 客户端重新解析。触发原因可能只是 yd_latency 变化 0.5ms。

建议:scoring 增加 switch_margin_pct: 10;main() 写文件前比对 last_good 是否仍在候选内且与新首选差距小于阈值 → 保持不动。这同时降低 commit 数量、Sub-Store 请求量与客户端重解析频率。这是对"业务流程"收益最集中的一条。

6.7 统一快照发布 [原]

采集本次结果
    → 生成 current value / state / vars / metadata
    → 校验所有文件与 schema
    → 原子替换本地运行时目录
    → Git / HTTP 发布
    → 记录发布结果

本地更新成功与远端发布成功必须是两个状态。远端发布失败不回滚已验证的当前值,但必须保留未发布版本并在下一轮重试(即 P1-3)。


七、Sub-Store 建议流程

7.1 流程重构 [原]

显式 domain 覆盖
    → 检查是否有匹配 VMess 节点
    → 并行获取允许的来源
    → 校验状态、时间、地址、有效期
    → 根据模式与策略选来源
    → 更新节点

具体:

  • 没有匹配节点时直接返回,不产生网络请求(当前 operator_template.js:287-296 是先 fetch 再判断节点)。
  • Router 与 Server 请求并行执行(当前在 default 模式下串行,两个 5s 超时可能叠加)。
  • 缓存键包含来源 URL、模式、策略版本。
  • 缓存值包含 value / source / status / last_success_at / valid_until。
  • 并发 cache miss 用共享 pending Promise,避免重复请求。
  • error_use_last_good 默认不作为健康来源(即 P1-4)。
  • 保留 $arguments.domain 最高优先级与 domain_override 开关。

7.2 [补] 把硬编码常量改为 $arguments 参数

位置:substore/operator_template.js:7-14

ROUTER_VALUE_JSON_URL / SERVER_VALUE_JSON_URL / ROUTER_MIN_SPEED_MB_PER_S / VALUE_SOURCE_MODE 全是常量,用户每次换模式或阈值都要编辑脚本内容。而仓库里已有正确模式 —— domain / domain_override 走 $arguments(L250-L276)。

建议同样参数化(带默认值):$arguments.mode / min_speed / router_url / server_url。CACHE_KEY 带上解析后的 mode 即保持缓存隔离语义。

附带信息:出厂默认 VALUE_SOURCE_MODE = "server_only",而 router_url 指向 http://192.168.50.1:8080 —— 意味着 selectValueRouterFirst 与 parseRouterSpeedMBps 这约 120 行在默认部署下是死代码。不算错误,但值得知道。

7.3 [补] top_candidates 被大量生产却几乎未被消费

substore/operator_template.js 只读 domain / ip(L15),唯一例外是读 top_candidates[0].download_speed 一个标量(L61-L62)。

也就是说 base_top_candidate / build_cfst_candidate / build_api_candidate / build_top_candidates / set_if_nonempty_text / text_or_blank / maybe_resolve_field 这 7 个函数,加上 score_value / scores / created_raw 字段,本质上只为一个标量 + 人工排查服务。

两条路,选一条并写进文档:

  • 裁剪:只保留被消费字段 + 一个明确标注"调试用"的块。
  • 真正用起来(推荐):运行时 JSON 已有排好序的 top-N,Sub-Store 完全可以在当前 server 不可达时自动 failover 到 candidate #2。数据已经在那儿,能力白放着。

八、测试建议

项目当前主要依赖语法检查与手工运行(CLAUDE.md:「无专用测试框架」)。

8.1 [原] 回归测试清单

  • API HTTP 错误响应不会产生候选。
  • API 错误消息中的域名不会进入候选。
  • 全部评分字段缺失时会 fallback 或返回不可用。
  • Server 与 Local 状态目录互不覆盖。
  • Server API 失败可读取自己的 last-good 域名。
  • BusyBox 非法 IP、空行、重复表头不会进入 state。
  • 旧 CSV 不会被视为本次成功结果。
  • 更新过程中读取 JSON 永远得到完整旧版或完整新版。
  • 两个并发更新只有一个成功持有锁。
  • push 失败后下一轮会继续重试。
  • 缺失 selected JSON 或 vars JSON 时会触发修复提交。
  • 2h、45min 和跨午夜调度符合配置语义。
  • Sub-Store 遇到过期 router fallback 时选择健康 server。
  • 没有匹配 VMess 节点时不会发起运行时请求。

8.2 [补] 优先覆盖的纯函数

零依赖(stdlib unittest 即可,符合本仓库不引三方库的风格)。这些函数目前完全没有测试,但它们是"选哪个域名"的唯一裁决者:

函数 值得测的点
rank_scored_records 比较器很微妙:None 处理、asc/desc、数值与字符串的降级比较、tie_breaker 顺序
parse_scored_records 加权求和的缺失字段分支;lexicographic 的 prefer_lower 默认方向
parse_created_time 多格式 + 时区 offset + ISO 兜底 + 无时区时的 tz 注入
rule_matches contains / equals / regex × case_sensitive × 列表值展开
get_values_by_path [] 展开、缺失路径、非 dict 中间节点
apply_filter suffix / regex 组合
build_output_settings 相对/绝对路径解析、legacy key 拒绝

九、建议实施顺序

第一阶段:数据安全与「错误成功」[合]

  1. 轮换 API token,移除配置中的字面量(S0-1)。[补]
  2. 隔离 config.server.json 与 config.local.json 的 runtime/state/vars 目录(P1-2)。
  3. 禁止 API 错误文本默认进入候选(P1-1)。
  4. Python 与 BusyBox 统一严格域名/IP 校验(P2-2)。
  5. 拒绝缺少必需评分字段的候选(P2-1)。
  6. BusyBox state 与 HTTP 文件改原子写入(P1-5)。
  7. 检查 cd / cfst / 输入文件 / 结果文件(P2-4、P2-3)。
  8. 修复 Sub-Store 对 error_use_last_good 的来源判断(P1-4)。
  9. 修复 Git push 失败后的重试逻辑(P1-3)。
  10. 修复缺失 JSON 输出导致的错误跳过(P2-6)。

第二阶段:统一状态与运行并发 [原]

  1. 定义 schema_version / last_success_at / valid_until / reason(6.3)。
  2. 每实例加锁(P2-5)。
  3. 统一 CSV 解析与候选结构(P2-2)。
  4. 防止旧 CSV 被当成本次新结果(P2-3)。
  5. 修复高峰/非高峰调度语义(P2-7)。
  6. 通知改为主流程外的非阻塞副作用(P2-8)。
  7. top_n 增加按域名去重与测量聚合规则。

第三阶段:拆模块、消重复、简化部署 [合]

  1. 拆分 source / policy / state / publisher / CLI(6.1)。
  2. 合并 cfst 与 api 管线(6.2)。[补]
  3. 统一 Python / BusyBox / Sub-Store 输出契约,落为 docs/OUTPUT_CONTRACT.md。[补]
  4. 统一配置加载、相对路径解析、CRLF、BusyBox 解析(P2-9~P2-12)。[补]
  5. router_local_update.sh 参数表驱动化(P2-14)。[补]
  6. Sub-Store 常量参数化 + 节点预检 + 并行取源(7.2、7.1)。[补]
  7. 打包:生成匹配安装根的配置、加 manifest、权限检查、原子归档(P2-16~P2-19)。
  8. 增加 validate / doctor 子命令,输出解析后路径、状态与契约检查结果。
  9. 评估服务器是否可直接提供静态 JSON,将 Git 降为留档而非唯一发布通道。
  10. 补 tests/(8.1、8.2)。

第四阶段:业务优化 [补]

  1. 切换迟滞 switch_margin_pct(6.6)。
  2. top_candidates 用于下游 failover(7.3)。
  3. 调整数据源或评分策略时,核对上游字段定义并同步更新映射、权重和排序方向(6.5)。
  4. 清理一次性迁移代码:build_output_settings 的 legacy key 拒绝逻辑(domain_updater.py:941-957)在配置迁移完成后是永久负担。
  5. 修正文档漂移:README §2 与 §4.2 / §5.6 关于 runtime/ 与 cfip_runtime/ 的说法互相矛盾,且与出厂配置不完全一致。

十、验证结果与边界

10.1 已通过(两轮审查共同)

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
sh -n build_router_package.sh
node --check substore/operator_template.js
git diff --check

10.2 已离线复现 [合]

原文档复现:

  • API 错误文本被解析为域名。
  • 缺失评分字段仍产生 ok 候选。
  • Server/Local 共用状态造成回退值丢失。
  • Git push 失败后的永久跳过。
  • 缺失输出文件被错误跳过同步。
  • Router fallback 优先于健康 Server。
  • BusyBox 非法 IP 被写入成功状态。
  • 工作目录 cd 失败后继续执行。
  • 非高峰长间隔计算不符合配置语义。

本次合并复现/确认:

  • parse_domains 对 401 错误页返回 ['status.example.com']。
  • Token UR6SYEEA… 存在于 commit ea38258,且 ea38258 是 main 的祖先。
  • runtime-state 分支跟踪 state.json / substore_vars.json,与 config.local.json 输出路径冲突。
  • 仓库无 tests/ 目录。

10.3 尚需在目标设备确认 [原]

  • 部署的具体 cfst 版本在无结果时是否保留旧 CSV。
  • 路由器存储设备的原子 rename、fsync 与掉电行为。
  • 目标 BusyBox ash 对相对 source 路径与 CRLF 的具体行为。
  • 真实 systemd 环境中的 timer 漂移、并发启动与远端推送失败恢复。

10.4 [补] 运维观察:线上服务可能已停止

runtime-state 分支 HEAD 停在 2026-09-15(一次 manual: 提交),上一次自动提交是 2026-07-27 —— 此后 2.5 个月无自动提交。

结合 P2-19 的提交分布(历史显示值平均每 ~3 小时一变),这段空白有三种解释,建议排查:

  1. 服务已停止(timer 未启用 / 机器下线);
  2. 值恰好一直未变(可能性低);
  3. API 或推送持续失败(若是这个,正好命中 P1-3 的永久分叉)。

排查命令:

sudo systemctl status vmess-domain-rotator.timer   # 定时器是否仍在运行
sudo journalctl -u vmess-domain-rotator.service -n 100 --no-pager  # 近期是否报错
git ls-remote origin runtime-state                 # 远端是否与本地一致

附录 A:两份审查的对应关系

主题 原文档 本次补充 状态
API Token 泄露 — S0-1 新增
API 错误响应被解析为域名 P1 独立复现 一致
Server/Local 状态互覆盖 P1 补充"污染 runtime-state" 一致并加深
Git push 失败永久跳过 P1 代码路径确认 一致
Sub-Store 偏好过期 router fallback P1 代码路径确认 一致
BusyBox 非原子写入 P1 指出与 Python_atomic_write 能力不对等 一致并加深
缺失评分字段仍被选中 P2 — 原文档独有
cfst 无地址校验 P2 补充"选中值与候选列表不一致" 一致并加深
旧 CSV 被当新结果 P2 — 原文档独有
cd 失败后继续执行 P2 — 原文档独有
无互斥锁 P2 — 原文档独有
发布同步检查不完整 P2 — 原文档独有
调度长间隔语义 P2 补充start==end 静默失效 + OnCalendar 方案 一致并加深
通知影响主事务 P2 扩展为 shell↔Python stdout RPC 问题 一致并加深
BusyBox 配置路径 / CRLF P2 归并为"路径解析三实现不统一" 一致并加深
BusyBox 静默换二进制 P2 — 原文档独有
打包(权限/路径/原子) P2 ×3 补充"构建不可复现" 一致并加深
状态字段语义 §5.2 — 原文档独有
指标单位统一 §5.3 — 原文档独有
选优三层规则 §5.4 补充上游字段定义与自定义排序方向的区分 一致并加深
迟滞/切换阈值 §5.4 概念 量化数据 + switch_margin_pct 一致并加深
统一快照发布 §5.5 — 原文档独有
Sub-Store 流程 §6 补充参数化 + 节点预检 + 死代码说明 一致并加深
模块拆分 §5.1 — 原文档独有
合并 cfst/api 管线 — 6.2 新增
BusyBox schema 不一致 / 忽略 domain_filter — P2-13 新增
参数表驱动化 — P2-14 新增
top_candidates 未被消费 — 7.3 新增
Sub-Store 常量参数化 — 7.2 新增
构建不可复现 — P2-19 新增
index.html 逻辑重复 — P2-20 新增
线上服务可能已停 — 10.4 新增
文档漂移 — 九.4/5 新增
回归测试清单 §8 补充纯函数优先级表 一致并加深

无矛盾项

两轮审查在以下判断上完全一致,不存在需要仲裁的分歧:

  • 状态隔离必须做,config.local.json → cfip_runtime/。
  • 调度应交给 systemd,而不是脚本内算术。
  • 发布成功与本地写入成功必须分离。
  • 应建立单一输出契约。
  • 应补离线测试,且不引入大型测试框架。

本报告为只读审查结论,未修改仓库业务代码。建议第一轮只处理 S0-1(token)、P1-1~P1-5、P1-3(发布重试)与 10.4(线上状态排查),完成后再进入模块拆分。