Files
ykxmz/_build/fix_large_download.py
xmz7891 60d1bdfe58 中转站加下载区 + 大文件流式发送
下载区
- 管理页加一张「下载最新版」卡片,就一个按钮
  自动指向 /v1/downloads 里最新的被控端整包,不用每次改页面
- GET /v1/downloads 列出成品
- GET /download/<文件> 直接给文件
  只允许白名单后缀(.apk/.zip/.txt/.exe/.idsig)
  文件名严格校验,挡目录穿越(../ 和 ..%2F 都试过)
- 卡片下面附一个 Gitee 历史版本入口

大文件
- 原来 read_bytes() 一次性读进内存再 write。
  19MB 在 460MB 的设备上勉强,更要命的是 Cloudflare
  有 100 秒的源站响应上限,数据越晚开始流动越容易被掐。
- 改成 64KB 分块流式发送,边读边发
  实测:19MB 公网下载 40 秒完成,字节数完全一致

版本
- 三端分开打包(原来是一个合并 zip)
    ykxmz-agent-1.5.0.zip   被控端
    ykxmz-server-1.5.0.zip  服务器端
    ykxmz-watch-1.5.0.apk   控制端(直接给 APK)
2026-10-06 23:08:06 +08:00

192 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
r"""修"大文件下载失败"。
现象:
公网下载 0.19MB 的 APK -> 200 ✓
公网下载 46KB 的服务器包 -> 200 ✓
公网下载 19MB 的被控端包 -> 000 ✗
原因:
1. relay 原来是 blob = f.read_bytes() 一次性读进内存再 write 出去。
19MB 在 460MB 的设备上勉强,但更要命的是——
2. **Cloudflare 有 100 秒的源站响应上限**。
设备上行慢(4G,还带丢包重传),19MB 传不完就被掐断。
小文件几百毫秒就完了,所以只有大文件挂。
改法:
A. 改成**分块流式**发送(64KB 一块),边读边发、及时 flush ——
至少不会因为攒内存而卡住,也让数据尽早开始流动。
B. 管理页的下载列表里,每个文件旁边加一个「从 Gitee 下」的链接。
走 Gitee 的 CDN 比走这条 4G 隧道快得多,大文件应该走那边。
C. 前端按大小给提示:超过 5MB 的文件,标一句"建议从 Gitee 下"。
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
RELAY = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay" r"\relay.py")
# ---------- A. 流式发送 ----------
OLD_SERVE = ''' try:
blob = target.read_bytes()
except OSError as exc:
self._json(500, {"ok": False, "error": f"读取失败: {exc}"})
return
if safe.lower().endswith(".apk"):
content_type = "application/vnd.android.package-archive"
elif safe.lower().endswith(".zip"):
content_type = "application/zip"
elif safe.lower().endswith(".txt"):
content_type = "text/plain; charset=utf-8"
else:
content_type = "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(blob)))
# 文件名可能含中文,用 RFC 5987 编码
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(blob)
log(f"下载:{safe}({len(blob)} 字节)")
return'''
NEW_SERVE = ''' try:
size = target.stat().st_size
except OSError as exc:
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
return
if safe.lower().endswith(".apk"):
content_type = "application/vnd.android.package-archive"
elif safe.lower().endswith(".zip"):
content_type = "application/zip"
elif safe.lower().endswith(".txt"):
content_type = "text/plain; charset=utf-8"
else:
content_type = "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(size))
# 文件名可能含中文,用 RFC 5987 编码
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
# 分块流式发送,不要一次性 read_bytes 再 write ——
# 大文件(19MB)那样会先攒满内存、数据也迟迟不开始流动;
# 而 Cloudflare 有 100 秒的源站响应上限,越晚开始发越容易被掐。
sent = 0
try:
with target.open("rb") as stream:
while True:
chunk = stream.read(65536)
if not chunk:
break
self.wfile.write(chunk)
sent += len(chunk)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
# 客户端/边缘主动断开(比如大文件超时),正常现象
log(f"下载中断:{safe}(已发 {sent}/{size} 字节)")
return
except OSError as exc:
log(f"下载出错:{safe}(已发 {sent}/{size} 字节){exc}")
return
log(f"下载:{safe}({sent} 字节)")
return'''
# ---------- B/C. 前端加 Gitee 链接与大文件提示 ----------
OLD_JS = ''' body.innerHTML = files.map(f => {{
const isApk = f.name.toLowerCase().endsWith('.apk');
const tag = isApk
? '<span class="badge on">手表 APK</span>'
: '<span class="badge off">整包</span>';
return '<div style="display:flex;align-items:center;gap:10px;'
+ 'margin:6px 0;flex-wrap:wrap">'
+ tag
+ '<a href="/download/' + encodeURIComponent(f.name) + '" '
+ 'style="color:#79c0ff;text-decoration:none;font-weight:500">'
+ f.name + '</a>'
+ '<span style="color:#6e7681;font-size:12px">'
+ humanSize(f.size) + '</span>'
+ '</div>';
}}).join('');'''
NEW_JS = ''' const GITEE = 'https://gitee.com/xmz7891/ykxmz/releases';
body.innerHTML = files.map(f => {{
const isApk = f.name.toLowerCase().endsWith('.apk');
const tag = isApk
? '<span class="badge on">手表 APK</span>'
: '<span class="badge off">整包</span>';
// 大文件走这条 4G 隧道很慢(Cloudflare 还有 100 秒上限),
// 超过 5MB 就提示一下,并给个 Gitee 的入口。
const big = f.size > 5 * 1024 * 1024;
const hint = big
? ' <span style="color:#d29922;font-size:12px">'
+ '(较大,隧道慢,建议走 Gitee)</span>'
: '';
const alt = big
? ' <a href="' + GITEE + '" target="_blank" rel="noopener" '
+ 'style="color:#8b949e;font-size:12px">从 Gitee 下</a>'
: '';
return '<div style="display:flex;align-items:center;gap:10px;'
+ 'margin:6px 0;flex-wrap:wrap">'
+ tag
+ '<a href="/download/' + encodeURIComponent(f.name) + '" '
+ 'style="color:#79c0ff;text-decoration:none;font-weight:500">'
+ f.name + '</a>'
+ '<span style="color:#6e7681;font-size:12px">'
+ humanSize(f.size) + '</span>'
+ hint + alt
+ '</div>';
}}).join('');'''
def main() -> int:
text = RELAY.read_text(encoding="utf-8")
ok = True
if "分块流式发送" in text:
print(" 流式发送已经改过")
elif OLD_SERVE in text:
text = text.replace(OLD_SERVE, NEW_SERVE, 1)
print(" 下载改成 64KB 分块流式发送")
else:
print(" [失败] 流式发送锚点没找到")
ok = False
if "从 Gitee 下" in text:
print(" 前端提示已经加过")
elif OLD_JS in text:
text = text.replace(OLD_JS, NEW_JS, 1)
print(" 前端加了大文件提示 + Gitee 入口")
else:
print(" [失败] 前端锚点没找到")
ok = False
if not ok:
return 1
RELAY.write_text(text, encoding="utf-8", newline="\n")
try:
ast.parse(text)
print(" relay.py 语法 OK")
except SyntaxError as exc:
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())