diff --git a/dish_server/README.md b/dish_server/README.md new file mode 100644 index 0000000..78cbd70 --- /dev/null +++ b/dish_server/README.md @@ -0,0 +1,78 @@ +# 总服务器中心 API + +给**总服务器**单独用的服务:收工位机图片、提供图片 URL、接收识别结果。 +不依赖相机、YOLO、客户端界面。把整个 `dish_server` 文件夹拷到云机即可。 + +## 快速启动 + +Windows / Linux: + +1. 安装 Python 3.10+(标准库即可,无需 torch) +2. 在本目录执行: + +```bat +python main.py +``` + +也可在项目根目录: + +```bat +python -m dish_server +``` + +上传接口:`http://116.147.40.47:9100`(与 `cpsb.ruimeida.cc` 同机) +图片落盘:`/data/tpweb` +对外访问:`https://cpsb.ruimeida.cc/tpweb/...`(由站点 Nginx 提供 HTTPS) + +浏览器探活:`http://116.147.40.47:9100/api/health` → `"status": "up"`。 + +## 配置 + +1. 复制 `env.example` 为 `.env` +2. 推荐: + +``` +DISH_API_HOST=0.0.0.0 +DISH_API_PORT=9100 +DISH_MEDIA_ROOT=/data/tpweb +DISH_MEDIA_PUBLIC_BASE=https://cpsb.ruimeida.cc/tpweb +``` + +3. 确保 Nginx 已把 `/tpweb/` 映射到 `/data/tpweb` +4. 安全组放行 **9100**(工位机上传用) + +## 接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/health` | 探活 | +| POST | `/api/dish/media/upload` | 上传图片,返回 `url` | +| GET | `/tpweb/...` 或 `/media/...` | 访问已上传图片(同目录) | +| POST | `/api/dish/recognize/result` | 上报识别结果(字段为 URL) | +| GET | `/api/dish/recognize/records` | 最近结果列表 | + +数据目录: + +``` +/data/tpweb/ 图片(DISH_MEDIA_ROOT) +dish_server/data/results/ 识别 JSON(可选) +``` + +## 工位机怎么连 + +工位机客户端设置(或环境变量): + +``` +DISH_RESULT_API_MOCK=0 +DISH_MEDIA_UPLOAD_URL=http://116.147.40.47:9100/api/dish/media/upload +DISH_MEDIA_PUBLIC_BASE=https://cpsb.ruimeida.cc/tpweb +DISH_RESULT_API_URL=https://cpsb.ruimeida.cc/api/public/recognition-orders +``` + +客户端默认:上传到 `:9100`,上报 JSON 里的图为 `https://cpsb.ruimeida.cc/tpweb/...`。 + +## 生产建议 + +- 对外图片 URL 必须用 HTTPS:`https://cpsb.ruimeida.cc/tpweb` +- Nginx 将 `/tpweb/` 映射到 `/data/tpweb` +- 云机一键部署:`bash deploy_tpweb.sh` diff --git a/dish_server/__init__.py b/dish_server/__init__.py new file mode 100644 index 0000000..ac0eb71 --- /dev/null +++ b/dish_server/__init__.py @@ -0,0 +1,5 @@ +# 总服务器中心 API(图片上传 + 识别结果) +from .api_server import serve +from .media_store import MediaStore, is_http_url + +__all__ = ["serve", "MediaStore", "is_http_url"] diff --git a/dish_server/__main__.py b/dish_server/__main__.py new file mode 100644 index 0000000..4143c4c --- /dev/null +++ b/dish_server/__main__.py @@ -0,0 +1,4 @@ +from .api_server import serve + +if __name__ == "__main__": + serve() diff --git a/dish_server/api_server.py b/dish_server/api_server.py new file mode 100644 index 0000000..056f34e --- /dev/null +++ b/dish_server/api_server.py @@ -0,0 +1,373 @@ +# coding=utf-8 +"""总服务器中心 API:收图、静态访问、识别结果落库。 + +本文件不依赖 YOLO / 相机 / 客户端 UI,可单独拷到总服务器运行。 +""" +from __future__ import annotations + +import json +import os +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, unquote, urlparse + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +try: + from .media_store import ( + ALLOWED_TYPES, + CLOUD_MEDIA_PUBLIC_BASE, + CLOUD_MEDIA_PORT, + MEDIA_ROOT, + MediaStore, + local_path_under, + ) +except ImportError: + from media_store import ( + ALLOWED_TYPES, + CLOUD_MEDIA_PUBLIC_BASE, + CLOUD_MEDIA_PORT, + MEDIA_ROOT, + MediaStore, + local_path_under, + ) + + +def _cfg(): + host = os.environ.get("DISH_API_HOST", "0.0.0.0") + port = int(os.environ.get("DISH_API_PORT", str(CLOUD_MEDIA_PORT))) + token = os.environ.get("DISH_API_TOKEN", "").strip() + result_dir = os.environ.get("DISH_RESULT_STORE") or os.path.join(_HERE, "data", "results") + max_bytes = int(os.environ.get("DISH_MEDIA_MAX_BYTES", str(12 * 1024 * 1024))) + return host, port, token, result_dir, max_bytes + + +HOST, PORT, API_TOKEN, RESULT_DIR, MAX_UPLOAD_BYTES = _cfg() +os.makedirs(RESULT_DIR, exist_ok=True) +_store = MediaStore() +_result_lock = threading.Lock() + + +def reload_runtime(): + """start.py 加载 env 后再调用,刷新监听配置与存储路径。""" + global HOST, PORT, API_TOKEN, RESULT_DIR, MAX_UPLOAD_BYTES, _store + HOST, PORT, API_TOKEN, RESULT_DIR, MAX_UPLOAD_BYTES = _cfg() + os.makedirs(RESULT_DIR, exist_ok=True) + # 对外 HTTPS:https://cpsb.ruimeida.cc/tpweb ;磁盘默认 /data/tpweb + public = os.environ.get("DISH_MEDIA_PUBLIC_BASE", CLOUD_MEDIA_PUBLIC_BASE).rstrip("/") + root = os.environ.get("DISH_MEDIA_ROOT") or MEDIA_ROOT + _store = MediaStore(root=root, public_base=public) + print(f"[central] media root={root}") + print(f"[central] public ={public}") + + +def _json_bytes(obj, status=200): + raw = json.dumps(obj, ensure_ascii=False).encode("utf-8") + return status, raw + + +def _ok(data=None, message="ok"): + return _json_bytes({"code": 0, "message": message, "data": data if data is not None else {}}) + + +def _err(http_status, code, message): + return _json_bytes({"code": code, "message": message, "data": None}, status=http_status) + + +def _parse_multipart(content_type, body): + files = {} + fields = {} + if not content_type or "multipart/form-data" not in content_type.lower(): + return fields, files + boundary = None + for part in content_type.split(";"): + part = part.strip() + if part.lower().startswith("boundary="): + boundary = part.split("=", 1)[1].strip().strip('"') + break + if not boundary: + return fields, files + marker = b"--" + boundary.encode("utf-8") + chunks = body.split(marker) + for chunk in chunks: + chunk = chunk.lstrip(b"\r\n") + if not chunk or chunk.startswith(b"--"): + continue + if b"\r\n\r\n" not in chunk: + continue + header_raw, data = chunk.split(b"\r\n\r\n", 1) + if data.endswith(b"\r\n"): + data = data[:-2] + headers = {} + for line in header_raw.decode("utf-8", errors="replace").split("\r\n"): + if ":" in line: + k, v = line.split(":", 1) + headers[k.strip().lower()] = v.strip() + disp = headers.get("content-disposition", "") + name = None + filename = None + for item in disp.split(";"): + item = item.strip() + if item.lower().startswith("name="): + name = item.split("=", 1)[1].strip().strip('"') + elif item.lower().startswith("filename="): + filename = item.split("=", 1)[1].strip().strip('"') + if not name: + continue + if filename is not None: + files[name] = (filename, data) + else: + fields[name] = data.decode("utf-8", errors="replace") + return fields, files + + +def _auth_ok(handler): + if not API_TOKEN: + return True + header = handler.headers.get("Authorization") or "" + if header == f"Bearer {API_TOKEN}": + return True + if header == API_TOKEN: + return True + if handler.headers.get("X-Api-Token") == API_TOKEN: + return True + return False + + +def _guess_mime(path): + ext = os.path.splitext(path)[1].lower() + return { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".bmp": "image/bmp", + ".webp": "image/webp", + ".json": "application/json", + }.get(ext, "application/octet-stream") + + +def _save_result(payload): + request_id = str(payload.get("request_id") or "").strip() + if not request_id: + return False, "missing request_id", None + path = os.path.join(RESULT_DIR, f"{request_id}.json") + duplicate = False + with _result_lock: + if os.path.isfile(path): + duplicate = True + else: + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) + return True, None, duplicate + + +class ApiHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + sys.stderr.write("[central] " + (fmt % args) + "\n") + + def _send(self, status, body, content_type="application/json; charset=utf-8"): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _read_body(self, limit=None): + limit = MAX_UPLOAD_BYTES if limit is None else limit + length = int(self.headers.get("Content-Length") or 0) + if length > limit: + return None + return self.rfile.read(length) if length else b"" + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Base-Code, X-Api-Token") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.end_headers() + + def do_GET(self): + parsed = urlparse(self.path) + path = unquote(parsed.path) + if path in ("/api/health", "/health"): + status, body = _ok({ + "status": "up", + "media_root": _store.root, + "public_base": _store.public_base, + "result_dir": RESULT_DIR, + }) + self._send(status, body) + return + # /media/... 与 /tpweb/... 都映射到同一落盘目录 + rel = None + if path.startswith("/media/"): + rel = path[len("/media/"):] + elif path.startswith("/tpweb/"): + rel = path[len("/tpweb/"):] + if rel is not None: + try: + dest = local_path_under(_store.root, rel) + except ValueError: + self._send(404, b"not found", "text/plain") + return + if not os.path.isfile(dest): + self._send(404, b"not found", "text/plain") + return + with open(dest, "rb") as fh: + data = fh.read() + self._send(200, data, _guess_mime(dest)) + return + if path == "/api/dish/recognize/records": + qs = parse_qs(parsed.query or "") + rid = (qs.get("request_id") or [None])[0] + items = [] + if os.path.isdir(RESULT_DIR): + for name in sorted(os.listdir(RESULT_DIR), reverse=True): + if not name.endswith(".json"): + continue + fpath = os.path.join(RESULT_DIR, name) + try: + with open(fpath, "r", encoding="utf-8") as fh: + row = json.load(fh) + except Exception: + continue + if rid and row.get("request_id") != rid: + continue + items.append(row) + status, body = _ok({"items": items[:50], "total": len(items)}) + self._send(status, body) + return + if path.startswith("/api/dish/recognize/records/"): + rid = path.rsplit("/", 1)[-1] + fpath = os.path.join(RESULT_DIR, f"{rid}.json") + if not os.path.isfile(fpath): + status, body = _err(404, 40400, "record not found") + self._send(status, body) + return + with open(fpath, "r", encoding="utf-8") as fh: + row = json.load(fh) + status, body = _ok(row) + self._send(status, body) + return + status, body = _err(404, 40400, "not found") + self._send(status, body) + + def do_POST(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") or "/" + if not _auth_ok(self): + status, body = _err(401, 40100, "未授权") + self._send(status, body) + return + body = self._read_body() + if body is None: + status, body = _err(413, 41300, "文件过大") + self._send(status, body) + return + + if path == "/api/dish/media/upload": + fields, files = _parse_multipart(self.headers.get("Content-Type", ""), body) + blob = files.get("file") + if not blob: + status, out = _err(400, 40001, "缺少 file") + self._send(status, out) + return + filename, data = blob + media_type = (fields.get("type") or "source").strip().lower() + if media_type not in ALLOWED_TYPES: + status, out = _err(400, 40001, "type 必须是 source / labeled / crop") + self._send(status, out) + return + base_id = fields.get("base_id") or self.headers.get("X-Base-Code") or "BASE" + request_id = fields.get("request_id") or "" + try: + saved = _store.save_bytes( + data, filename, media_type=media_type, base_id=base_id, request_id=request_id, + ) + except Exception as exc: + status, out = _err(500, 50000, str(exc)) + self._send(status, out) + return + status, out = _ok({"url": saved["url"], "path": saved["rel_path"], "type": saved["type"]}) + self._send(status, out) + return + + if path == "/api/dish/recognize/result": + try: + payload = json.loads(body.decode("utf-8")) + except Exception: + status, out = _err(400, 40001, "JSON 无法解析") + self._send(status, out) + return + if not isinstance(payload, dict): + status, out = _err(400, 40001, "body 必须是对象") + self._send(status, out) + return + dishes = payload.get("dishes") or [] + try: + dish_count = int(payload.get("dish_count")) + except Exception: + dish_count = -1 + if dish_count != len(dishes): + status, out = _err(400, 40001, "dish_count 与 dishes 长度不一致") + self._send(status, out) + return + if not payload.get("request_id"): + status, out = _err(400, 40001, "缺少 request_id") + self._send(status, out) + return + # 兼容客户端:无 base_id 时用 device_code / base_name + if not payload.get("base_id"): + payload["base_id"] = ( + payload.get("device_code") + or payload.get("base_name") + or self.headers.get("X-Base-Code") + or "BASE" + ) + ok, msg, duplicate = _save_result(payload) + + if not ok: + status, out = _err(400, 40001, msg) + self._send(status, out) + return + status, out = _ok({ + "request_id": payload["request_id"], + "accepted": True, + "duplicate": bool(duplicate), + }) + self._send(status, out) + return + + status, out = _err(404, 40400, "not found") + self._send(status, out) + + +def serve(host=None, port=None): + reload_runtime() + host = HOST if host is None else host + port = PORT if port is None else port + httpd = ThreadingHTTPServer((host, port), ApiHandler) + print(f"[central] listening http://{host}:{port}") + print("[central] GET /api/health") + print("[central] POST /api/dish/media/upload") + print("[central] GET /media/... or /tpweb/...") + print("[central] POST /api/dish/recognize/result") + print(f"[central] media {_store.root}") + print(f"[central] public {_store.public_base}") + print(f"[central] result {RESULT_DIR}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n[central] stopped") + finally: + httpd.server_close() + + +if __name__ == "__main__": + serve() diff --git a/dish_server/deploy_tpweb.sh b/dish_server/deploy_tpweb.sh new file mode 100644 index 0000000..2ce63c2 --- /dev/null +++ b/dish_server/deploy_tpweb.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# 在云服务器执行: +# cd /home/cpsb/project/dish_server && bash deploy_tpweb.sh +# +# 正确结构: +# 磁盘: /data/tpweb/BASE/REQ/xxx.jpg +# 访问: https://cpsb.ruimeida.cc/tpweb/BASE/REQ/xxx.jpg +# 错误结构(多一层 media): +# /data/tpweb/media/BASE/... ← 会导致 HTTPS 404/500 +set -euo pipefail + +APP_DIR="/home/cpsb/project/dish_server" +MEDIA_DIR="/data/tpweb" +PUBLIC_BASE="https://cpsb.ruimeida.cc/tpweb" + +cd "$APP_DIR" + +echo "[1/6] 准备目录 $MEDIA_DIR" +sudo mkdir -p "$MEDIA_DIR" +if id -u cpsb >/dev/null 2>&1; then + sudo chown -R cpsb:cpsb "$MEDIA_DIR" +fi +sudo chmod 755 "$MEDIA_DIR" + +echo "[2/6] 若存在错误子目录 $MEDIA_DIR/media,迁移到上一级" +if [ -d "$MEDIA_DIR/media" ]; then + echo " 发现 $MEDIA_DIR/media ,开始迁移..." + # 把 media 下的基地目录挪到 /data/tpweb/ + shopt -s dotglob nullglob + for item in "$MEDIA_DIR/media"/*; do + name="$(basename "$item")" + if [ -e "$MEDIA_DIR/$name" ]; then + echo " 合并: $item -> $MEDIA_DIR/$name" + cp -a "$item"/. "$MEDIA_DIR/$name"/ 2>/dev/null || true + else + echo " 移动: $item -> $MEDIA_DIR/$name" + mv "$item" "$MEDIA_DIR/$name" + fi + done + rmdir "$MEDIA_DIR/media" 2>/dev/null || mv "$MEDIA_DIR/media" "$MEDIA_DIR/media.bak.$(date +%s)" + echo " 迁移完成" +fi + +echo "[3/6] 写入 .env(DISH_MEDIA_ROOT=$MEDIA_DIR,不要带 /media)" +cat > "$APP_DIR/.env" </dev/null || true +pkill -f "python.*main.py" 2>/dev/null || true +fuser -k 9100/tcp 2>/dev/null || true +sleep 1 + +echo "[5/6] 启动" +if [ -x "$APP_DIR/.venv/bin/python" ]; then + PY="$APP_DIR/.venv/bin/python" +elif command -v python3 >/dev/null 2>&1; then + PY=python3 +else + PY=python +fi +nohup "$PY" "$APP_DIR/main.py" > /tmp/dish_server.log 2>&1 & +sleep 2 + +echo "[6/6] 探活" +curl -sS "http://127.0.0.1:9100/api/health" || true +echo +echo "---- 期望 ----" +echo " media_root = $MEDIA_DIR" +echo " public_base = $PUBLIC_BASE" +echo " 目录示例: ls $MEDIA_DIR (应直接看到 BASE_*/TEST,而不是 media/)" +echo "日志: /tmp/dish_server.log" diff --git a/dish_server/env.example b/dish_server/env.example new file mode 100644 index 0000000..8452466 --- /dev/null +++ b/dish_server/env.example @@ -0,0 +1,8 @@ +# 复制为 .env +# 警告:DISH_MEDIA_ROOT 必须是 /data/tpweb +# 不要写成 /data/tpweb/media(否则访问 https://cpsb.ruimeida.cc/tpweb/xxx 对不上) + +DISH_API_HOST=0.0.0.0 +DISH_API_PORT=9100 +DISH_MEDIA_ROOT=/data/tpweb +DISH_MEDIA_PUBLIC_BASE=https://cpsb.ruimeida.cc/tpweb diff --git a/dish_server/main.py b/dish_server/main.py new file mode 100644 index 0000000..412fd3c --- /dev/null +++ b/dish_server/main.py @@ -0,0 +1,53 @@ +# coding=utf-8 +"""一键启动总服务器中心 API。 +""" +from __future__ import annotations + +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_HERE) +for p in (_HERE, _ROOT): + if p not in sys.path: + sys.path.insert(0, p) + + +def load_env(path, override=True): + """加载 .env;默认覆盖已有环境变量,确保云机配置生效。""" + if not os.path.isfile(path): + print(f"[WARN] .env not found: {path}") + return + with open(path, "r", encoding="utf-8-sig") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if not key: + continue + if override or key not in os.environ: + os.environ[key] = value + print(f"[OK] loaded env {path}") + print(f" DISH_MEDIA_ROOT={os.environ.get('DISH_MEDIA_ROOT')}") + print(f" DISH_MEDIA_PUBLIC_BASE={os.environ.get('DISH_MEDIA_PUBLIC_BASE')}") + + +def main(): + load_env(os.path.join(_HERE, ".env"), override=True) + # Linux 云机强制默认(即便没写 .env) + if os.name != "nt": + os.environ.setdefault("DISH_MEDIA_ROOT", "/data/tpweb") + os.environ.setdefault("DISH_MEDIA_PUBLIC_BASE", "https://cpsb.ruimeida.cc/tpweb") + os.chdir(_HERE) + try: + from api_server import serve + except ImportError: + from dish_server.api_server import serve + serve() + + +if __name__ == "__main__": + main() diff --git a/dish_server/media_store.py b/dish_server/media_store.py new file mode 100644 index 0000000..fb3c21e --- /dev/null +++ b/dish_server/media_store.py @@ -0,0 +1,120 @@ +# coding=utf-8 +"""本地对象存储(静态文件目录)。 + +云机默认落盘:/data/tpweb +对外访问:https://cpsb.ruimeida.cc/tpweb/... +""" + +from __future__ import annotations +import os +import re +import time +from pathlib import Path + +_FOLDER = os.path.dirname(os.path.abspath(__file__)) + +# 云微服务:上传走 :9100;落盘 /data/tpweb;对外 https://cpsb.ruimeida.cc/tpweb +CLOUD_MEDIA_HOST = "116.147.40.47" +CLOUD_MEDIA_PORT = 9100 +# 部署目录参考:/home/cpsb/project/dish_server +# 磁盘目录(与 Nginx /tpweb 根一致);本机 Windows 开发回落到项目内目录 +_DEFAULT_MEDIA_ROOT = ( + os.environ.get("DISH_MEDIA_ROOT") + or ("/data/tpweb" if os.name != "nt" else os.path.join(_FOLDER, "data", "tpweb")) +) +# 浏览器访问前缀(HTTPS,避免管理系统 Mixed Content) +CLOUD_MEDIA_PUBLIC_BASE = "https://cpsb.ruimeida.cc/tpweb" +CLOUD_MEDIA_UPLOAD_URL = f"http://{CLOUD_MEDIA_HOST}:{CLOUD_MEDIA_PORT}/api/dish/media/upload" + +MEDIA_ROOT = _DEFAULT_MEDIA_ROOT +MEDIA_PUBLIC_BASE = os.environ.get("DISH_MEDIA_PUBLIC_BASE", CLOUD_MEDIA_PUBLIC_BASE).rstrip("/") + +_SAFE = re.compile(r"[^A-Za-z0-9._-]+") +ALLOWED_TYPES = ("source", "labeled", "crop") +ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} + + +def _safe_token(value, fallback="x"): + text = _SAFE.sub("_", str(value or "").strip())[:80] + return text or fallback + + +def public_url(rel_path): + rel = str(rel_path or "").replace("\\", "/").lstrip("/") + return f"{MEDIA_PUBLIC_BASE}/{rel}" + + +def local_path(rel_path): + rel = str(rel_path or "").replace("\\", "/").lstrip("/") + return os.path.normpath(os.path.join(MEDIA_ROOT, *rel.split("/"))) + + +def is_http_url(value): + text = (value or "").strip().lower() + return text.startswith("http://") or text.startswith("https://") + + +class MediaStore: + """按 基地 / 请求号 归档图片。""" + + def __init__(self, root=None, public_base=None): + self.root = os.path.abspath(root or MEDIA_ROOT) + self.public_base = (public_base or MEDIA_PUBLIC_BASE).rstrip("/") + os.makedirs(self.root, exist_ok=True) + + def save_bytes(self, data, filename, media_type="source", base_id="BASE", request_id=None): + if not data: + raise ValueError("empty file") + media_type = (media_type or "source").strip().lower() + if media_type not in ALLOWED_TYPES: + media_type = "source" + ext = Path(filename or "").suffix.lower() or ".jpg" + if ext not in ALLOWED_EXT: + ext = ".jpg" + req = _safe_token(request_id or f"REQ_{int(time.time() * 1000)}") + base = _safe_token(base_id or "BASE") + stem = _safe_token(Path(filename or media_type).stem, media_type) + rel = f"{base}/{req}/{media_type}_{stem}{ext}" + dest = local_path_under(self.root, rel) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "wb") as fh: + fh.write(data) + rel_url = rel.replace("\\", "/") + return { + "rel_path": rel_url, + "path": dest, + "url": f"{self.public_base}/{rel_url}", + "type": media_type, + } + + def save_file(self, src_path, media_type="source", base_id="BASE", request_id=None): + if not src_path or not os.path.isfile(src_path): + return None + with open(src_path, "rb") as fh: + data = fh.read() + return self.save_bytes( + data, + filename=os.path.basename(src_path), + media_type=media_type, + base_id=base_id, + request_id=request_id, + ) + + +def local_path_under(root, rel_path): + dest = os.path.normpath(os.path.join(root, *str(rel_path).replace("\\", "/").split("/"))) + root_abs = os.path.abspath(root) + dest_abs = os.path.abspath(dest) + if dest_abs != root_abs and not dest_abs.startswith(root_abs + os.sep): + raise ValueError("invalid media path") + return dest_abs + + +def copy_if_local(src_path, media_type, base_id, request_id, store=None): + if not src_path: + return src_path + if is_http_url(src_path): + return src_path + store = store or MediaStore() + saved = store.save_file(src_path, media_type=media_type, base_id=base_id, request_id=request_id) + return saved["url"] if saved else src_path diff --git a/dish_server/nginx.https.example.conf b/dish_server/nginx.https.example.conf new file mode 100644 index 0000000..c129105 --- /dev/null +++ b/dish_server/nginx.https.example.conf @@ -0,0 +1,36 @@ +# 示例:用 Nginx 给 dish_server 提供 HTTPS,解决管理系统混合内容拦截。 +# +# 前提: +# 1. 有域名解析到 116.147.40.47(推荐,可用 Let's Encrypt) +# 2. 云上 dish_server 仍监听 127.0.0.1:9100 或 0.0.0.0:9100 +# 3. 安全组放行 80/443 +# +# 证书申请(有域名时): +# certbot --nginx -d media.your-domain.com +# +# dish_server/.env 中改为: +# DISH_MEDIA_PUBLIC_BASE=https://media.your-domain.com/media + +server { + listen 80; + server_name media.your-domain.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name media.your-domain.com; + + ssl_certificate /etc/letsencrypt/live/media.your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/media.your-domain.com/privkey.pem; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:9100; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} diff --git a/dish_server/nginx.tpweb.example.conf b/dish_server/nginx.tpweb.example.conf new file mode 100644 index 0000000..81ce200 --- /dev/null +++ b/dish_server/nginx.tpweb.example.conf @@ -0,0 +1,22 @@ +# 将 https://cpsb.ruimeida.cc/tpweb/ 映射到磁盘 /data/tpweb +# 若主站已有 server 块,只需把 location 合并进去。 + +server { + listen 80; + server_name cpsb.ruimeida.cc; + + location /tpweb/ { + alias /data/tpweb/; + autoindex off; + expires 7d; + add_header Access-Control-Allow-Origin *; + } +} + +# HTTPS(443)的 server 里同样需要: +# location /tpweb/ { +# alias /data/tpweb/; +# autoindex off; +# expires 7d; +# add_header Access-Control-Allow-Origin *; +# } diff --git a/central_server/start.bat b/dish_server/start.bat similarity index 100% rename from central_server/start.bat rename to dish_server/start.bat