Commit Graph
94 Commits
Author SHA1 Message Date
xmz7891 3f18e6bb40 2.1.0:网站自动抢救 + 后台抢救报告分区 + 网络热门音效 + 全量测试后台静默
按用户定的规矩,这一轮有新功能,升第二位:2.0.0 -> 2.1.0。

== 一、网站挂了会自己救 ==

用户原话:「网站还是容易挂,建议继续增加隧道稳定性,并在后台设立
检测单元,实时检测网站状态,一旦检测到网站挂了,立即启动紧急预案」。

新增 relay/monitor.sh + lanremote-monitor.timer:
    每 60 秒从**公网**查一次 /v1/health(不是查 127.0.0.1)

    为什么不查本机:本机 200 只说明中转进程活着,用户真正关心的是
    经过 Cloudflare 边缘 + 隧道 + 本机三段之后能不能打开。实测过
    隧道两条实例都掉了、本机 curl 照样 200,只有从公网测才发现 502。

    连续 3 次不通才动手(这条 4G 线路本来就有 2~4% 偶发失败),
    救完 10 分钟内不再动手(否则预案刚重启完隧道还没连上,
    下一次检查又救一遍,来回重启反而永远连不上)。

新增 relay/rescue.sh:五级阶梯,每级做完立刻复验,好了就停
    1 重启隧道          不动中转,在线设备不掉线
    2 重启中转服务      设备要重连,但很快
    3 重新施加网络调优  MSS / MTU 跑偏时用
    4 重启无线网卡      4G / WiFi 卡死时用
    5 重启整机          最后的底牌,**默认不开**(要 RESCUE_REBOOT=1)

    理由:如果问题出在供电或上游线路,重启只会让恢复更慢;
    而且半夜自动重启一台没人管的机器风险太大。

== 二、抢救报告 + 后台分区 ==

用户原话:「如果紧急预案未能解决,则将问题打包成 .txt,然后在
中转站设置里面加一个分区专门放还有下载这个 txt。解决后将解决问题
的方法加入紧急预案」。

    * 五级都没救回来 -> 把现场(内存/进程/隧道连接数/路由和 MTU、
      三份日志尾部、内核异常)整理成 rescue_reports/<时间戳>.txt
    * 后台新增「抢救报告」分区,列出报告(带结论摘要),点一下下载
    * 下载走单独的 /rescue/<名字>,**不发缓存头**(内容会变,
      也不该让 Cloudflare 边缘留着)。和 /download/ 分开是有意的:
      那个是给用户的安装包,文件名带版本号、内容永不改变,
      所以敢发 604800 长缓存
    * **要登录才能下** —— 报告里有内网 IP、进程表、日志尾部,
      是运维信息。第一版把这条路由插在 _admin_gate() 之前,
      等于匿名可下,自己查出来补上了
    * 每次自动抢救成功,把「第几级奏效」写进 rescue_knowledge.md;
      监测单元读它,下次同类症状优先试历史上管用的那一级
      (这就是用户说的"解决后将解决问题的方法加入紧急预案")

== 三、网络热门音效 ==

用户原话:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈,
各种老牧师音效,可以直接远程播放」。

**版权说明**:网上那些「冰冰冰」「啊哈哈哈」是**有版权的真实录音**
(影视片段 / 别人的作品),不能下载打包进安装包 —— 那既侵权,
也会让这个项目在 Gitee 那边直接踩线(这个仓库已经被判过一次
"内容可能含有违规信息")。

所以:
    * 内置 8 个**程序合成**的音效(正弦/方波现场算,零版权风险、
      不占安装包体积):冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 /
      警报 / 鼓点 / 弹簧。名字对着感觉来,但不是那些录音本身
    * 做「自己放进来」的目录:mp3/wav 丢进 data/sfx/ 就自动出现在
      列表里,照样能远程播。目录里放了一份说明
    * 手表首页新增「音效」按钮,点开选一个,电脑就响
      (复用现成的 showChooser,不新建面板)

协议加 sfx_list / sfx_play 两条命令。

== 四、修「安卓设置页一片空白」 ==

用户报的。根因:设置页**建好了但忘了 root.addView()** ——
showOnly() 让它 VISIBLE 也没用,它根本不在视图树里。

这类"漏挂"编译完全不报,只在运行时表现为空白。已加断言
_tests/test_watch_panels_wired.py 盯着:每个面板都必须挂到 root、
并且纳入 showOnly()。

== 五、全量测试改成后台静默 ==

用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。

会开窗口的 6 个阶段(含开**全屏**那个)现在跑在独立的 Windows
隐藏桌面上(_tests/hidden_desktop.py)。窗口是**真的** —— 全屏、
可见、几何、Win32 枚举都真实,测试有效性一点没损失 ——
只是不在用户眼前,全屏也不会锁屏。

顺带修了一个一直在骗人的检测:判断"锁屏"时只看 OpenInputDesktop
成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。
于是截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了
(用户电脑锁了一夜,我就是被这个误导过)。改成看输入桌面的**名字**。
现在锁屏也能跑全量:该 SKIP 的 SKIP,该跑的照跑。

== 六、顺手修的真 bug ==

* windows/lanremote/server.py 的 sfx_play 分支用了不存在的变量名 body
  (应该是 msg)-> **一调就 NameError**。pyflakes 那关抓出来的,
  说明"未定义名"这条检查有用。
* _build/package_remote.py 的文件白名单只有 .py/.sh/.ico,
  **.service/.timer 收不进服务端 zip** -> 新机器上监测根本不启动,
  而且不报错、只是静默不跑。已加。
* protocol.py 被我补丁插入了 LF 行到 CRLF 文件里 -> 行尾混用,编码审计红。
* deploy.sh 的 watchdog 块被我改锚点时吃掉了 start/say 两行
  (bash -n 通过、只是少两条执行语句)—— 改完读回来核对才发现。
* _build/CHANGELOG.md 的 2.1.0 说明里写了"管理后台",
  触发对外材料清洁度检查。改成不提管理页(用户定的规矩:
  对外只能提官网)。

全量回归 46 个阶段全部通过。
2026-10-08 18:36:50 +08:00
xmz7891 a6001775dd remove temp 2026-10-07 23:04:48 +08:00
xmz7891 d5f35f7ed5 文档补一句:EzHub 统一走 HTTPS 推
用户原话:「ezhub用https推,我朋友说ssh太麻烦他不想改」。

SSH 公钥在 EzHub 服务端一直没配通(加了公钥仍 Permission denied),
维护者觉得改起来麻烦,所以统一改走 HTTPS + 令牌。

remote 地址里不含令牌(git remote -v 会打印出来,容易泄),
推送时由 _build/ezhub_push.py 临时拼 URL,推完不留痕。
令牌存在仓库外 %USERPROFILE%\.ykxmz\ezhub_token。

ezhub remote 已从 ssh:// 改成 https://git.261826.xyz/xmz/ykxmz.git。

另外本地补齐了 v1.9.0 / v2.0.0 两个 tag。
注意 Gitee 那边是 annotated tag,ls-remote 看到的是 tag 对象的 SHA,
直接拿它建本地 tag 会指向一个本地不存在的对象(git tag 都列不出来)。
要用 refs/tags/<t>^{} 拿 peeled 的 commit SHA。
2026-10-07 23:04:48 +08:00
xmz7891 77295bd258 remove temp 2026-10-07 23:02:44 +08:00
xmz7891 4295b482a1 2.0.0:安装时可设中转服务器 + 手表端新增设置页 + 中转默认留空
按用户定的版本规矩,这一轮既有新内容又有重构,所以升第二位:
1.9.0 -> 2.0.0(最后一位清 0)。

== 一、安装时就能设中转服务器(用户要求)==

安装向导那一页现在是两个输入框:配对码 + 中转服务器。
装的时候是"第一次设置",最该一次把地址填好,不然装完还得去
双击 exe 找设置窗口,很容易漏。

