feat(api): 支持挂载 Nuxt 静态 UI 并统一桌面端运行参数

- 若存在 WECHAT_TOOL_UI_DIR 或 frontend/.output/public,则在 / 挂载静态站点并支持 SPA 路由回退
- API 根端点调整为 /api,避免与静态 UI 冲突
- 新增 WECHAT_TOOL_DATA_DIR 输出目录约定,统一 databases/key store 等路径
- host/port 支持通过 WECHAT_TOOL_HOST / WECHAT_TOOL_PORT 配置,并打印可访问地址
- 新增 backend_entry.py 作为 PyInstaller 入口,减少动态导入识别问题
This commit is contained in:
2977094657
2026-01-17 18:23:35 +08:00
parent 95f4d32d53
commit 848847c162
9 changed files with 148 additions and 18 deletions

12
main.py
View File

@@ -14,12 +14,16 @@ from pathlib import Path
def main():
"""启动微信解密工具API服务"""
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
access_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
print("=" * 60)
print("微信解密工具 API 服务")
print("=" * 60)
print("正在启动服务...")
print("API文档: http://localhost:8000/docs")
print("健康检查: http://localhost:8000/api/health")
print(f"API文档: http://{access_host}:{port}/docs")
print(f"健康检查: http://{access_host}:{port}/api/health")
print("按 Ctrl+C 停止服务")
print("=" * 60)
@@ -29,8 +33,8 @@ def main():
# 启动API服务
uvicorn.run(
"wechat_decrypt_tool.api:app",
host="0.0.0.0",
port=8000,
host=host,
port=port,
reload=enable_reload,
reload_dirs=[str(repo_root / "src")] if enable_reload else None,
reload_excludes=[

View File

@@ -1,7 +1,13 @@
"""微信解密工具的FastAPI Web服务器"""
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
from .logging_config import setup_logging, get_logger
from .path_fix import PathFixRoute
@@ -47,6 +53,70 @@ app.include_router(_chat_export_router)
app.include_router(_chat_media_router)
class _SPAStaticFiles(StaticFiles):
"""StaticFiles with a SPA fallback (Nuxt generate output)."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._fallback_200 = Path(str(self.directory)) / "200.html"
self._fallback_index = Path(str(self.directory)) / "index.html"
async def get_response(self, path: str, scope): # type: ignore[override]
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code != 404:
raise
# For client-side routes (no file extension), return Nuxt's SPA fallback.
name = Path(path).name
if "." in name:
raise
if self._fallback_200.exists():
return FileResponse(str(self._fallback_200))
return FileResponse(str(self._fallback_index))
def _maybe_mount_frontend() -> None:
"""Serve the generated Nuxt static site at `/` if present.
This keeps web + desktop UI identical when the desktop shell (Electron) loads
http://127.0.0.1:<port>/ from the same backend that serves `/api/*`.
"""
ui_dir_env = os.environ.get("WECHAT_TOOL_UI_DIR", "").strip()
candidates: list[Path] = []
if ui_dir_env:
candidates.append(Path(ui_dir_env))
# Repo default: `frontend/.output/public` after `npm --prefix frontend run generate`.
repo_root = Path(__file__).resolve().parents[2]
candidates.append(repo_root / "frontend" / ".output" / "public")
ui_dir: Path | None = None
for p in candidates:
try:
if (p / "index.html").is_file():
ui_dir = p
break
except Exception:
continue
if not ui_dir:
return
try:
app.mount("/", _SPAStaticFiles(directory=str(ui_dir), html=True), name="ui")
logger.info("Serving frontend UI from: %s", ui_dir)
except Exception:
logger.exception("Failed to mount frontend UI from: %s", ui_dir)
_maybe_mount_frontend()
@app.on_event("shutdown")
async def _shutdown_wcdb_realtime() -> None:
try:
@@ -62,4 +132,6 @@ async def _shutdown_wcdb_realtime() -> None:
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
uvicorn.run(app, host=host, port=port)

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
import os
from pathlib import Path
def get_data_dir() -> Path:
"""Base writable directory for all runtime output (logs, databases, key store).
- Desktop (Electron) should set `WECHAT_TOOL_DATA_DIR` to a per-user directory
(e.g. `%APPDATA%/WeChatDataAnalysis`).
- Dev defaults to the current working directory (repo root).
"""
v = os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip()
if v:
return Path(v)
return Path.cwd()
def get_output_dir() -> Path:
return get_data_dir() / "output"
def get_output_databases_dir() -> Path:
return get_output_dir() / "databases"
def get_account_keys_path() -> Path:
return get_output_dir() / "account_keys.json"

View File

@@ -0,0 +1,21 @@
"""Entry point for bundling the FastAPI backend into a standalone executable.
This avoids dynamic import strings like "pkg.module:app" which some bundlers
cannot detect reliably.
"""
import os
import uvicorn
from wechat_decrypt_tool.api import app
def main() -> None:
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
uvicorn.run(app, host=host, port=port, log_level="info")
if __name__ == "__main__":
main()

View File

@@ -12,6 +12,7 @@ from urllib.parse import quote
from fastapi import HTTPException
from .app_paths import get_output_databases_dir
from .logging_config import get_logger
try:
@@ -21,8 +22,7 @@ except Exception:
logger = get_logger(__name__)
_REPO_ROOT = Path(__file__).resolve().parents[2]
_OUTPUT_DATABASES_DIR = _REPO_ROOT / "output" / "databases"
_OUTPUT_DATABASES_DIR = get_output_databases_dir()
_DEBUG_SESSIONS = os.environ.get("WECHAT_TOOL_DEBUG_SESSIONS", "0") == "1"

View File

@@ -3,8 +3,9 @@ import json
from pathlib import Path
from typing import Any, Optional
_REPO_ROOT = Path(__file__).resolve().parents[2]
_KEY_STORE_PATH = _REPO_ROOT / "output" / "account_keys.json"
from .app_paths import get_account_keys_path
_KEY_STORE_PATH = get_account_keys_path()
def _atomic_write_json(path: Path, payload: Any) -> None:
@@ -66,4 +67,3 @@ def upsert_account_keys_in_store(
pass
return item

View File

@@ -16,14 +16,14 @@ from urllib.parse import urlparse
from fastapi import HTTPException
from .app_paths import get_output_databases_dir
from .logging_config import get_logger
logger = get_logger(__name__)
# 仓库根目录(用于定位 output/databases
_REPO_ROOT = Path(__file__).resolve().parents[2]
_OUTPUT_DATABASES_DIR = _REPO_ROOT / "output" / "databases"
# 运行时输出目录(桌面端可通过 WECHAT_TOOL_DATA_DIR 指向可写目录
_OUTPUT_DATABASES_DIR = get_output_databases_dir()
_PACKAGE_ROOT = Path(__file__).resolve().parent

View File

@@ -8,10 +8,10 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
@router.get("/", summary="根端点")
async def root():
"""根端点"""
logger.info("访问根端点")
@router.get("/api", summary="API 根端点")
async def api_root():
"""API 根端点"""
logger.info("访问 API 根端点")
return {"message": "微信数据库解密工具 API"}

View File

@@ -20,6 +20,8 @@ from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from .app_paths import get_output_databases_dir
# 注意:不再支持默认密钥,所有密钥必须通过参数传入
# SQLite文件头
@@ -251,7 +253,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
logger.info("=" * 60)
# 创建基础输出目录
base_output_dir = Path("output/databases")
base_output_dir = get_output_databases_dir()
base_output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"基础输出目录: {base_output_dir.absolute()}")