server.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. #!/usr/bin/env python3
  2. """3D 部件查看与配色 - 本地演示服务器 (纯标准库实现, 无需安装依赖)
  3. 用法:
  4. python3 server.py # 默认端口 9000, 浏览器打开 http://127.0.0.1:9000
  5. python3 server.py --port 9100
  6. 功能:
  7. GET / 首页 (index.html)
  8. GET /js/... /css/... 前端静态资源
  9. GET /models/<file> GLB/glTF 模型文件
  10. GET /api/models 返回 models 目录下可用的模型列表 (JSON)
  11. GET /api/state?model=xx 读取某个模型的配色状态 (JSON)
  12. POST /api/state?model=xx 保存配色状态到 data/color_state.json
  13. """
  14. import argparse
  15. import json
  16. import mimetypes
  17. import os
  18. import re
  19. import urllib.parse
  20. from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
  21. BASE = os.path.dirname(os.path.abspath(__file__))
  22. STATIC_DIR = os.path.join(BASE, "static")
  23. MODELS_DIR = os.path.join(BASE, "models")
  24. DATA_DIR = os.path.join(BASE, "data")
  25. DATA_FILE = os.path.join(DATA_DIR, "color_state.json")
  26. EXTRA_TYPES = {
  27. ".glb": "model/gltf-binary",
  28. ".gltf": "model/gltf+json",
  29. ".js": "text/javascript; charset=utf-8",
  30. ".json": "application/json; charset=utf-8",
  31. ".css": "text/css; charset=utf-8",
  32. ".html": "text/html; charset=utf-8",
  33. }
  34. # 允许的扩展名 -> 相对子目录
  35. ROUTES = {
  36. "/models": ("models", {".glb", ".gltf"}),
  37. "/js": ("static/js", {".js", ".mjs"}),
  38. "/css": ("static/css", {".css"}),
  39. "/images": ("static/images", {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}),
  40. }
  41. SAFE_NAME = re.compile(r"^[\w\-. ]+$")
  42. class Handler(SimpleHTTPRequestHandler):
  43. protocol_version = "HTTP/1.1"
  44. # ---- 工具 ----
  45. def _guess_type(self, path):
  46. ext = os.path.splitext(path)[1].lower()
  47. if ext in EXTRA_TYPES:
  48. return EXTRA_TYPES[ext]
  49. return mimetypes.guess_type(path)[0] or "application/octet-stream"
  50. def _send_bytes(self, code, body: bytes, ctype="application/octet-stream"):
  51. self.send_response(code)
  52. self.send_header("Content-Type", ctype)
  53. self.send_header("Content-Length", str(len(body)))
  54. self.send_header("Cache-Control", "no-store")
  55. self.end_headers()
  56. self.wfile.write(body)
  57. def _send_json(self, obj, code=200):
  58. self._send_bytes(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
  59. "application/json; charset=utf-8")
  60. def _send_file(self, full_path, ctype=None):
  61. try:
  62. with open(full_path, "rb") as f:
  63. body = f.read()
  64. except OSError:
  65. self.send_error(404, "Not Found")
  66. return
  67. self._send_bytes(200, body, ctype or self._guess_type(full_path))
  68. def _safe_join(self, root, rel):
  69. """防路径穿越"""
  70. full = os.path.abspath(os.path.join(root, rel))
  71. if not full.startswith(os.path.abspath(root) + os.sep) and full != os.path.abspath(root):
  72. return None
  73. return full
  74. def _resolve_static(self, path):
  75. for prefix, (sub, exts) in ROUTES.items():
  76. if path == prefix or path.startswith(prefix + "/"):
  77. rel = path[len(prefix):].lstrip("/")
  78. root = os.path.join(BASE, sub)
  79. full = self._safe_join(root, rel) if rel else root
  80. if full and os.path.isfile(full):
  81. ext = os.path.splitext(full)[1].lower()
  82. return full if ext in exts else None
  83. if full and os.path.isdir(full):
  84. idx = os.path.join(full, "index.html")
  85. if os.path.isfile(idx):
  86. return idx
  87. return None
  88. return None
  89. # ---- 数据读写 ----
  90. def _load_state(self):
  91. try:
  92. with open(DATA_FILE, "r", encoding="utf-8") as f:
  93. return json.load(f)
  94. except (OSError, json.JSONDecodeError):
  95. return {}
  96. def _save_state(self, state):
  97. os.makedirs(DATA_DIR, exist_ok=True)
  98. tmp = DATA_FILE + ".tmp"
  99. with open(tmp, "w", encoding="utf-8") as f:
  100. json.dump(state, f, ensure_ascii=False, indent=2)
  101. os.replace(tmp, DATA_FILE)
  102. # ---- handlers ----
  103. def do_GET(self):
  104. parsed = urllib.parse.urlparse(self.path)
  105. path = urllib.parse.unquote(parsed.path)
  106. query = urllib.parse.parse_qs(parsed.query)
  107. if path == "/api/models":
  108. try:
  109. names = [f for f in os.listdir(MODELS_DIR)
  110. if os.path.isfile(os.path.join(MODELS_DIR, f))
  111. and SAFE_NAME.match(f)
  112. and f.lower().endswith((".glb", ".gltf"))]
  113. except OSError:
  114. names = []
  115. self._send_json(sorted(names))
  116. return
  117. if path == "/api/state":
  118. model = (query.get("model") or [""])[0]
  119. state = self._load_state()
  120. self._send_json(state.get(model, {}))
  121. return
  122. if path == "/" or path == "":
  123. self._send_file(os.path.join(STATIC_DIR, "index.html"))
  124. return
  125. static_file = self._resolve_static(path)
  126. if static_file:
  127. self._send_file(static_file)
  128. return
  129. if path.startswith("/models/"):
  130. rel = path[len("/models/"):]
  131. if SAFE_NAME.match(rel):
  132. full = self._safe_join(MODELS_DIR, rel)
  133. if full and os.path.isfile(full):
  134. self._send_file(full)
  135. return
  136. self.send_error(404, "Not Found")
  137. def do_POST(self):
  138. parsed = urllib.parse.urlparse(self.path)
  139. path = urllib.parse.unquote(parsed.path)
  140. query = urllib.parse.parse_qs(parsed.query)
  141. if path == "/api/state":
  142. try:
  143. length = int(self.headers.get("Content-Length") or 0)
  144. body = self.rfile.read(length) if length else b"{}"
  145. data = json.loads(body.decode("utf-8"))
  146. except (ValueError, json.JSONDecodeError):
  147. self.send_error(400, "Bad JSON")
  148. return
  149. model = (query.get("model") or [""])[0]
  150. if not model:
  151. self.send_error(400, "缺少 model 参数")
  152. return
  153. if not isinstance(data, dict):
  154. self.send_error(400, "body 应为 JSON 对象")
  155. return
  156. state = self._load_state()
  157. state[model] = data
  158. self._save_state(state)
  159. self._send_json({"ok": True, "model": model, "count": len(data)})
  160. return
  161. self.send_error(405, "Method Not Allowed")
  162. # ---- 日志 ----
  163. def log_message(self, fmt, *args):
  164. print(f"[server] {self.address_string()} - {fmt % args}")
  165. def main():
  166. ap = argparse.ArgumentParser(description="3D 部件查看与配色本地服务器")
  167. ap.add_argument("--host", default="127.0.0.1")
  168. ap.add_argument("--port", type=int, default=9000)
  169. args = ap.parse_args()
  170. httpd = ThreadingHTTPServer((args.host, args.port), Handler)
  171. url = f"http://{args.host}:{args.port}"
  172. print("=" * 56)
  173. print(" 3D 部件查看与配色 演示服务器已启动")
  174. print(f" 请用浏览器打开: {url}")
  175. print(f" 模型目录: {MODELS_DIR}")
  176. print(f" 配色数据: {DATA_FILE}")
  177. print(" 按 Ctrl+C 停止")
  178. print("=" * 56)
  179. try:
  180. httpd.serve_forever()
  181. except KeyboardInterrupt:
  182. print("\n已停止")
  183. if __name__ == "__main__":
  184. main()