中转站加下载区 + 大文件流式发送

下载区
- 管理页加一张「下载最新版」卡片,就一个按钮
  自动指向 /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)
This commit is contained in:
xmz7891
2026-10-06 19:21:34 +08:00
parent e89ff18e4c
commit b3630a0107
10 changed files with 732 additions and 30 deletions
+191
View File
@@ -0,0 +1,191 @@
# -*- 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())