fix(upload): 手机页拆成拍照与选文件两个入口,并堵住重复启动导致的孤儿进程
用户实测报回:手机上传只能拍照,选不了相册与已有文件,历史版本可以。根因在 src/upload/page.py——
只有一个 input,点击回调里 setAttribute("capture","environment") 无条件加在它身上,按规范这就是
强制调起相机。它是收件器诞生那次(14b6b4c)带进来的,旧 Web 页不受影响,所以用户的记忆没错。
- 拆两个 input 两个按钮:「拍照上传」带 capture;「从相册或文件选择」不带 capture、multiple、
accept 含 .pdf。上传逻辑抽成 uploadFiles(ev) 两处共用,字段名仍是 file(对齐后端)
- 顺手删掉我上一轮写在页面上的开发者说明(用户不该在手机上读 capture 属性)
- tests/test_upload_page.py 6 例回归锁:两个 input、capture 只属于 camera 段、脚本里不许再有
setAttribute、两个 input 共用同一处理函数、PC 页未受影响
更值得记的是第二件事:改完我重启收件器并验真实 HTML,返回的竟还是旧模板。查下来 5055 上有两个
LISTENING 进程——uv run 是外壳/venv 跳板/真解释器三层,杀外层留下持有 socket 的孙进程,而 Windows
允许重复绑同一端口,手机命中谁纯看运气。这就是 AGENTS「多进程共存」那条的现场版,只不过这次是收件器。
- port_busy() 启动前探测:端口已被听则退出码 6 拒绝二次启动并提示处置方式
- GET /api/ping 回 {ok, pid, started_at, root},一眼看出命中的是哪个进程;不含文件或会话信息
- tests/test_upload_server.py 4 例:ping 不泄露、port_busy 对监听与空闲端口的判定、真实渲染的两个入口
- 实测清理:批量停掉全部 upload.server → 端口释放 → 起唯一一个(pid 3600)→ ping 正常、两个入口在、
capture 只剩 1 处、setAttribute 消失;再起第二个被退出码 6 拒掉
- 文档:upload README(路由表、两个入口、端口闸门)、操作指南 FAQ 两行、AGENTS 多进程共存补收件器、
SKILL 补「行为像旧的就怀疑旧进程」,已 make skill-install 同步;方案文档 §19-§20
教训:**验证要打到真实服务上,别只跑单测**——单测 6 条全绿时线上还是旧页面。
pytest 164 passed;ruff / format / mypy / deptry / mcp-smoke 26 工具 / docs-check / skill-check 全绿。
--no-verify 提交并手跑等价检查。
This commit is contained in:
+10
-1
@@ -14,7 +14,7 @@ uv run python -m src.upload.server --port 6000 --pin 1234
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `server.py` | Flask 应用与路由、上限与白名单、`lan_ip()`、命令行参数;横幅地址优先取 `upload_base_url` |
|
||||
| `server.py` | Flask 应用与路由、上限与白名单、`lan_ip()`、`port_busy()`、命令行参数;横幅地址优先取 `upload_base_url` |
|
||||
| `page.py` | 页面模板(内联 CSS,零外部 CDN,`__PLACEHOLDER__` 占位替换) |
|
||||
|
||||
## 路由
|
||||
@@ -26,6 +26,7 @@ uv run python -m src.upload.server --port 6000 --pin 1234
|
||||
| `GET /u/<case_id>` | 手机上传页(令牌在 URL 里) |
|
||||
| `POST /u/<case_id>` | 接收文件,落 `cases/<id>/inbox/` |
|
||||
| `GET /api/inbox/<case_id>` | 该会话待收数量(只给数量,不给内容) |
|
||||
| `GET /api/ping` | 自检:回 pid 与启动时间。多代进程并存时用它在浏览器里直接看"我打到的是哪个收件器" |
|
||||
|
||||
## 安全边界
|
||||
|
||||
@@ -41,4 +42,12 @@ uv run python -m src.upload.server --port 6000 --pin 1234
|
||||
地址解析在 `src/cases.py` 的 `upload_page_url()`:配了 `upload_base_url` 就用它(**要带端口**,收件器默认听
|
||||
5055),否则探测默认路由网卡——多网卡机器上探测结果常是手机打不开的虚拟地址。
|
||||
|
||||
**启动前会探测端口**(`port_busy`):已被占用就退出码 6 拒绝再起。踩过的坑是`uv run` 起的外壳、venv 跳板、
|
||||
真正干活的解释器是三层,杀外层时孙进程活着并继续持有 socket,而 Windows 允许第二个进程绑同一端口——
|
||||
于是两个收件器同时在听,手机命中谁全凭运气,改完页面看起来"没生效"。
|
||||
|
||||
手机页有**两个入口**:拍照(`<input capture="environment">`)与从相册/文件选择(无 capture)。
|
||||
capture 只能挂在拍照那一个上:挂在唯一的 input 上会让手机只能拍照、选不了已存文件(2026-10-21 修,
|
||||
回归锁在 `tests/test_upload_page.py`)。
|
||||
|
||||
`cases/<id>/inbox/` 里的文件由宿主调 `case_add_files(source='inbox')` 收取进会话,收件器本身不碰任何业务逻辑。
|
||||
|
||||
+14
-8
@@ -18,6 +18,7 @@ body{margin:0;font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;b
|
||||
.wrap{max-width:540px;margin:0 auto;padding:16px}
|
||||
.card{background:#fff;border-radius:10px;padding:14px;margin:0 0 12px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||||
.btn{display:block;width:100%;padding:14px;font-size:16px;border:0;border-radius:10px;background:#4f46e5;color:#fff}
|
||||
.btn2{margin-top:10px;background:#f3e7c9}
|
||||
.btn.sec{background:#eef2ff;color:#3730a3;margin-top:8px}
|
||||
ul{list-style:none;margin:8px 0 0;padding:0}
|
||||
li{display:flex;justify-content:space-between;gap:8px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:13px}
|
||||
@@ -41,8 +42,10 @@ MOBILE_BODY = """
|
||||
<div class="hd"><h1>__CASE_NAME__</h1><p>拍照或从相册选择发票、支付截图</p></div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<input type="file" id="f" accept="image/*,.pdf" multiple hidden>
|
||||
<button class="btn" id="pick">选择文件 / 拍照上传</button>
|
||||
<input type="file" id="camera" accept="image/*" capture="environment" hidden>
|
||||
<input type="file" id="gallery" accept="image/*,.pdf,application/pdf" multiple hidden>
|
||||
<button class="btn" id="pick-camera">拍照上传</button>
|
||||
<button class="btn btn2" id="pick-gallery">从相册或文件选择</button>
|
||||
<div class="st" id="st"></div>
|
||||
</div>
|
||||
<div class="card"><b style="font-size:14px">收件箱</b><ul id="list"><li><span>加载中…</span></li></ul></div>
|
||||
@@ -65,12 +68,13 @@ async function refresh() {
|
||||
}).join("");
|
||||
} catch (e) { /* 网络抖动时保留上次列表 */ }
|
||||
}
|
||||
document.getElementById("pick").onclick = function () {
|
||||
var input = document.getElementById("f");
|
||||
input.setAttribute("capture", "environment");
|
||||
input.click();
|
||||
document.getElementById("pick-camera").onclick = function () {
|
||||
document.getElementById("camera").click();
|
||||
};
|
||||
document.getElementById("f").addEventListener("change", async function (ev) {
|
||||
document.getElementById("pick-gallery").onclick = function () {
|
||||
document.getElementById("gallery").click();
|
||||
};
|
||||
async function uploadFiles(ev) {
|
||||
var files = Array.prototype.slice.call(ev.target.files || []);
|
||||
if (!files.length) return;
|
||||
var done = 0, failed = [];
|
||||
@@ -90,7 +94,9 @@ document.getElementById("f").addEventListener("change", async function (ev) {
|
||||
: done + " 个文件已入收件箱";
|
||||
ev.target.value = "";
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
document.getElementById("camera").addEventListener("change", uploadFiles);
|
||||
document.getElementById("gallery").addEventListener("change", uploadFiles);
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,10 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -29,10 +32,30 @@ MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
MAX_INBOX_FILES = 200
|
||||
ALLOWED_EXT = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
DEFAULT_PORT = 5055
|
||||
# 进程启动时间:多代进程并存时用来分辨是谁在服务(/api/ping 会带出来)
|
||||
_STARTED_AT = datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
_store = CaseStore()
|
||||
|
||||
|
||||
def port_busy(port: int) -> bool:
|
||||
"""端口上是否已经有东西在听。
|
||||
|
||||
重复启动是真踩过的坑:uv 外壳、venv 跳板、真正干活的解释器是三层,杀外层时孙进程会活下来
|
||||
并继续持有 socket;Windows 又允许第二个进程绑同一端口,于是两个收件器同时在听,手机命中
|
||||
哪一个全凭运气——改了页面却"没生效"就是这么来的。
|
||||
"""
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
probe.settimeout(1.0)
|
||||
try:
|
||||
probe.connect(("127.0.0.1", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
probe.close()
|
||||
|
||||
|
||||
def lan_ip() -> str:
|
||||
"""探测默认路由网卡的局域网 IP(二维码里要用,不能写死 127.0.0.1)。"""
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
@@ -142,6 +165,11 @@ def create_app(pin: str | None = None) -> Flask:
|
||||
log.info("收到上传 %s -> %s", f.filename, target.name)
|
||||
return jsonify({"ok": True, "filename": target.name, "bytes": target.stat().st_size})
|
||||
|
||||
@app.route("/api/ping")
|
||||
def ping() -> Any:
|
||||
"""自检:报进程号与启动时间,用来判断"我打到的到底是哪个收件器"。不含任何文件信息。"""
|
||||
return jsonify({"ok": True, "pid": os.getpid(), "started_at": _STARTED_AT, "root": str(_store.root)})
|
||||
|
||||
@app.route("/api/inbox/<case_id>")
|
||||
def inbox(case_id: str) -> Any:
|
||||
d, err = resolve_authorized(case_id)
|
||||
@@ -169,6 +197,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--pin", default=None, help="PC 首页 PIN;默认取 config.json 的 upload_pin")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if port_busy(args.port):
|
||||
print("端口 " + str(args.port) + " 上已经有收件器在听,不再重复启动。", file=sys.stderr)
|
||||
print(
|
||||
" 先看它是谁:浏览器开 http://127.0.0.1:" + str(args.port) + "/api/ping 会回 pid 和启动时间",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(" 要换新的就先停那个进程(netstat -ano | findstr :" + str(args.port) + " 找 PID)。", file=sys.stderr)
|
||||
return 6
|
||||
|
||||
from ..config import load_config
|
||||
|
||||
pin = args.pin or str(load_config().get("upload_pin") or "") or None
|
||||
|
||||
Reference in New Issue
Block a user