Files
fj/server.py
T
selfreleaseandDevin 490f032d60 feat: 隐藏后台面板 + /api/stats 运行统计
- 连点页脚提示 6 次唤出管理面板:本机偏好/播放状态 + 服务器统计
- server.py 新增 GET /api/stats:运行时长、访客 IP(X-Forwarded-For
  末位取真实 IP 防伪造)、TTS 请求与缓存命中率、缓存体积、经典数
- 计数为进程内存统计,重启清零

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-05 13:19:59 +08:00

203 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""心经诵读站点后端:静态文件托管 + 千问 TTS 代理。"""
import hashlib
import json
import os
import time
import urllib.error
import urllib.request
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
CACHE_DIR = BASE_DIR / "cache"
SUTRA_DIR = BASE_DIR / "sutras"
ENV_PATH = BASE_DIR / ".env"
def load_env(path: Path) -> None:
"""读取 .env 文件并注入环境变量(不覆盖已有变量)。"""
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
load_env(ENV_PATH)
API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE = os.environ.get(
"DASHSCOPE_BASE",
"https://llm-znfsxsdp6uwik7ad.cn-beijing.maas.aliyuncs.com/api/v1",
).rstrip("/")
TTS_MODEL = os.environ.get("TTS_MODEL", "qwen3-tts-flash")
PORT = int(os.environ.get("PORT", "8000"))
GENERATION_URL = f"{DASHSCOPE_BASE}/services/aigc/multimodal-generation/generation"
# 运行期统计:进程内存内计数,重启清零
STATS = {"start": time.time(), "tts_requests": 0, "cache_hits": 0}
VISITORS = set() # 见过的客户端 IP(本次进程生命周期内)
def http_post_json(url: str, payload: dict, headers: dict, timeout: int = 60) -> dict:
"""发送 JSON POST 请求并返回解析后的响应。"""
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **headers},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def synthesize(text: str, voice: str) -> bytes:
"""调用千问 TTS 合成语音,返回 wav 音频字节流。"""
payload = {
"model": TTS_MODEL,
"input": {"text": text, "voice": voice},
}
data = http_post_json(GENERATION_URL, payload, {"Authorization": f"Bearer {API_KEY}"})
audio_url = (data.get("output") or {}).get("audio", {}).get("url")
if not audio_url:
raise RuntimeError(f"TTS 响应缺少音频地址:{json.dumps(data, ensure_ascii=False)[:300]}")
with urllib.request.urlopen(audio_url, timeout=60) as resp:
return resp.read()
class Handler(SimpleHTTPRequestHandler):
"""静态资源 + /api/tts 合成接口。"""
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def _track_visitor(self) -> None:
"""记录客户端 IP 到访客集合;nginx 反代后真实来源在 X-Forwarded-For / X-Real-IP。"""
# nginx 把真实 IP 追加在 X-Forwarded-For 末尾,取最后一个元素防伪造
xff = (self.headers.get("X-Forwarded-For") or "").split(",")[-1].strip()
ip = xff or (self.headers.get("X-Real-IP") or "").strip() or self.client_address[0]
VISITORS.add(ip)
def do_GET(self) -> None:
"""处理 GET 请求:/api/* 接口,其余按静态文件处理。"""
self._track_visitor()
if self.path == "/api/stats":
cache_files = list(CACHE_DIR.glob("*.wav")) if CACHE_DIR.is_dir() else []
self._send_json({
"uptime_sec": int(time.time() - STATS["start"]),
"tts_requests": STATS["tts_requests"],
"cache_hits": STATS["cache_hits"],
"cache_files": len(cache_files),
"cache_bytes": sum(f.stat().st_size for f in cache_files),
"sutra_count": len(list(SUTRA_DIR.glob("*.json"))) if SUTRA_DIR.is_dir() else 0,
"visitors": len(VISITORS),
"model": TTS_MODEL,
})
return
if self.path == "/api/sutras":
try:
items = []
if SUTRA_DIR.is_dir():
for f in sorted(SUTRA_DIR.glob("*.json")):
d = json.loads(f.read_text(encoding="utf-8"))
items.append({
"id": d.get("id") or f.stem,
"name": d.get("name") or f.stem,
"cat": d.get("cat") or "其他",
"order": d.get("order", 99),
})
items.sort(key=lambda x: (x["order"], x["id"]))
self._send_json({"sutras": items})
except Exception as exc: # noqa: BLE001
self._send_json({"error": f"读取经典目录失败:{exc}"}, status=500)
return
super().do_GET()
def do_POST(self) -> None:
"""处理 POST 请求,目前仅支持 /api/tts。"""
self._track_visitor()
if self.path != "/api/tts":
self._send_json({"error": "not found"}, status=404)
return
try:
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length).decode("utf-8"))
except (ValueError, json.JSONDecodeError):
self._send_json({"error": "请求体不是合法 JSON"}, status=400)
return
text = (body.get("text") or "").strip()
voice = (body.get("voice") or "").strip()
if not text or not voice:
self._send_json({"error": "缺少 text 或 voice 参数"}, status=400)
return
if len(text) > 800:
self._send_json({"error": "文本超过 800 字上限"}, status=400)
return
if not API_KEY:
self._send_json({"error": "服务端未配置 DASHSCOPE_API_KEY"}, status=500)
return
cache_key = hashlib.sha256(f"{TTS_MODEL}|{voice}|{text}".encode("utf-8")).hexdigest()[:24]
cache_file = CACHE_DIR / f"{cache_key}.wav"
STATS["tts_requests"] += 1
try:
if cache_file.exists():
STATS["cache_hits"] += 1
audio = cache_file.read_bytes()
else:
audio = synthesize(text, voice)
CACHE_DIR.mkdir(exist_ok=True)
# 先写临时文件再原子重命名,避免中途崩溃留下半截 WAV 毒化缓存
tmp_file = cache_file.with_suffix(".tmp")
tmp_file.write_bytes(audio)
tmp_file.replace(cache_file)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:300]
self._send_json({"error": f"TTS 服务返回 {exc.code}:{detail}"}, status=502)
return
except Exception as exc: # noqa: BLE001 - 统一兜底,向前端返回可读错误
self._send_json({"error": f"语音合成失败:{exc}"}, status=502)
return
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.end_headers()
self.wfile.write(audio)
def _send_json(self, obj: dict, status: int = 200) -> None:
"""以 JSON 形式返回响应。"""
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def end_headers(self) -> None:
"""本地开发环境禁用静态资源缓存,避免页面拿到旧版 JS/CSS。"""
if not self.path.startswith("/api/"):
self.send_header("Cache-Control", "no-cache")
super().end_headers()
def log_message(self, fmt, *args) -> None:
"""精简请求日志输出。"""
if "/api/" in self.path:
super().log_message(fmt, *args)
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
print(f"心经诵读站点已启动:http://127.0.0.1:{PORT}")
print(f"TTS 模型:{TTS_MODEL} | 上游:{DASHSCOPE_BASE}")
if not API_KEY:
print("警告:未配置 DASHSCOPE_API_KEY,请复制 .env.example 为 .env 并填入密钥")
server.serve_forever()