识别菜品上传接口
This commit is contained in:
parent
5a61c59fba
commit
3e09e771cf
|
|
@ -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`
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# 总服务器中心 API(图片上传 + 识别结果)
|
||||
from .api_server import serve
|
||||
from .media_store import MediaStore, is_http_url
|
||||
|
||||
__all__ = ["serve", "MediaStore", "is_http_url"]
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from .api_server import serve
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 *;
|
||||
# }
|
||||
Loading…
Reference in New Issue