识别菜品上传接口
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