识别菜品上传接口

This commit is contained in:
wanghanbo 2026-10-12 09:55:55 +08:00
parent 5a61c59fba
commit 3e09e771cf
11 changed files with 776 additions and 0 deletions

78
dish_server/README.md Normal file
View File

@ -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`

5
dish_server/__init__.py Normal file
View File

@ -0,0 +1,5 @@
# 总服务器中心 API(图片上传 + 识别结果)
from .api_server import serve
from .media_store import MediaStore, is_http_url
__all__ = ["serve", "MediaStore", "is_http_url"]

4
dish_server/__main__.py Normal file
View File

@ -0,0 +1,4 @@
from .api_server import serve
if __name__ == "__main__":
serve()

373
dish_server/api_server.py Normal file
View File

@ -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()

View File

@ -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" <<EOF
DISH_API_HOST=0.0.0.0
DISH_API_PORT=9100
DISH_MEDIA_ROOT=$MEDIA_DIR
DISH_MEDIA_PUBLIC_BASE=$PUBLIC_BASE
EOF
cat "$APP_DIR/.env"
echo "[4/6] 停止旧进程"
pkill -f "$APP_DIR/main.py" 2>/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"

8
dish_server/env.example Normal file
View File

@ -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

53
dish_server/main.py Normal file
View File

@ -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()

120
dish_server/media_store.py Normal file
View File

@ -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

View File

@ -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;
}
}

View File

@ -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 *;
# }