From 848847c162a9e4d8381cbe62ea271325bcd892f9 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Sat, 17 Jan 2026 18:23:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=94=AF=E6=8C=81=E6=8C=82?= =?UTF-8?q?=E8=BD=BD=20Nuxt=20=E9=9D=99=E6=80=81=20UI=20=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=A1=8C=E9=9D=A2=E7=AB=AF=E8=BF=90=E8=A1=8C=E5=8F=82?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 若存在 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 入口,减少动态导入识别问题 --- main.py | 12 ++-- src/wechat_decrypt_tool/api.py | 74 ++++++++++++++++++++++- src/wechat_decrypt_tool/app_paths.py | 31 ++++++++++ src/wechat_decrypt_tool/backend_entry.py | 21 +++++++ src/wechat_decrypt_tool/chat_helpers.py | 4 +- src/wechat_decrypt_tool/key_store.py | 6 +- src/wechat_decrypt_tool/media_helpers.py | 6 +- src/wechat_decrypt_tool/routers/health.py | 8 +-- src/wechat_decrypt_tool/wechat_decrypt.py | 4 +- 9 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 src/wechat_decrypt_tool/app_paths.py create mode 100644 src/wechat_decrypt_tool/backend_entry.py diff --git a/main.py b/main.py index 732d404..426d786 100644 --- a/main.py +++ b/main.py @@ -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=[ diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index 4054ff0..df3773f 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -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:/ 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) diff --git a/src/wechat_decrypt_tool/app_paths.py b/src/wechat_decrypt_tool/app_paths.py new file mode 100644 index 0000000..caab0a3 --- /dev/null +++ b/src/wechat_decrypt_tool/app_paths.py @@ -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" + diff --git a/src/wechat_decrypt_tool/backend_entry.py b/src/wechat_decrypt_tool/backend_entry.py new file mode 100644 index 0000000..bbfbf3b --- /dev/null +++ b/src/wechat_decrypt_tool/backend_entry.py @@ -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() diff --git a/src/wechat_decrypt_tool/chat_helpers.py b/src/wechat_decrypt_tool/chat_helpers.py index 2322d3b..fa64347 100644 --- a/src/wechat_decrypt_tool/chat_helpers.py +++ b/src/wechat_decrypt_tool/chat_helpers.py @@ -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" diff --git a/src/wechat_decrypt_tool/key_store.py b/src/wechat_decrypt_tool/key_store.py index f06addd..570b080 100644 --- a/src/wechat_decrypt_tool/key_store.py +++ b/src/wechat_decrypt_tool/key_store.py @@ -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 - diff --git a/src/wechat_decrypt_tool/media_helpers.py b/src/wechat_decrypt_tool/media_helpers.py index 47a9e3c..59ed9c8 100644 --- a/src/wechat_decrypt_tool/media_helpers.py +++ b/src/wechat_decrypt_tool/media_helpers.py @@ -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 diff --git a/src/wechat_decrypt_tool/routers/health.py b/src/wechat_decrypt_tool/routers/health.py index 5e8caf9..1a91ce2 100644 --- a/src/wechat_decrypt_tool/routers/health.py +++ b/src/wechat_decrypt_tool/routers/health.py @@ -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"} diff --git a/src/wechat_decrypt_tool/wechat_decrypt.py b/src/wechat_decrypt_tool/wechat_decrypt.py index a0d5b5c..b6aa38a 100644 --- a/src/wechat_decrypt_tool/wechat_decrypt.py +++ b/src/wechat_decrypt_tool/wechat_decrypt.py @@ -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()}")