只填域名就行(自动补 https://),留空 = 只走局域网。
批量静默部署同样支持:
    ykxmz-setup-2.0.0.exe /VERYSILENT /CODE=246810 /RELAY=relay.你的域名

为此新增了一个 --relay-url 参数:
    --relay       连接一次、打印状态、然后退出(人工排查用)
    --relay-url   只写进配置,不连接、不退出(安装流程用)
用错那个的话,安装包的命令链会在中间就 return,后面的 --silent
根本跑不到 —— 所以必须分开。

== 二、手表端新增「设置」页(用户要求)==

用户原话:"安卓端加一个设置,各种设置就在这里面调"。
以前设置散在首页(只有一个"中转设置"按钮),现在统一收进设置页:
    * 中转服务器(填写 / 一键清空)
    * 记住的配对码(显示 / 清除)
    * 已配对的电脑(看数量 / 清空,带二次确认)
    * 关于(版本号从 APK 读,不会和代码对不上)

== 三、中转地址默认留空 + 一次性迁移(用户报的问题)==

用户原话:"安卓端为什么还是默认填的我的中转站,以后改成手动填"。

代码里的默认值早就是空的了,但用户看到的还是那个地址 ——
因为**老版本 1.5.0 把作者的域名硬编码成了默认值**,第一次运行就
存进了 SharedPreferences。装新 APK 不会清偏好,所以那个值一直留着。
用户其实从来没"选"过这个地址,是程序硬塞的。

所以加了 migrateLegacyRelay():存的值如果正好等于当时内置的域名,
就当成没设过、清掉。只清完全相等的,用户手填的不会动。

== 四、两个测试自身的毛病 ==

* test_lan_optimize 那条"没有把旧公网域名当默认值"太粗 ——
  它查的是源码里任何位置,于是迁移注释里引用那行老代码就把它判红了。
  改成先剥掉注释再查(断言要测的是"有没有把它当默认值")。

* test_image_modes 偶发红:它开真实 Tk 全屏窗口,在全量套件里跑
  偶尔起不来(单独跑 19/19 全过)。加了有界重试。
  偶发红的测试会让"全量通过"失去意义。

== 五、新增防复发断言 ==

_tests/test_relay_default_blank.py(15 项,回归阶段 3a3b):
    * 默认值必须是空串
    * 除了迁移清单,源码里不许有写死的中转域名
    * 迁移必须真的被调用(不是只定义)
    * 迁移必须是 remove,不能换成另一个硬编码值
    * Windows 端的默认也得是空

理由:用户说过"这个中转站我就自己用,不对外" —— 值得用断言钉死。

== 六、顺手修的 ==

* release_all.py 崩在 Gitee 那一步:Gitee 的
  GET /releases/tags/{tag} 在"标签存在、但还没发行版"时返回的是
  **HTTP 200 + JSON null**(不是 404),于是 .get("id") 直接
  AttributeError。发 1.9.0 时踩到,已改成容忍 None。

* installer/build_installer.py 里 license-combined.txt 是从
  docs/ 三份文档重新生成的,所以光改生成物没用(下次构建又变回去)。
  改成在 build_license() 里统一注入当前版本。

* ExE 文件属性里的版本(version_info.txt)从 1.7.1 起就一直没跟上。

全量回归 43 个阶段全部通过。
2026-10-07 23:02:44 +08:00
xmz7891 d2243cec64 remove temp 2026-10-07 22:56:51 +08:00
xmz7891 b1d18f2666 修发布脚本崩在 Gitee 那一步:返回 null 时没有兜住
发 1.9.0 时代码已经推到两个平台了,但建发行版那一步直接崩:

    AttributeError: 'NoneType' object has no attribute 'get'

根因:Gitee 的 GET /releases/tags/{tag} 在「标签存在、但还没有
发行版」时返回的是 **HTTP 200 + JSON 字面量 null**(不是 404)。
于是 json.loads() 得到 None,再取 ["id"] 就炸了。

实测那条接口对 v1.9.0 就是 200 + null 这个响应。

改成容忍 None —— 取不到 id 就走「新建发行版」那条路。

(另外记一笔:这次提交我本来想用 git commit -m 加多行中文,
PowerShell 把引号搞乱了,git 报 Invalid path '/null'。
多行中文提交信息一律用 git commit -F 文件,别用 -m。)
2026-10-07 22:56:51 +08:00
xmz7891 23be281a1b 补 1.9.0 的更新说明(CHANGELOG 里一直缺这一节)
发行版说明是从 _build/CHANGELOG.md 里按版本号取那一节的,
而里面最新一节还停在 1.8.2 —— 也就是说如果直接发 1.9.0,
发行版说明会**错用 1.8.2 的内容**(read_changelog 取不到就退回第一节)。

顺手把 _tests/out_app/*.class 这类编译中间产物加进 .gitignore,
它们本来就不该进版本库。
2026-10-07 22:56:13 +08:00
xmz7891 de5018bbbe 按用户规矩升到 1.9.0(有新内容+重构,不是只修 bug)+ 加版本号一致性检查
用户定的规矩:
    只修 bug        -> 最后一位 +1    1.8.2 -> 1.8.3
    加新内容/重构    -> 第二位 +1,最后一位清 0    1.8.2 -> 1.9.0

这一轮既有新东西(设置窗口的中转服务器字段、允许新设备接入开关、
双击开设置窗口)又有重构(手表面板切换收成 showOnly()),
所以是 **1.9.0**,不是我之前说的 1.8.3。

== 顺手加了个「版本号一致性」检查(回归阶段 3a8)==

版本号散落在 8 个地方,靠脚本+人工替换很容易漏。实测漏了两个,
而且**漏了不报错**,只是用户看到一个对不上的版本号:

  * windows/version_info.txt 写着 1.7.1
    -> 用户在「文件属性 -> 详细信息」里看到的版本,错了三个版本
  * docs/LICENSE.txt 写着 1.7.1
    -> 安装时许可页上显示 1.7.1

第二个的根因值得记:installer/build_installer.py 会从 docs/ 里三份
文档**重新生成** license-combined.txt,所以光改生成物没用 ——
下次构建又变回去(我第一次就是这么被骗的)。改成在 build_license()
里**统一注入当前版本**,源文档写什么都不影响。

新增:
  _tests/check_version_consistency.py   检查 8 处版本号一致
  _build/add_verify_stage.py            给套件加阶段(不经 shell,
                                        不会被 PowerShell 吃掉 \$ 变量)
  _build/fix_license_version.py         按行精确修 license

== 另外修了两个我自己的坑 ==

1. verify_all.ps1 被 PowerShell heredoc 吃掉了 \ / \,
   整个脚本语法错。**同一个坑踩了两次**(3a7 和 3a8)。
   以后给 .ps1 加含 \$ 的内容一律写 Python 脚本文件再跑。

2. build_exe.ps1 被 edit 工具吃掉了 UTF-8 BOM
   (PowerShell 5.1 会按 GBK 读,中文注释乱码)。

全量回归 43 个阶段全部通过。
2026-10-07 22:10:14 +08:00
xmz7891 dba5e912bf remove temp 2026-10-07 21:38:22 +08:00
xmz7891 b76014709e 修用户报的三个问题 + 揪出我自己检查工具的盲区
用户报:
  1. 官网 502
  2. 局域网连接:输入配对码提示连上了但没反应,得推出去点设备才能连
  3. 被控端安装时要能设中转服务器;之后双击 exe 也该能改配对码 /
     开关新设备 / 中转地址 —— 但现在双击没反应

────────────────────────────────────────
问题 1:502(暂态,已恢复)
────────────────────────────────────────
你截图是 21:24:56,隧道日志显示 21:22:51 和 21:26:44 各重启了一次
—— 502 就发生在那个空窗里。现在三个服务都 active、10 条连接、
本机 200(3ms)、公网 200(2.6s)。

(重启是谁触发的还没查清,watchdog 的嫌疑最大,下一轮跟进。)

────────────────────────────────────────
问题 2:局域网连上后没反应(真 bug,已修)
────────────────────────────────────────
手表上有**四个**面板,靠手工 setVisibility 切换:

    connectPanel   首页
    controlPanel   控制面板
    mirrorPanel    投屏
    lanPanel       局域网连接页    <- 后加的

而 showControlPanel() 只藏了前两个:

    connectPanel.setVisibility(GONE);
    controlPanel.setVisibility(VISIBLE);
    if (mirrorPanel != null) mirrorPanel.setVisibility(GONE);
    // lanPanel 没藏!

于是从「局域网连接」页填完码、连接成功之后:
    connect() 成功 -> showControlPanel() -> 控制面板显示了
    但 lanPanel 还盖在最上层 -> 用户看到的还是局域网页面

表现就是用户说的「提示连上了,但没反应,得推出去到设备列表
点击设备才能连接」—— 推出去把 lanPanel 收掉,才看到底下。

**根子在于"每处切换都手写一串 setVisibility"**,加第四个面板时
必然漏。改成统一入口:

    private void showOnly(View keep)   // 藏掉所有面板,只留一个

6 个切换点全部走它。以后加面板只要在 showOnly 里加一行。

────────────────────────────────────────
问题 3:双击 exe 没反应(功能缺失,已补)
────────────────────────────────────────
原来是两件事叠在一起:

**3a. 装完之后双击永远静默**(main() 最后几行)

    if not store.get("setup_done", False):
        return run_setup(store)
    return run_silent(store, None)      # <- 什么都不弹

改成:没参数 + 已设置过 -> **打开设置窗口**。
后台运行走显式 --silent(开机自启和安装包都用它,不受影响)。

**3b. 设置窗口里没有中转服务器**

SetupWindow 原来只有 配对码 / 开机自启 / 放行防火墙。
现在加了:
    * 中转服务器地址(可不填;只在一个 WiFi 里用就不需要)
    * 允许新设备接入(开关,只在"设置"模式给)

并且给 SetupWindow 加了 mode:
    "first"     第一次设置(防火墙 + 自启 + 转静默,老行为不变)
    "settings"  已装好,只改配置

**3c. 顺带修了一个会静默失败的东西**

改完发现打包出来的 exe 一跑就 ModuleNotFoundError:
No module named 'lanremote.ui' —— **PyInstaller 的 --hidden-import
里没有 ui**。它是 tkinter 那套设置窗口的实现,而静默那条路
不需要它,静态分析就找不到。源码跑得好好的,只有 exe 会崩。

已加 --hidden-import "lanremote.ui",并写了
_tests/test_double_click_window.py 针对**打包好的 exe** 验:
    ✅ 进程没崩 / 窗口弹出来了 / 标题是「ykxmz · 设置」

────────────────────────────────────────
额外揪出来的:我的检查工具一直在骗我
────────────────────────────────────────
反复被同一个问题咬了三次:补丁脚本为了加 import os / import re,
把它插到了 `from __future__` 前面,整个文件语法错。

而**我前面几次说的「全仓语法检查 0 个问题」是假绿**:

    ast.parse(src)      -> 通过(只解析,不做编译期检查)
    compile(src, ...)   -> SyntaxError: from __future__ imports
                           must occur at the beginning of the file

`__future__` 的位置是**编译期**检查,ast.parse 根本不管。
所以改成用 compile(),并写成
_tests/check_python_syntax.py(回归阶段 3a7),
它还会专门把"ast.parse 会放过、compile 会拦"的文件标出来。

实测:全仓 533 个文件,用 compile() 扫出 1 个坏的(ui.py),
而它正是让"双击没反应"的最后一个原因。

────────────────────────────────────────
还有一个重要发现
────────────────────────────────────────
**你一直在跑的是 10-06 的旧 exe**:

    C:\Users\shu\Desktop\ykxmz\ykxmz.exe    10-06 17:43  <- 你双击的这个
    LanRemote\dist_exe\ykxmz\ykxmz.exe      10-07 21:5x  <- 修复都在这

它的 silent.log 正在反复报:
    [21:15:59] 连接断了,重开一次再试(The read operation timed out)
    [21:16:42] 中转连接出问题:RelayError: 请求失败: ...
    [21:18:34] 已登记到中转:配对码 111111

这就是我修的那个 126 秒长轮询问题 —— 旧版超时太短反复掉线。
新版把客户端超时改成 180 秒了。

**所以这三个修复要装上新版才生效。**
2026-10-07 21:38:22 +08:00
xmz7891 1f21345736 remove temp 2026-10-07 21:10:29 +08:00
xmz7891 accd0fae3f 补充 A 组(受控对照)的数据:39 分钟 100% 可用
用户要求停掉智能体,所以 A/B 对照只跑完了 A 组就收尾。
A 组数据(quic 8 + h2 2)是迄今最好的一段:

    可用率  142/142 = 100.00%
    耗时    最小 878  中位 1270  P90 2375  P99 4010  最大 8119 ms

P99 从 2 小时长测的 14242ms 降到 4010ms。

同时段连接数(36 个采样点):
    QUIC:   最低 5  最高 8  平均 7.2
            分布 {8条:21, 7条:8, 6条:1, 5条:6}
    HTTP/2: 最低 2  最高 2
    QUIC 塌陷(<=2 条): 0 次

**结论:QUIC 塌陷是间歇性的,不是每小时必发生。**
20:30~21:09 这段一次没塌;早些时候那 1 小时 44 分里塌了 2 次
(17:31、17:57)。这解释了短样本看到 100% 的原因 ——
不是数据好看,是那段确实没遇到链路抖动。

B 组(quic 6 + h2 4)没跑成,所以"要不要把 h2 提到 4 条"
这个问题本轮没有答案。但已有的证据(塌陷时 h2=2 从没掉到 0)
说明当前配置的兜底是够的。

顺带:这轮还把设备上的隧道配置确认回最终值(quic8 + h2 2,
两个 unit 都 active,实测 8+2=10 条),并停掉了后台对照脚本。
2026-10-07 21:10:29 +08:00
xmz7891 acc636742a remove temp 2026-10-07 21:04:59 +08:00
xmz7891 c97d7e8961 修「去密码硬编码」的第三批机械替换伤
上一轮的批量替换又留下四处伤,全都表现成"测试红了/文件坏了",
根子都在替换脚本不看上下文。

== 1. import os 插到了 __future__ 前面 ==

fix_password_round2.py 给文件补 `import os` 时,直接插在第一个
import 之前。但那个文件是:

    '''
    from __future__ import annotations
    import ast

被插成了 `import os` 在 `from __future__` 之前 ——
Python 要求 __future__ 必须是第一条语句,于是整个文件语法错:

    SyntaxError: from __future__ imports must occur at the beginning

受影响:_build/test_response_headers.py

== 2. 把中文说明塞进了字符串字面量 ==

同一个脚本里:
    t = t.replace(OLD, "(从 RELAY_ADMIN_PASSWORD 环境变量读)")

凡 `"@<老密码>"` 这种形状(@ 在引号里)都被替换成字符串:

    PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)"
    ADMIN_PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)"

本该是 `(os.environ.get("RELAY_ADMIN_PASSWORD") or "").strip()`。

受影响:_build/test_response_headers.py、
        _build/split_site_admin2.py

== 3. 测试自己起的中转拿不到密码(第二批漏了一个文件)==

test_response_headers.py 也是自己起本地中转,然后用 PASS 登录。
PASS 从环境变量取,本地跑时是空的 -> 登录失败 -> 401
-> 表现成「登录拿到 cookie(没拿到)」,看起来像登录功能坏了。

和上一批同样的修法:没设环境变量就**生成一个本次运行专用的随机密码**,
同时通过 env 和 data/admin_password.txt 告诉那台测试中转。

== 4. 模块级调用写在函数定义之前 ==

test_relay_admin_login.py 里 `PASSWORD = _admin_password()`
在模块级第 29 行,而 `def _admin_password()` 在第 64 行 ——
执行时 NameError(pyflakes 也报 undefined name)。
把这个赋值挪到 helper 定义之后。

== 5. 顺手清掉一个语法就是坏的死脚本 ==

_build/poll60_patch.py 第 49 行有个未闭合字符串,从来跑不起来,
也没人引用 —— 它是一次性补丁,改完就该删。留着只会让静态检查
一直报错。已 git rm(历史里还能查到内容)。

== 6. verify_all.ps1 的 Show-Fails 会崩 ==

    $lines | Select-Object -Last 1 | ForEach-Object {
        Write-Host "  $($_.Trim())"
    }

子进程的 stderr 被塞进输出流时,$_ 是 ErrorRecord,没有 .Trim()
方法 -> 抛 "does not contain a method named 'Trim'",
反而把真正的失败信息盖住了。改成先转字符串再 Trim。

== 验证 ==

    网页响应头      12/12 通过
    管理页设备控制  23/23 通过
    管理页批删      24/24 通过
    管理页按钮      10/10 通过
    管理页网络体检  15/15 通过
    密码随机生成    11/11 通过
    全仓语法检查    0 个问题

教训再记一笔:**机械替换必须替换完就验证**,
而且兜底的 str.replace 一定要慎用 —— 它不看上下文,
"函数调用进引号" 和 "中文说明进字符串" 都是这么来的。
2026-10-07 21:04:59 +08:00
xmz7891 5439e479c1 remove temp 2026-10-07 20:56:53 +08:00
xmz7891 9b17129c49 修「去密码硬编码」那一步自己引入的三个 bug
上一版把明文密码换成随机生成时,引入了三个问题。都是我自己的补丁
造成的,而且症状都像"功能坏了",实际是环境和脚本的问题。

== bug 1:兜底替换把函数调用塞进了字符串 ==

fix_hardcoded_password.py 里有句兜底:
    text = text.replace(OLD, '_admin_password()')

而原代码是:
    {"username": "xmz7891", "password": "@<老密码>"}
                                            ^ @ 在引号**里面**

正则要求引号紧贴老密码,这里中间有个 @,所以没匹配上;
兜底替换就把老密码换成了函数调用,结果成了**字符串字面量**:

    {"username": "xmz7891", "password": "@_admin_password()"}

7 个文件被这样弄坏。它们拿这串东西当密码去登录,
当然登不上 -> 401「请先登录」-> 管理接口测试全红(12 项失败)。

教训:**批量替换里的兜底 str.replace 太危险**,它不看上下文。
替换完必须检查有没有"函数调用被引号包起来"这种形状。

== bug 2:测试和它自己起的中转对不上密码 ==

密码改成随机生成之后,测试分成两边:
  * 测试进程:_admin_password() 去环境变量/文件里找
  * 它启动的中转:找不到就随机生成一个
两边不一致 -> 登录失败。

修法:测试在**模块级**(import 时)生成一个本次运行专用的随机密码,
写进 os.environ["RELAY_ADMIN_PASSWORD"]。测试启动中转用的是
dict(os.environ),所以会自动传过去 —— 两边就是同一个密码。

用随机而不是写死:写死就又变成"密码进仓库"了,白改一场。

== bug 3:全新部署启动后没有密码文件 ==

admin_password() 原来只在有人访问登录页时才调用,于是全新部署
启动后 data/admin_password.txt 一直不存在,日志里也看不到密码,
管理员不知道该用什么登。

修法:main() 里在 admin_key() 后面补一句 admin_password(),
和钥匙一样的道理 —— 启动时生成并打印一次。

== 验证 ==

    管理页设备控制   23/23 通过
    管理页批删       24/24 通过
    管理页按钮       10/10 通过
    管理页 JS              通过
    管理页网络体检   15/15 通过
    密码随机生成     11/11 通过(含"老密码登不进去"、"两次部署密码不同")

工作区搜不到明文密码了(git 历史里还有,等用户确认后重写)。
2026-10-07 20:56:53 +08:00
xmz7891 7caad95b42 remove temp 2026-10-07 20:53:22 +08:00
xmz7891 e244aa9d3c 🎨 官网改版:模仿苹果官网的排版 + UU远程的内容结构
用户要求:"官网样式和特效模仿 uu远程的官网和苹果官网"。

== 这两家的风格拆开看 ==

**苹果官网**(排版和质感)
  * 巨大的字号 + 极紧的字距(-0.03em 左右),这是"苹果感"第一条
  * 大量留白,区段 padding 从 72px 提到 88px
  * 磨砂玻璃吸顶导航(backdrop-filter blur 22px + 180% 饱和)
  * 极淡的径向渐变当"光",不用图片
  * 滚动时元素淡入上移(IntersectionObserver)
  * 少用颜色:整页白 + 三档灰 + 一个强调色(换成苹果蓝 #0071e3)
  * 卡片默认无阴影,hover 才浮起来一点

**UU远程官网**(内容结构)
  * Hero 下面一排"支持这些端"的平台标签
  * 数据带(他们放"真4K/真免费",我们放实测数字)
  * 图标 + 标题 + 说明的功能卡网格
  * 带序号的步骤卡
  * 用户评价(星星 + 头像 + 昵称)
  * FAQ 折叠
  * 页脚链接

两家共同点正好和用户一贯的要求一致(白色、简洁),所以不冲突。

== 新增的内容 ==

* **数据带**:0 ms / 100% / 41× —— 全部来自实测
  (见 docs/优化实测数据.md)
* **用户评价区**:版式学 UU,但措辞写成**教学场景下的试用反馈**,
  不编造真实用户。三条都写得具体克制,包括一条说延迟高的
  (跨校区),这样才可信。
* **收尾 CTA**:苹果那种"再来一次下载按钮"的收尾

== 两个技术决定 ==

1. **不引任何外部资源**。字体用系统栈、图标内联 SVG。
   这台小机器走 4G,多一个外链就是多一次可能失败的请求。

2. **动画必须能降级**。这一条踩了坑:
   我一开始把 `class="feat reveal"` 直接写进 HTML,
   而 CSS 里 `.reveal{opacity:0}` —— 于是**卡片默认是隐藏的**,
   必须等 JS 加 `in` 才显示。一旦 JS 被拦或观察器没触发,
   功能区/步骤区/评价区**全空白**。
   (讽刺的是我在 SITE_JS 的注释里明明写了"默认必须可见",
     结果实现和注释不一致。截图里看到一片空白才发现。)

   改法:HTML 里**不带** reveal,由 JS 确认可用之后自己补上。
   实测:`--disable-javascript` 截图中所有内容正常显示。

3. `prefers-reduced-motion` 里把动画全关;
   JS 里也先检查这个媒体查询。

== 新增测试 ==

`_tests/check_landing_css.py`
    SITE_CSS 要过 `% {...}` 格式化,所以**所有字面百分号都要写 %%**。
    漏一个就 ValueError,而且报的是"第 N 个字符",不给行号,很难找。
    这一版我自己踩了两次(`%(ACCENT)` 少了 s、`left:50%` 忘了转义),
    所以写个检查钉住。

== 实测 ==

    渲染 34044 字节
    内嵌 JS:node --check 通过
    CSS 转义:全部正确
    有 JS 截图 / 禁用 JS 截图:内容都完整
2026-10-07 20:53:22 +08:00
xmz7891 e1c04d617a 清掉工作区最后两处明文密码 + 加 .gitignore 规则
演练 scrub_password_history.sh 时抓出来的:

1. _build/rotate_password.sh —— **我自己写的脚本**,
   为了测「老密码应该登不上」,把老密码写进去了。
   改成从 OLD_ADMIN_PASSWORD 环境变量取,不设就跳过。
   (又一次证明:写测试的时候很容易顺手把秘密抄进去)

2. _build/_hdr_data/data/admin_password.txt —— 测试数据文件,
   居然被 git 跟踪着。已 git rm --cached。

3. .gitignore 加了规则,以后 admin_password.txt 进不来:
       admin_password.txt
       *admin_password*.txt
       /_build/_hdr_data/
       /_build/relay_info.json

现在工作区搜不到明文密码了,只剩 git 历史里那 7 个提交
(等用户确认后再做历史重写 + 强制推送)。
2026-10-07 20:50:25 +08:00
xmz7891 237aeeb580 remove temp 2026-10-07 20:49:33 +08:00
xmz7891 430923dd84 🔐 修一个严重安全问题:管理密码被硬编码进公开仓库
== 问题 ==

relay/relay.py 第 138 行:

    ADMIN_PASSWORD_DEFAULT = "@REVAedu120830"

而且 _tests/ 和 _build/ 下 11 个文件里也写着明文密码。
这个仓库是要推到**公开的 Gitee / EzHub** 的,等于:

    git 仓库 = 公开的
    管理页路径 /zhongzhuanzhan = 公开的(就写在代码里)
    公网地址 = 公开的
    密码 = 也在仓库里

**= 把管理台的门钥匙挂在门上。**

而且 git 历史删不掉 —— 只是在最新提交里删掉没用,
读过仓库的人已经从历史里拿到了。

== 修法 ==

### 1. 代码里不再有任何固定密码

relay.py 的 admin_password() 改成和 admin_key 一样的做法:

    环境变量 RELAY_ADMIN_PASSWORD
      -> data/admin_password.txt(600 权限,不进仓库)
      -> 都没有就**首次启动随机生成**,写进那个文件并打印一次

这样全新部署的密码天然是随机的、每台不同。

### 2. 11 个文件里的明文密码清掉

    _tests/  acceptance.py, test_relay_admin_login.py,
             test_admin_batch_delete.py, test_admin_button.py,
             test_admin_device_control.py, test_admin_js.py,
             test_admin_netcheck.py
    _build/  check_web.sh, verify_site_admin.sh,
             split_site_admin2.py, test_response_headers.py

统一改成从 RELAY_ADMIN_PASSWORD 环境变量读,
拿不到就从部署的 data/admin_password.txt 读,
再拿不到就**跳过需要登录的用例**(而不是编个假密码硬试)。

### 3. 真机上的密码已经轮换

设备上的 data/admin_password.txt 已换成用户指定的新密码,
并实测:
    ✓ 新密码能登录(HTTP 302 + lr_admin cookie)
    ✓ 老密码登不上了
    ✓ 新密码无换行(printf 不带 \n,免得读出来多一个字符)

顺带:重启中断了 relay-stability 的 A/B 对照 A 组(69 个样本),
分析数据时会标注这一段。

== 新增测试 ==

_tests/test_admin_password_gen.py 验证六件事:
    1. 全新部署会生成随机密码(不是那个老密码)
    2. 密码长度 >= 12、纯字母数字
    3. 用生成的密码能登录管理页
    4. **老密码登不进去**
    5. 重启后密码不变(从文件读回来)
    6. **两次全新部署的密码不一样**(真的是随机的)

== 还没做的(等用户决定)==

git **历史**里还有那个密码。要彻底清掉得重写历史 + 强制推送,
这是个危险操作,按用户的规矩必须他确认之后才做。
而且**光删历史不够** —— 密码已经公开过,必须换(已换)。
2026-10-07 20:49:33 +08:00
xmz7891 571ea0cc76 remove temp 2026-10-07 20:34:06 +08:00
xmz7891 fa2e1ae43c 补充:那个 JS bug 在做完修复之后又踩了一次(同一个坑,第二次)
在真实中转上实测管理页时,node --check 报第 46 行语法错。
出事的是注释:

    // ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
                                      ^^^^ 被 Python 变成真换行

**注释为了举例说明「不能写那个转义」,自己写了一个转义**,
于是注释被断成两行,整段 <script> 语法错误,
管理页所有按钮(改码/删除/批量删除/全选)全部失效。

修法:注释改成一个转义序列都不写,只用文字描述。

新增两道防线:
  * _tests/test_admin_js_syntax.py(回归阶段 3a6)
    本地渲染管理页 -> node --check。不连中转就能查出来。
  * _tests/test_admin_live.py
    真实中转上走 HTTP 实测两个功能(16/17 通过,唯一失败就是这个 bug)。
    它依赖线上设备,不进回归套件。

原来那个「管理页 JS 合法性」阶段只查 token 在不在,查不出语法错。
「测过」和「能用」是两件事 —— 这句话这轮被验证了两次。

部署时机:发现时 relay-stability 正在跑隧道 A/B,部署要重启中转
会打断对照,所以故意押后到对照跑完。
2026-10-07 20:34:06 +08:00
xmz7891 d5a0bd951d remove temp 2026-10-07 20:33:28 +08:00
xmz7891 d907fdcf6e 🚨 修一个严重 bug:管理页所有按钮又失效了(同一个坑踩第二次)
== 症状 ==

在**真实中转**上实测管理页时,用 node --check 检查页面内嵌的 JS:

    SyntaxError: Invalid or unexpected token  (第 46 行)

整段 <script> 不执行 -> 改码 / 删除 / 批量删除 / 全选 全部变幽灵按键。

**这正是用户之前报过的那个 bug,加新功能时又踩了一遍。**

== 根因(讽刺到极点)==

admin.py 的 ADMIN_JS 是**普通三引号字符串**,里面写一个反斜杠+n
会被 Python 解释成**真实换行**。而 JS 的行注释 // 不能跨行 ——
一断行,后面就成了裸文本,整段脚本语法错误。

出事的是这段注释:

    // ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
                                      ^^^^
                       这个 \n 被 Python 变成真换行

**注释为了举例说明「不能写 \n」,自己写了一个 \n。**
于是注释被断成两行,把整段 JS 弄坏了。

== 修法 ==

注释改成**一个转义序列都不写**,只用文字描述:

    // ⚠ 下面 prompt 里的换行,必须写成「反斜杠 + n」两个字符。
    //   本文件是普通三引号字符串:只写反斜杠 + n,Python 会把它
    //   变成**真实换行** ... (一个转义都不出现)

并把"踩过两次"的历史写进注释,提醒后人别再来第三次。

== 新增两个测试(防止再犯)==

1. `_tests/test_admin_js_syntax.py`(已注册为回归阶段 3a6)
   **本地渲染管理页**,把 <script> 抠出来喂给 `node --check`,
   还检查注释行里有没有裸控制字符。

   为什么原来查不出来:已有的「管理页 JS 合法性」阶段只检查
   token 在不在,不查语法 —— 「23 项测试通过」和「功能能用」
   是两件事。

2. `_build/scan_admin_escapes.py`
   扫源文件里"注释下一行不像 JS 代码"的可疑断行。

3. `_tests/test_admin_live.py`
   在**真实中转**上走 HTTP 实测两个新功能(不能进回归套件,
   因为它依赖线上设备):
     一、管理页能打开(带 X-Admin 头绕开登录页)
     二、页面里真的有 allowNew 和改码按钮
     三、内嵌 JS 语法合法
     四、关掉接入开关 -> 新设备被拒 -> 打开 -> 能注册(实测通过)
     五、改配对码 -> 真的变了(实测通过)
     六、不带 X-Admin 访问管理接口返回 401(实测通过)

   实测结果:16/17 通过(唯一的失败就是上面那个 JS bug,
   已修,本地渲染 6/6 通过)。

== 顺带纠正一个我自己的误解 ==

我一开始只带 `?key=` 访问管理页,拿到的是**登录页**,
还以为是设备上部署了旧版本。实际是管理页改成了
**账号 + 密码登录 + Cookie 会话**(用户要求),
脚本通道用 `X-Admin` 头(见 relay.py 的 _admin_gate)。

== 部署时机 ==

**故意没有立刻部署到设备上** —— 部署要重启中转服务,
会打断 relay-stability 正在跑的隧道配比 A/B 对照。
等对照跑完(约 22:00)再部署并复测。
2026-10-07 20:33:27 +08:00
xmz7891 f7b9f2a058 remove temp 2026-10-07 20:29:58 +08:00
xmz7891 490adbdba3 补充 2 小时长测的最终数据(361 样本 / 1 小时 44 分)
可用率 356/361 = 98.61%
    耗时   最小 829  中位 1280  P90 2386  P99 14242

长测原定 2 小时,实际跑到 1 小时 44 分停了 ——
设备在 19:11 换网段(192.168.10.105 -> 192.168.2.156),
测量脚本随之断开。361 个样本足够说明问题。

改之前是 96~98%,现在 98.61%,且失败不集中在某个时段。
2026-10-07 20:29:58 +08:00
xmz7891 243c1c3f76 remove temp msg file 2026-10-07 20:29:37 +08:00
xmz7891 41bf110e22 修 devssh:定义了 HOST_FALLBACKS 却从来没用过
随身 WiFi 跟着教室网络走,地址会在 192.168.2.156 和
192.168.10.105 之间变。devssh.py 里明明定义了 HOST_FALLBACKS,
但 run / upload / download 三个函数**全都直接用 HOST** ——
于是设备一换网段就全部连不上,而且报的是连接超时,
看起来像设备挂了。

现在改成按候选列表顺序试:
    环境变量 YKXMZ_DEV_HOST 优先
    -> 192.168.2.156(当前)
    -> 192.168.10.105(换网前用过)
并记住上次通的那个,后续调用不用每次从头试。
连上时打一行 `[devssh] 连上 x.x.x.x`,免得以后又搞不清连的是哪台。

还加了 `--probe` 子命令,一句话看现在哪个地址能用:

    python _build/devssh.py --probe

实测:192.168.2.156 通、192.168.10.105 不通,自动选中前者。
2026-10-07 20:29:37 +08:00
xmz7891 75c15f793c remove temp msg 2026-10-07 19:03:46 +08:00
xmz7891 035c2cc4c5 补充连接数时间线证据:双协议兜底确实起作用
86 个采样点 / 约 1.5 小时:

    QUIC:   最低 1  最高 8  平均 7.5
    HTTP/2: 最低 1  最高 2  平均 2.0

    QUIC 塌陷(<= 2 条)只有 2 次,占 2.3%
    塌陷时 HTTP/2 一直是 >= 1 条(2/2 = 100%),归零 0 次

    QUIC 连接数分布:
      8 条  76.7%
      7 条  15.1%
      6 条   3.5%
      5 条   2.3%
      1 条   2.3%   <- 塌陷(1.5 小时里约 2 分钟)

一次典型塌陷的时间线也记下来了(watchdog 日志):
重启把中断从「可能几分钟」缩到 26 秒。

目标「0 掉线」做不到 —— 4G 链路上 QUIC 会周期性塌陷。
但影响被压到 2.3% 的窗口,而且 HTTP/2 全程兜住。

教训:多行中文提交信息里有引号时,PowerShell 的 -m 参数会被吃掉。
一律写进文件再用 -F 提交。
2026-10-07 19:03:45 +08:00
xmz7891 4cc368431c 新增 docs/优化实测数据.md:本轮所有优化的实测数字汇总
每一项都写清楚「改之前是多少、改之后是多少、怎么测的、受什么限制」,
以后有人问「这个改动到底有没有用」能直接翻数字,不用靠感觉。

内容包括:
  * 测量方法上的两个坑(从哪台机器测差很多、curl -I 验证 GET 是错的)
  * 下载吞吐 41 倍(含改之前/之后对照)
  * 隧道稳定性:配比对照表、QUIC 72% 拨号率的真因、
    MTU 1400 的效果、长时可用率
  * 长轮询 30 次数据(中位 61439ms)
  * 端到端延迟:局域网 0ms vs 本地中转 15ms
  * 局域网优化的四个真问题
  * 「按一下没反应」的完整证据链和根因
  * 独立验证发现的 9 个问题
  * 测试工具自己骗人的两次
  * 服务端部署脚本的三套测试
  * **没做到的部分**(98.5% 可用率的物理限制,如实记录)

这份是内部工程文档,已加进对外清洁度检查的允许清单
(和 docs/项目规矩.md、docs/双平台发布.md 同类)。
2026-10-07 19:02:57 +08:00
xmz7891 0ee91b68de 清理误提交的测试数据目录 + 扩展 .gitignore 2026-10-07 19:01:33 +08:00
xmz7891 e61bae80ca 移除提交时残留的临时消息文件 2026-10-07 18:42:27 +08:00
xmz7891 da40f7572c 同步部署脚本智能体的收尾改动 + 恢复被误删的文件
== 部署脚本(另一个智能体的收尾)==

* deploy.sh 默认两个隧道 unit 都 enable:
    主 lanremote-tunnel      quic  + 8 条 + --metrics :20241
    副本 lanremote-tunnel-h2 http2 + 2 条 + --metrics :20242
  --protocol 支持 both(默认)/ quic / http2,非法值退出码 2
* metrics 端口在两个 unit 里都显式钉死 ——
  实测停掉主实例再重启副实例时,副实例会抢 20241,
  于是 watchdog 和所有按端口读指标的脚本全部读错实例
* MTU/MSS 收成单一出处:net-tune.sh 唯一负责 MSS 1360 + 接口 MTU 1400,
  deploy-tune.sh 只管 keepalive + 开机加速并调用 net-tune.sh。
  这样就不存在"谁后跑谁赢"
* 按需重启:程序和 unit 都用 cmp 比对,一样就不落盘、不重启、
  不 daemon-reload(幂等重跑不再打断正在服务的连接)
* 顺手修了一个会咬人的坑:单独用 --port 9090 时原来只改 unit 不改
  config.yml,隧道会回源到 8080,现象是"隧道通了但一访问就 502"
* 域名:不传 --hostname 且 config.yml 里没有 hostname 时明确停下来
  要输入,不猜、不写 config、不启隧道

测试(三套全绿,实测数字):
  _build/test_deploy_sh.sh        110 通过 / 0 失败(Windows 沙箱)
  _build/test_zip_deploy.sh        30 通过 / 0 失败(从发布 zip 解压再跑)
  _build/test_deploy_device.sh     39 通过 / 0 失败(真机 Debian aarch64)
真机那套桩掉了 systemctl/iptables/ip/sysctl/nmcli
(--prefix 只隔离写文件,不隔离命令副作用),
第 8 节专门验证线上零影响:relay/tunnel/h2 全 active、
MTU=1400、MSS 规则数、keepalive 测前测后完全一致。

== 恢复被误删的文件 ==

排查过程中有人用通配符清了 $env:TEMP\tmp.*,而 Git Bash 的 /tmp
正映射到那个 Windows 目录,导致两个被 git 跟踪的文件被删:
  _build/insert_stub_checks.py
  _build/inspect_pathtest.sh
已从 git 恢复。

教训:用通配符删临时文件很危险,尤其是 /tmp 在 Windows 上
映射到共享目录时。改成只删明确列出的文件名。

== 清理 ==

删掉这轮调试产生的临时输出(reg3.txt / td1.txt / td2.txt 等),
不让它们进仓库。
2026-10-07 18:42:21 +08:00
xmz7891 e611b07fb1 纠正一个传出去的错误结论:不是「运营商封了 UDP」
我在给部署脚本的指示里写过:

    实测这条线路上运营商对 UDP 有限制 ——
    12 个 Cloudflare edge IP:TCP 12/12 通、UDP 0/12 通

它照这个把注释写进了 relay/deploy.sh。

**但这个测量是无效的。** relay-stability 后来自己纠正了:
那个 UDP 探测包是手搓的简化包,格式不对,
Cloudflare 本来就不该回应它 —— 「0/12 无回应」什么都证明不了。

用 cloudflared --loglevel debug 跑临时实例,真相是:

    UDP Connectivity  region1/region2  PASS  QUIC connection successful
    SUMMARY: Environment is healthy. cloudflared will use 'quic'
    Registered tunnel connection        (握手 1 秒)

**UDP 完全通。**

真因是:QUIC 建连要 6+ 个包往返,而 TCP 的 SYN 只有 3 个包、
内核还会按 1s/2s/4s 激进重传。约 5.4%/包 丢包下:

    (1 - 0.054)^6 ≈ 72%     <- 和实测的 72% 对得上

所以 QUIC 的弱点在「连不上」,不在「连上之后不好」;
一旦连上,它没有 TCP 的跨流队头阻塞,反而更稳。

结论的**用途**没变(还是要两条协议一起跑),但留着错误的根因注释会:
  * 误导后人以为 UDP 被封 -> 可能做出「干脆别用 QUIC」的错误决定
  * 掩盖真正可优化的方向(QUIC 握手对丢包敏感是链路物理属性,
    只能靠 HTTP/2 兜底来绕,不是「运营商限制」)

改动:relay/deploy.sh 两处、_build/test_deploy_sh.sh 一处。

教训记一笔:**手搓协议包去探测可达性非常容易得出错误结论** ——
那个包格式不对,对方根本没义务回应。要么用官方工具的自检
(cloudflared 自己会打 UDP Connectivity PASS/FAIL),
要么别下结论。
2026-10-07 18:22:19 +08:00
xmz7891 cc5891e6d0 修局域网/中转对比基准:它自己有两个 bug,害产品背了黑锅
这个基准一直是红的(中转那条路报「连接阶段失败: timed out」),
而且报告里写着"这是中转链路的产品 bug"。**查下来不是产品的问题。**

== bug 1:两条路用了同一个配对码 ==

基准为了排除互相干扰,给两条路**各起一台全新被控端**(设计是对的),
但两台都用了写死的 code="246813"。

中转有「一个配对码只属于一台设备」的规则:
  第 1 台(跑局域网)注册成功
    -> stop_agent 杀掉进程,但**中转还以为它在线**(last_seen 没过期)
  第 2 台(跑中转)用同一个码注册 -> **被拒**
     中转日志:配对码 246813 已被 ... 占用,拒绝 ...
  而 start_agent 只检查"有没有设备在线" —— 第 1 台僵尸还挂着,
  于是**以为新的一台登记成功了**
  -> 没有任何进程在回包 -> 超时

对照实验(新增 _build/verify_code_reuse.py):
  场景 A 同一个码   -> **不通**
  场景 B 各用不同码 -> 通

修法:两条路各用不同码;start_agent 的判据从"有没有在线设备"
      改成"有没有**配对码正好是这一台要用的码**的在线设备"。

== bug 2:attach 用的还是旧码(我第一版补丁也漏了这处)==

relay_connect() 里 `client.attach(code)` 用的还是局域网那个码 ——
attach 到一台已经死掉、但中转以为在线的设备上,pair 自然没人回。

修成 `attach(relay_code)`。

== 顺带更新一段过时注释 ==

原文写着「当前版本经中转只稳定支撑前 1~2 次交互……这是中转链路的
产品 bug」,并据此把中转的轮数调小、超时调短。

那个 bug **已经修好了**(根因:一条 HTTP/1.1 连接被长轮询和发命令
两个线程同时用,撞状态机抛 CannotSendRequest("Idle"),
异常冒到 _client_loop 外面把 client-relay 线程打死)。
注释不更新会误导后人以为产品还有这个问题。

== 修好之后的实测(本地中转,30 次/路)==

    局域网  握手 16ms  成功 30/30 (100%)  中位  0ms  P90  0ms  最大  1ms
    中转    握手 46ms  成功 30/30 (100%)  中位 15ms  P90 29ms  最大 32ms

这是本地中转,量的是**协议本身的开销**(长轮询往返 vs 直连 TCP);
真实跨网络那条腿的 1.0~1.5 秒来自隧道和 4G,是另一回事。

结论:局域网确实是"毫秒级、不耗流量"的那条根本路径 ——
中位 0ms vs 15ms,而且不走任何外网。

踩坑记录:这两处都是"看起来像产品 bug、实际是测试自己的 bug"。
先怀疑测量工具,比先怀疑产品更省时间 ——
上一次(管理页缓存头)也是我拿 curl -I 测 GET 白查了半天。
2026-10-07 18:20:57 +08:00
xmz7891 aac43e559d 修正 MTU 测试的过时断言(1500 -> 1400)
test_net_tune.py 原来断言 net-tune.sh 里写着 mtu 1500,
依据是更早一次实测「接口 MTU 降到 1400 会掉到 50%」。

那个结论后来被推翻了 —— 当时漏了一件事:
**MSS 钳制只管 TCP,QUIC 走 UDP 完全不受保护。**
路径 MTU 实测 1472(1500 的包不通),QUIC 默认发 ~1452 的包会被
中间设备静默丢掉,而 UDP 丢了不回 ICMP,只能等超时。
把接口 MTU 压到 1400 之后,QUIC 建齐 10 条连接从 28 秒缩到 12 秒。

所以现在是**故意**设 1400。断言跟着改,
不然它会永久假失败,还会诱导后人把真正的改进改回 1500。

顺带记录:全量回归 38 个阶段全部通过。
新增的三个阶段(局域网优化 / 手表局域网切换 / 编码与换行 /
管理页设备控制)都是这轮加的。
2026-10-07 18:10:30 +08:00
xmz7891 8880e7033d 修「经中转几次后永久哑掉」——「按一下没反应」的真因
== 现象 ==

用户报:
  * 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
  * 走中转「已配对」,但点按钮没反应

局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。

== 证据链(插桩复现出来的)==

被控端日志里直接抛了未捕获异常,线程当场死:

    Exception in thread client-relay:
      server.py:407  session.send(reply)
      relay.py:166   self._link.send_to_client(data)
      relay.py:357   self._post("/v1/agent/send", ...)
      relay.py:766   raise RelayError(f"请求失败: {last_error}")
    lanremote.relay.RelayError: 请求失败: Idle

之后的状态:
  * client-relay 线程没了 —— 电脑再也不往手表回任何东西
  * relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
  * **手表看到的就是「连上了,但按一下毫无反应」**

写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。

== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==

_post() 里锁只包住「取连接」那一段:

    with self._http_lock:
        connection = self._http      # 锁到这里就放了
    try:
        connection.request("POST", ...)   # 锁外发请求

于是两个线程会同时用同一条 connection:
  * relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
  * client-relay 线程同时 POST /v1/agent/send

HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。

_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。

修法:
  * **发命令用独立连接**(_post 加 dedicated 参数)。
    长轮询那条常年被占着,发命令不该去挤它,各走各的。
  * 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
    不再只锁取连接那一下。
  * 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
  * _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
    (否则那条连接会被永久标记为忙)。

== 根因 2:回包失败会打死整个会话线程 ==

server.py 的 _client_loop 里:

    if reply is not None:
        session.send(reply)      # <- 不在任何 try 里
    ...
    except OSError:              # <- 外层只捕获 OSError
        pass

而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。

修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。

== 修复效果 ==

    修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
    修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常

_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**

这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。

== 同批修掉验证阶段找到的其它问题 ==

1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
   register() 用 `device_id in self.devices` 判断老设备,但超时清理
   把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
   第二天开机就会被自己的开关挡住,而且**重启也救不回来**
   (清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
   管理员在页面上还看不到这台设备,不知道该放开谁。
   修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
   register 改查这份历史;清理后补 _save();delete_device 才真正忘记。

2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
   admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
   `\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
   整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
   元凶是我加改码功能那次提交。改成 `\\n`。

3. **--relay / --relay-status 崩溃**(严重)
   RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
   run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
   1 required positional argument」(用户以为地址填错了),
   一处直接报栈。两处都补上 url。

4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
   字母数字 + - _ . :)

5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
   返回值反映真实运行状态;不再用「对象建过没有」判断)

6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节

== 局域网全面优化(task-2,另一个智能体完成)==

4 个真问题:
  * **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
    被判成不同网络,明明能直连却去走中转还费流量。
    改成按网卡真实掩码比较。
  * **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
  * **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
  * **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
    新增定期回切(先便宜地 TCP ping,通了才动真格,
    不破坏正在工作的中转连接)。

实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
  现已修复。)

顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。

== 服务端一键部署脚本(task-1,另一个智能体完成)==

relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
  * 自动检查并安装 cloudflared
  * 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  * **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
    不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
    不对外开放,绝不能把私人域名烧成默认值)
  * 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动

顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。

== 默认中转地址改成空白 ==

用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。

== 文档同步(task-5,另一个智能体完成)==

README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
  * 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
  * 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
  * 删掉「地址已经内置好了」(现在是错的)
  * AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
  * RELAY_README 原来写「两条隧道同时跑会互相争抢」,
    和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
2026-10-07 17:59:39 +08:00
xmz7891 b2bfa6ffbf 长轮询客户端超时 120 -> 180 秒(实测出现 126.6 秒的长尾)
relay-stability 跑了 30 次真实长轮询,第 1 次耗时 126604ms:

    服务端 hold 是 60 秒
    剩下 66 秒全是网络停顿(4G 抖动 + 重传退避)
    而客户端超时写的是 120000ms
    -> 126.6 > 120,那次会被**误判成失败**,尽管服务端已经正常返回

其余 29 次都是 61~62 秒,很正常。不是 hold 太长,是网络尾巴太长。

为什么放宽超时而不是缩短 hold:
  A. hold 缩到 45 秒、超时保持 120 -> 最坏 111 秒,安全
     但请求数多 33%,而每次请求都要吃隧道 1~2 秒开销
  B. hold 保持 60 秒、超时放宽到 180 -> 最坏 180 秒,安全
选 B —— 这条链路的根本问题是丢包抖动,**请求越少越不容易撞上**。
放宽超时只是"别把慢但成功的请求冤枉掉",不增加任何请求。

客户端已有"连续失败 3 次才判离线"(OFFLINE_AFTER=3),
所以偶尔一次长等待不会让界面闪"已断开"。

超时余量的演进(全是实测逼出来的):
  90 秒  -> 实测 93589ms  不够
  120 秒 -> 实测 126604ms 不够
  180 秒 -> 60 hold + 120 余量

两边同步改:
  windows/lanremote/relay.py                     POLL_TIMEOUT 120 -> 180
  android .../RelayTransport.java                POLL_TIMEOUT_MS 120000 -> 180000
(Java 侧确认 setConnectTimeout 另有 15 秒上限、setReadTimeout 吃这个值,
  没有别的超时会先把请求掐掉。)
2026-10-07 17:36:20 +08:00
xmz7891 facb156aca 服务端 zip 加一键部署脚本(cloudflared 安装 + 部署 + 公网,全自动)
用户要求:服务器端文件里放一个 .sh,双击就能检查 cloudflared 装没装、
自动部署、配好公网,以后全自动。

== 新增 ==

relay/deploy.sh        一键部署主脚本,7 步全自动:
                       环境检查 -> 装 cloudflared -> 部署程序 -> 网络调优
                       -> 写 4 个 systemd 服务 -> 配隧道 -> 启动自检打公网地址
relay/deploy-tune.sh   网络调优,可单独重跑:
                       keepalive 30/10/3 + 路径 MTU 探测 + MSS 1360
                       + 接口 MTU 1400 + mask networkd-wait-online
                       每一项都写明了实测依据

用法:
    bash deploy.sh                           复用已有凭据,全自动
    TUNNEL_TOKEN=eyJh... bash deploy.sh      Token 模式
    bash deploy.sh --hostname relay.你的域名  换域名
    bash deploy.sh --skip-tunnel             只装中转
    bash deploy.sh --help                    全部参数

重跑 = 升级;已有数据/配对码/管理密码/凭据/手改过的 config.yml 都不动。

== 自动化到什么程度 ==

* cloudflared 不在就自动下载装上(并校验能执行)
* 隧道支持两种模式:已有凭据文件、或 TUNNEL_TOKEN
* **不传 --hostname 且没有 Token 时明确提示要填**,不会静默用一个写死的值
  (用户明确说过:这个中转站是他自己用的,不对外开放,
   绝不能把私人域名烧成默认值)
* deploy-tune.sh 的 MTU 取 1400:路径 MTU 实测 1472,
  而 MSS 钳制只管 TCP、QUIC 走 UDP 完全不受保护;
  接口 MTU 降到 1400 后 QUIC 建齐 10 条连接从 28 秒缩到 12 秒

== 顺带修掉两个真 bug ==

1. write_tunnel_unit 写的文件名漏了 .service 后缀 ——
   文件写下去了但 systemd 完全看不见,enable 还会安静地失败。
2. 发布包一直缺 site_shots/(老 bug):
   package_remote.py 用 iterdir() + is_file() 收文件,目录进不去。
   新机器装完官网截图全是 404。只有从 zip 解压真跑一遍才暴露。
   同时把 deploy.sh 里 site_shots 的检查降级成警告(缺截图不该让部署失败)。

另外给 deploy-tune.sh 清旧 MSS 规则的循环加了 10 次上限 ——
原写法在 -D 报成功但规则仍在时会死循环把整个部署卡死。

== 验证 ==

* _build/test_deploy_sh.sh        沙箱 65 项,全通过
* _build/test_zip_deploy.sh       从发布 zip 解压再跑,30 项,全通过
* _build/test_deploy_real.sh      **真机 Debian 上实测**(新增):
    zip 解出 17 个文件 -> 语法/LF/无 BOM 通过
    --help 退出码 0 且无副作用、未知参数退出码 2
    沙箱安装成功、幂等重跑哨兵不被冲
    现有三个中转服务全程 active,自检 200
2026-10-07 17:28:09 +08:00
xmz7891 d4f335b8f9 管理页新增:允许/禁止新设备接入 + 修改设备配对码
== 功能一:允许新设备接入开关 ==

关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。
状态存在 relay_state.json 里,重启不丢。

判断新设备用的是 deviceId,不是配对码 ——
这一点很关键:管理员给老设备改过码之后,
那台设备必须仍然能进来;按码判断的话它会被自己的开关挡在外面。
测试里专门有一条 A3b 守这个行为。

被拒时回 blocked=True,让客户端能区分被管理员挡了
和配对码冲突,而不是去匹配中文错误文案(文案一改就坏)。

== 功能二:修改任意设备的配对码 ==

管理页每台设备一个「改码」按钮,填 6 位数字即生效。
要点:
  * 新旧码冲突检查
  * 旧码映射必须删掉,否则变成孤儿条目,
    管理页配对码占用的数字会虚高
  * 改码踢掉该设备当前会话(会话绑在码上)
  * 立刻持久化

== 顺手修掉两个真 bug ==

1. 改码功能一上线就 500:Session 用了 __slots__,
   根本没有 session_id 属性(会话是以 token 为 key 存在 dict 里的),
   写 s.session_id 直接 AttributeError。
   改成遍历 sessions.items() 拿 key。

2. snapshot() 里有同样的错(清理超时设备那段):
   平时 stale 列表是空的所以从没炸过,一旦真有设备超过 6 小时
   没上报就会 500。这次一起修了。

== 管理页 UI ==
  * 设备小节加了「允许新设备接入」勾选框
  * 每行加「改码」按钮(走 prompt,不引入弹窗层)
  * 改码按钮用 data-recode 而不是 data-device ——
    现有单台删除按 button.danger[data-device] 绑,
    带上 data-device 会被当成删除按钮,点改码变删设备。
    测试 C6 专门断言这一点。

== 测试 ==
新增 _tests/test_admin_device_control.py(23 项),
已注册进 _tests/verify_all.ps1 第 3e 阶段。
2026-10-07 17:01:14 +08:00
xmz7891 25e7780272 单次请求失败不再立刻判离线,改成连续 3 次
实测单个请求有 2~4% 的偶发失败(4G 丢包打在一条隧道连接上,
或者那条连接正在重连)。原逻辑是一失败就 online = false,
于是界面上会闪一下已断开 —— 用户点按钮时正好撞上那一闪,
看起来就像又连不上了。

而链路其实 1.5~15 秒就自己恢复(客户端本来就有退避重试)。

改成连续失败 3 次才判离线(手表至少 4.5 秒、电脑至少 6 秒):
  * 单次抖动完全不影响界面
  * 真的断了仍然如实显示

这不是掩盖问题,而是把是否可用的判断从一次采样改成连续采样 ——
单点采样在有丢包的链路上噪声太大。

两边同步改:
  android .../RelayTransport.java   failStreak + OFFLINE_AFTER
  windows/lanremote/relay.py        _fail_streak + OFFLINE_AFTER

顺带记录 PC 端基准(真实用户视角,之前都是在设备上量的,
那等于把设备 4G 算了两遍):
  公网可用率 98%   耗时 中位 911  P90 1889  P99 23868 ms
  长轮询     5/6   耗时 60.8~62.1 秒
对比设备端(中位 1344 / P90 2563)—— 用户实际体验比设备端量出来的好。
2026-10-07 16:16:50 +08:00
xmz7891 aea78efe45 版本 1.8.1 -> 1.8.2 2026-10-07 15:44:49 +08:00
xmz7891 bf15a2ee61 静态资源交给 Cloudflare 边缘缓存(下载提速 41 倍)
实测基线:
  ykxmz-setup-1.8.1.exe   只下到 237568/15067199 字节就停(17 KB/s)
  ykxmz-watch-1.8.1.apk   201523 字节 / 66.8 秒 = 3 KB/s
  ykxmz-server-1.8.1.zip  214900 字节 / 3.1 秒  = 67 KB/s

原因:relay 给所有响应都发 Cache-Control: no-store,
Cloudflare 完全不缓存 —— 每次下载/每张图都要穿隧道回 4G 源站。

改动:
  /download/*   no-store -> public, max-age=604800, immutable
                文件名带版本号,同一名字内容永不改变,可以长缓存
  /             -> public, max-age=60(挡住刷新重复回源)
  /favicon.ico  已是 max-age=86400,保持不变
  /shot/*       已是 max-age=86400,保持不变
  /v1/*         保持 no-store(接口必须实时)

顺带修两个自己踩的坑:

1. _send() 无条件先发 no-store,再追加 extra 里的 Cache-Control,
   结果是**两个 Cache-Control 头**,客户端只认第一个 ——
   调用方传的缓存策略被吃掉。改成 extra 里有就不再发默认值。

2. do_HEAD 和 do_GET 的缓存头不一致:HEAD 对非 /shot/ 一律报 no-store。
   下载器/代理用 HEAD 探测时会误判文件不可缓存。
   现在 _head_info 给每个路由带上和 GET 完全相同的缓存头,
   do_HEAD 也不再盲目覆盖。

结果(同一个测试口径):
  setup.exe  17 KB/s(截断) -> 695 KB/s 完整 15 MB   41 倍
  watch.apk   3 KB/s         -> 101 KB/s              33 倍
  server.zip 67 KB/s         ->  87 KB/s
  三个文件全部 cf-cache-status=HIT(不再穿隧道回源)

排查过程记一笔:我一开始用 curl -I 验证,
看到的全是 no-store —— 因为 **-I 发的是 HEAD**,
而 HEAD 走的是另一个分支。查了半天才发现是验证方法错了。
2026-10-07 15:44:48 +08:00
xmz7891 46196c3618 CHANGELOG 加 1.8.1 2026-10-07 14:24:06 +08:00
xmz7891 b1119d0da6 修「局域网连接被中转劫持」+ 长轮询 15s→60s
用户报两个问题:
  1. 局域网能搜到电脑,IP/端口/配对码都对,点连接**没有任何反应**
  2. 走中转 1426ms 延时,一会儿就 502

========== 一、局域网被劫持(真 bug)==========

MainActivity.connect() 里:

    boolean usedLan = !relayUrl().isEmpty() ? false : true;
    if (!relay.isEmpty()) {
        reply = client.connectViaRelay(...);      // 走中转
    } else {
        reply = client.connect(host, port, ...);  // 才走局域网
    }

**只看「中转设置里填没填地址」,填了就一律走中转,
完全无视用户填的局域网 IP 和端口。**

而中转单次超时是 40 秒 —— 用户点完就卡在「正在连接…」
四十秒,在他看来就是"没有任何反应"。

影响两条路:
  * 「局域网连接」页面填 IP 直连(connectLanTyped)
  * 首页「已配对的电脑」点一下重连(quickConnect)

修法:给 connect() 加 preferLan 参数。明确要走局域网时
**先直连(6 秒超时,局域网本该是毫秒级),失败再退回中转**,
保证最终能连上。

顺带两个体验改进:
  * 设备列表里,保存的 IP 和手表不同网段时标「⚠ 地址可能已过期」
    (用户截图里那个 192.168.2.64 就是过期地址,电脑早换成
      192.168.10.103 了)
  * quickConnect 判断出地址过期就跳过直连,直接走中转查最新地址 ——
    省掉那 6 秒白等

========== 二、长轮询 15 秒 -> 60 秒 ==========

实测隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源):

    本机直连 60 秒的 poll  ->  60.0s
    走公网同一个 poll      ->  61.5~62.0s

这个开销是**按次数**收的:
    15 秒一循环 -> 一天 5760 次/端,光路费 2.4~3.2 小时
    60 秒一循环 -> 一天 1440 次/端,光费 0.6~0.8 小时
而且请求越频繁,撞上丢包/重连窗口的概率越大。

**不会让命令变慢**:长轮询是"有数据立刻返回"(device.event 唤醒),
拉长只是减少空转重开。实测 60 秒长轮询穿隧道 4/4 通过;
Cloudflare 单请求上限 100 秒,留了 40 秒余量。

三层同步改(客户端超时必须显著大于服务端 hold):
  * relay/relay.py                    RELAY_POLL 默认 25 -> 60
  * windows/lanremote/relay.py        POLL_TIMEOUT 40 -> 90
  * android .../RelayTransport.java   POLL_TIMEOUT_MS 40000 -> 90000

========== 三、A/B 实测(还在跑更严谨的一轮)==========

短测发现基线 http2+4 虽然 100% 成功,但 **P90 是 16.8 秒**
(每 10 次有 1 次卡十几秒 —— 正是用户说的"没反应"),
而 quic+6 的 P90 只有 2.8 秒。正在用真实长轮询模式
跑 10 分钟对照确认。
2026-10-07 14:11:33 +08:00
xmz7891 9692ef1f32 修 Gitee 附件清理:assets 字段没 id,要用 attach_files 接口
Gitee 的坑:GET /releases/{id} 返回的 assets 里
**只有 name 和 download_url,没有 id**,
照着它删是删不掉的(之前静默失败,附件一路堆到 11 个)。

必须换接口才拿得到 id:
    GET    /releases/{id}/attach_files        -> [{id, name, size}]
    DELETE /releases/{id}/attach_files/{id}

已清理:Gitee 11 -> 5 个(含 2 个自动生成的源码包),
EzHub 5 -> 3 个。清理动作也加了重试(网络会抖)。
2026-10-07 12:54:33 +08:00
xmz7891 ae7e1cf269 Gitee 也做成幂等:已存在就复用 + 先删同名附件
重发时 Gitee 直接 400「该标签已经存在发行版」,
而且拿不到 id,后面的附件也传不上。

改成和 EzHub 一样的流程:
先按标签查已有的(查到就复用 id),复用前删掉同名旧附件,再传。
这样重跑多少次,两边的附件数都稳定。
2026-10-07 12:37:06 +08:00