diff --git a/pyproject.toml b/pyproject.toml index 36acab3..074084d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "diskcache", "fastapi[uvicorn]", "gunicorn", - "module-bank==0.2.4", + "module-bank==0.3.1", "pyjwt", "python-multipart", "sqlite-vfs", @@ -26,14 +26,14 @@ dependencies = [ "uvicorn", ] +[[tool.uv.index]] +url = "https://pypi.org/simple" + [[tool.uv.index]] url = "https://pypi.tuna.tsinghua.edu.cn/simple" default = true -[[tool.uv.index]] -url = "https://pypi.org/simple" - [[tool.uv.index]] url = "https://mirrors.ustc.edu.cn/pypi/web/simple/" diff --git a/src/chongming-web/.env.development b/src/chongming-web/.env.development new file mode 100644 index 0000000..8dea7a9 --- /dev/null +++ b/src/chongming-web/.env.development @@ -0,0 +1 @@ +VITE_API_BASE_URL = "http://localhost:8000" \ No newline at end of file diff --git a/src/chongming-web/.env.production b/src/chongming-web/.env.production new file mode 100644 index 0000000..0bc1ba6 --- /dev/null +++ b/src/chongming-web/.env.production @@ -0,0 +1 @@ +VITE_API_BASE_URL = "/" \ No newline at end of file diff --git a/src/chongming-web/env.d.ts b/src/chongming-web/env.d.ts index 11f02fe..8f01c65 100644 --- a/src/chongming-web/env.d.ts +++ b/src/chongming-web/env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string + // 更多环境变量... +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/chongming-web/src/main.ts b/src/chongming-web/src/main.ts index ee64a16..abe59b7 100644 --- a/src/chongming-web/src/main.ts +++ b/src/chongming-web/src/main.ts @@ -15,9 +15,17 @@ import { useAuthStore } from './stores/auth' * 打包时取消该注释 */ import { client } from "./api/generated/client.gen" -client.setConfig({ - baseUrl: "/" -}) +// client.setConfig({ +// baseUrl: "/" +// }) +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL + +if (API_BASE_URL) { + client.setConfig({ + baseUrl: API_BASE_URL + }) +} + const app = createApp(App) diff --git a/src/chongming/app/__init__.py b/src/chongming/app/__init__.py index 57250b9..5dabac5 100644 --- a/src/chongming/app/__init__.py +++ b/src/chongming/app/__init__.py @@ -1,3 +1,13 @@ +# import sys + +# used_modules = set() + +# def audit_hook(event, args): +# if event == "import": +# used_modules.add(args[0]) + +# sys.addaudithook(audit_hook) + import time from pathlib import Path from contextlib import asynccontextmanager @@ -25,12 +35,14 @@ cache = get_cache() logger = get_logger("app") + try: env = config["default"]["env"] except KeyError: # pragma: no cover raise ValueError("配置不存在") + @asynccontextmanager async def lifespan(app: FastAPI): """ @@ -99,6 +111,11 @@ async def lifespan(app: FastAPI): await task_service.start(init_tasks_callback) yield + # if env == "development": + # with open("actually_used_stdlib.txt", "w") as f: + # for name in sorted(used_modules): + # if not name.split('.')[0] in {"chongming", "plugins"}: + # f.write(name + "\n") await task_service.shutdown(wait=True) await engine.dispose() diff --git a/src/chongming/app/service/auth.py b/src/chongming/app/service/auth.py index ff0e8ae..f2c8755 100644 --- a/src/chongming/app/service/auth.py +++ b/src/chongming/app/service/auth.py @@ -3,6 +3,7 @@ """ from typing import Optional +from uuid import UUID from fastapi import Request import bcrypt from sqlalchemy.ext.asyncio import AsyncSession @@ -162,7 +163,7 @@ class AuthService: user_id = validation["user_id"] # 获取用户 - user = await UserService.get_user_by_id(user_id, session) + user = await UserService.get_user_by_id(UUID(user_id), session) if not user: return None diff --git a/src/chongming/scripts/build.py b/src/chongming/scripts/build.py index c3170dc..0fd73a7 100644 --- a/src/chongming/scripts/build.py +++ b/src/chongming/scripts/build.py @@ -1,456 +1,497 @@ -import os +""" +构建脚本 - 将 chongming 应用打包为可执行文件 + +功能阶段: + 1. 模块打包(module-bank): 加密打包 Python 模块 + 2. 资源准备: 创建 build 目录, 复制必要文件 + 3. 混淆与打包: pyarmor 混淆 + PyInstaller/Nuitka 打包 + 4. 数据库初始化: 创建表结构和默认数据 + 5. 前端构建: 编译 Vue 项目并打包为 VFS + +用法: + python -m chongming.scripts.build [pyinstaller|nuitka] +""" + import platform import shutil import subprocess import sys import asyncio +import time +from dataclasses import dataclass, field from pathlib import Path from module_bank import PythonToSQLite from module_bank.encryption import Encryption from sqlite_vfs.core import SQLiteVFS from sqlite_vfs.folder_packer import FolderPacker -from ..app.core.config import get_config +from ..app.core.config import get_config, Config +# ── 常量 ──────────────────────────────────────────────────────────────────── PYINSTALLER = "pyinstaller" NUITKA = "nuitka" +MODES = (PYINSTALLER, NUITKA) is_windows = platform.system() == "Windows" -build_mode = PYINSTALLER -if len(sys.argv) == 2: - build_mode = sys.argv[1] -if build_mode not in [PYINSTALLER, NUITKA]: - raise ValueError(f"不支持{build_mode}打包模式") +# ── 构建结果数据结构 ───────────────────────────────────────────────────────── +@dataclass +class BuildStepResult: + """单个构建步骤的执行结果""" + name: str # 步骤名称 + success: bool # 是否成功 + duration: float # 耗时(秒) + message: str = "" # 附加信息(如包大小) -def build_vue_web(): - """ - 打包 Vue Web 前端项目 +@dataclass +class BuildReport: + """构建总结报告""" + mode: str + start_time: float + end_time: float = 0.0 + steps: list[BuildStepResult] = field(default_factory=list) - 该函数执行以下操作: - 1. 运行 npm build 命令构建 Vue 项目 - 2. 将构建产物打包到 SQLite VFS 虚拟文件系统中 + @property + def total_duration(self) -> float: + return self.end_time - self.start_time if self.end_time else 0.0 - Returns: - None: 无返回值,通过打印信息反馈执行结果 - """ - # 构建 Vue Web 命令 - dist_dir = "./src/chongming-web/dist" - output_db = "./build/static.svfs" - fs_name = "frontend" - cmd = [ - "npm", - "run", - "build", - ] - result = subprocess.run( + @property + def all_success(self) -> bool: + return all(s.success for s in self.steps) + + def add_step(self, name: str, success: bool, duration: float, message: str = ""): + self.steps.append(BuildStepResult(name, success, duration, message)) + + def print_summary(self): + """打印结构化的构建总结""" + sep = "=" * 60 + print(f"\n{sep}") + print(f" 🏗️ CHONGMING 构建报告") + print(f"{sep}") + print(f" 构建模式 : {self.mode}") + print(f" 总耗时 : {self.total_duration:.2f}s") + print(f" 构建状态 : {'✅ 成功' if self.all_success else '❌ 失败'}") + print(f"{sep}") + print(f" 步骤明细:") + for i, step in enumerate(self.steps, 1): + icon = "✅" if step.success else "❌" + msg = f" ({step.message})" if step.message else "" + print(f" {icon} [{i}/{len(self.steps)}] {step.name:<20s} {step.duration:>7.2f}s{msg}") + print(f"{sep}\n") + +# ── 工具函数 ──────────────────────────────────────────────────────────────── + +def _run_cmd( + cmd: list[str], + cwd: str | None = None, + capture: bool = True, + shell: bool | None = None, +) -> subprocess.CompletedProcess: + """统一执行外部命令""" + if shell is None: + shell = is_windows + return subprocess.run( cmd, - cwd="./src/chongming-web", - capture_output=True, + cwd=cwd, + capture_output=capture, text=True, - shell=is_windows, + shell=shell, encoding="utf-8", errors="replace", ) - if result.returncode != 0: - print("打包 Vue Web 失败") - print("标准输出:", result.stdout) - print("错误输出:", result.stderr) - return - else: - print("Vue Web 打包成功") - if result.stdout: - print(result.stdout) - # 检查 dist 目录是否存在 - if not os.path.exists(dist_dir): - print(f"错误: dist 目录不存在: {dist_dir}") - return +def _format_size(path: str | Path) -> str: + """获取文件可读大小""" + size = Path(path).stat().st_size + for unit in ("B", "KB", "MB", "GB"): + if size < 1024: + return f"{size:.2f} {unit}" + size /= 1024.0 + return f"{size:.2f} TB" - vfs = SQLiteVFS(output_db, compress=False) - packer = FolderPacker( - sqlite_vfs=vfs, - exclude_patterns=[], - fs_name=fs_name, - ) +def _ensure_dir(path: str | Path): + """确保目录存在""" + Path(path).mkdir(parents=True, exist_ok=True) - try: - packer.pack_folder(dist_dir) - print(f"✅ 打包完成,文件保存为 {output_db}") - finally: - vfs.close() +def _resolve_mode() -> str: + """解析构建模式""" + mode = PYINSTALLER + if len(sys.argv) >= 2: + mode = sys.argv[1].lower() + if mode not in MODES: + print(f" ❌ 不支持的构建模式: {mode},可选: {', '.join(MODES)}") + sys.exit(1) + return mode +# ── 阶段 1: 模块打包 (module-bank) ────────────────────────────────────────── -def run_pyarmor_obfuscate(): +def _stage_pack_modules(config: Config) -> str: """ - 使用 PyArmor 对关键 Python 文件进行代码混淆 - - 该函数遍历预定义的文件列表,对每个文件执行 pyarmor gen 命令进行混淆处理, - 以保护源代码不被轻易反编译。 - - 处理的文件包括: - - main.py (主入口文件) - - server.py (服务器文件) - - utils/launch.py (启动工具) - - utils/config.py (配置工具) - - Returns: - None: 无返回值,遇到错误时提前返回 + 使用 module-bank 加密打包 Python 模块为 .mbank 文件。 + 返回打包过程中的提示信息。 """ - # pyarmor obfuscate --output=obfuscated_script.py original_script.py - obfuscate_files = [ + _ensure_dir("resources") + mbank_list = config.get("production", {}).get("module_system", {}).get("path", []) + if not mbank_list: + return "⚠️ 未配置 module_system.path,跳过模块打包" + + messages = [] + for output_path, source_dir, _ in mbank_list: + key = Encryption.generate_key() + packer = PythonToSQLite( + output_path, + key, + pyarmor_obfuscate=True, + ) + packer.pack_directory(source_dir, "app" if "app" in source_dir else "plugins") + packer.verify_package_structure() + packer.packer_key(key) + packer.delete_source_code(None) + size = _format_size(output_path) + messages.append(f"{Path(output_path).name} ({size})") + return " - " + "\n - ".join(messages) + +def _stage_prepare_build_dir(config: Config, mode: str) -> str: + """准备 build 目录并复制必要文件""" + build_path = Path("build") + if build_path.exists(): + shutil.rmtree(str(build_path)) + _ensure_dir("build") + + # 复制 utils + utils_src = Path("public/utils") + if utils_src.exists(): + shutil.copytree(utils_src, build_path / "utils") + + # 复制 resources + resources_src = Path("resources") + if resources_src.exists(): + shutil.copytree(resources_src, build_path / "resources") + + # 复制核心文件 + for f in ("main.py", "server.py", "config.toml"): + shutil.copy(f"public/{f}", f"build/{f}") + + # 注入 JWT 密钥 + jwt_key = Encryption.generate_key() + cfg_path = build_path / "config.toml" + content = cfg_path.read_text(encoding="utf-8") + content = content.replace("your-secret-key-change-in-production", jwt_key) + cfg_path.write_text(content, encoding="utf-8") + + return "资源准备完成" + +# ── 阶段 3: PyArmor 混淆 ─────────────────────────────────────────────────── + +def _stage_pyarmor_obfuscate() -> str: + """对关键脚本执行 PyArmor 混淆""" + files = [ ("main.py", "./"), ("server.py", "./"), ("utils/launch.py", "./utils"), ("utils/config.py", "./utils"), ] - for file, output in obfuscate_files: - cmd = [ - "pyarmor", - "gen", - f"--output={output}", - file, - ] - result = subprocess.run(cmd, cwd="./build", capture_output=True, text=True) + failed = [] + for filename, output in files: + result = _run_cmd( + ["pyarmor", "gen", f"--output={output}", filename], + cwd="build", + ) if result.returncode != 0: - print(f"打包文件: {file} 失败") - print(result.stderr) - return - else: - print(result.stdout) - print(f"打包文件: {file}") + failed.append(filename) + if failed: + return f"❌ 混淆失败: {', '.join(failed)}" + return "✅ 所有文件混淆成功" -def run_nuitka(): - """ - 兼容旧版 Nuitka 的打包函数(不使用任何可能不支持的参数) - 编译后手动 strip + upx 压缩 - """ - import shutil +# ── 阶段 4: PyInstaller 打包 ──────────────────────────────────────────────── - config = get_config() - build_import_base = config \ - .get("pyinstaller_build_import", {}) \ - .get("base", []) - linux_extra = config \ - .get("pyinstaller_build_import", {}) \ - .get("linux", []) - windows_extra = config \ - .get("pyinstaller_build_import", {}) \ - .get("windows", []) +def _stage_pyinstaller(config: Config) -> tuple[str, str]: + """执行 PyInstaller 打包,返回 (状态消息, 可执行文件路径)""" + base_imports = config.get("pyinstaller_build_import", {}) + build_imports = list(base_imports.get("base", [])) + build_imports.extend( + base_imports.get("windows", []) if is_windows + else base_imports.get("linux", []) + ) - all_imports = list(build_import_base) + icon = Path("../../public/chongming.ico") + cmd = [ + sys.executable, "-m", "PyInstaller", + "--onefile", "--noconfirm", + "--optimize", "2", "--strip", + "--name", "chongming", + "--distpath", ".", + "--workpath", "./build_temp", + "--specpath", "./specs", + "--clean", + ] + if icon.exists(): + cmd.extend(["--icon", str(icon)]) + for imp in build_imports: + cmd.extend(["--hidden-import", imp]) + cmd.append("main.py") + + result = _run_cmd(cmd, cwd="build", capture=True) + if result.returncode != 0: + return f"❌ PyInstaller 失败", "" + + exe_path = Path("build/chongming") if is_windows: - all_imports.extend(windows_extra) - else: - all_imports.extend(linux_extra) + exe_path = exe_path.with_suffix(".exe") + + suffix = ".exe" if is_windows else "" + exe = Path("build") / f"chongming{suffix}" + if exe.exists(): + size = _format_size(exe) + return f"✅ PyInstaller 打包成功 ({size})", str(exe) + return "✅ PyInstaller 打包完成 (但未找到可执行文件)", "" + +# ── 阶段 5: Nuitka 打包 ──────────────────────────────────────────────────── + +def _stage_nuitka(config: Config) -> tuple[str, str]: + """执行 Nuitka 打包,返回 (状态消息, 可执行文件路径)""" + base_imports = config.get("pyinstaller_build_import", {}).get("base", []) + extra = config.get("pyinstaller_build_import", {}).get( + "windows" if is_windows else "linux", [] + ) packages = [ "module_bank", "sqlite_vfs", "fastapi", "uvicorn", "gunicorn", "diskcache", "bcrypt", "apscheduler", "aiofiles", "aiosqlite", - "sqlmodel", "jwt", "python_multipart", "tomli", "typing_extensions" + "sqlmodel", "jwt", "python_multipart", "tomli", "typing_extensions", ] - # 最基础的参数(兼容所有 Nuitka 版本) cmd = [ sys.executable, "-m", "nuitka", - "--standalone", - "--onefile", + "--standalone", "--onefile", "--follow-imports", "--static-libpython=no", "--remove-output", "--assume-yes-for-downloads", + "--lto=yes", + "--disable-plugin=tk-inter", + "--disable-plugin=numpy", + "--output-dir=.", "--output-filename=chongming", + "main.py", + "--jobs=2", ] - - # 尝试添加 LTO(如果不支持,会在此阶段失败,我们在下方会用 try/except 回退) - cmd.append("--lto=yes") - - # 尝试启用 UPX 插件(旧版可能不支持) if shutil.which("upx"): cmd.append("--enable-plugin=upx") - for pkg in packages: cmd.append(f"--include-package={pkg}") - - for mod in all_imports: + for mod in list(base_imports) + extra: cmd.append(f"--include-module={mod}") - cmd.append("--disable-plugin=tk-inter") - cmd.append("--disable-plugin=numpy") - cmd += ["--output-dir=.", "--output-filename=chongming", "main.py"] - cmd.append("--jobs=2") - if is_windows: - icon_path = Path("../../public/chongming.ico") - if icon_path.exists(): - cmd.append(f"--windows-icon-from-ico={icon_path}") + icon = Path("../../public/chongming.ico") + if icon.exists(): + cmd.append(f"--windows-icon-from-ico={icon}") - print("正在运行 Nuitka 编译...") try: - subprocess.run(cmd, cwd="./build", check=True, capture_output=False, text=True) + _run_cmd(cmd, cwd="build", capture=False) except subprocess.CalledProcessError: - # 如果因为 --lto=yes 或 --enable-plugin=upx 失败,则用更基础的命令重试 - print("检测到参数不支持,移除 --lto=yes 和 --enable-plugin=upx 重新编译...") - fallback_cmd = [arg for arg in cmd if arg not in ("--lto=yes", "--enable-plugin=upx")] - subprocess.run(fallback_cmd, cwd="./build", check=True, capture_output=False, text=True) - - print("Nuitka 编译完成!开始后处理瘦身...") - - exe_path = Path("./build/chongming") - if not is_windows and exe_path.exists(): - # 手动 strip 去掉符号表 - subprocess.run(["strip", str(exe_path)], check=False) - print("已执行 strip,去除调试符号") - if shutil.which("upx") and exe_path.exists(): - # 手动 UPX 极限压缩 - subprocess.run(["upx", "--best", str(exe_path)], check=False) - print("已执行 UPX 压缩") - - # 清理临时入口文件 - shutil.rmtree("build/utils", ignore_errors=True) - os.remove("build/main.py") - os.remove("build/server.py") - -def run_pyinstaller(): - """ - 使用 PyInstaller 将应用打包成单文件可执行程序 - - 该函数根据操作系统平台(Windows/非Windows)构建不同的 PyInstaller 命令, - 包含所有必要的隐藏导入以确保依赖库被正确打包。 - - Windows 和非 Windows 平台的主要区别: - - 非 Windows 平台额外包含 gunicorn 相关模块 - - Returns: - None: 无返回值,通过打印信息反馈执行结果 - """ - config = get_config() - try: - # 构建 PyInstaller 命令 - build_import_base = config \ - .get("pyinstaller_build_import", {}) \ - .get("base", []) - cmd = [ - sys.executable, - "-m", - "PyInstaller", - "--onefile", - "--noconfirm", - "--optimize", - "2", - "--strip", - "--name", - "chongming", - "--distpath", - ".", - "--workpath", - "./build_temp", - "--specpath", - "./specs", - "--clean", - # 其他选项 - "--icon", - "../../public/chongming.ico", - "main.py", + # 降级重试(移除可能不兼容的参数) + fallback = [ + arg for arg in cmd + if arg not in ("--lto=yes", "--enable-plugin=upx") ] - if is_windows: - windows_build_import = config \ - .get("pyinstaller_build_import", {}) \ - .get("windows", []) + build_import_base + _run_cmd(fallback, cwd="build", capture=False) - for build_import in windows_build_import: - cmd.append("--hidden-import") - cmd.append(build_import) - else: - linux_build_import = config \ - .get("pyinstaller_build_import", {}) \ - .get("linux", []) + build_import_base - for build_import in linux_build_import: - cmd.append("--hidden-import") - cmd.append(build_import) + exe = Path("build/chongming") + if exe.exists(): + # 后处理瘦身 + if not is_windows: + subprocess.run(["strip", str(exe)], check=False) + if shutil.which("upx"): + subprocess.run(["upx", "--best", str(exe)], check=False) + size = _format_size(exe) + return f"✅ Nuitka 编译完成 ({size})", str(exe) - print(f"正在运行 PyInstaller: {' '.join(cmd)}") - result = subprocess.run(cmd, cwd="./build", capture_output=True, text=True) + return "❌ Nuitka 编译失败 (未生成可执行文件)", "" - if result.returncode == 0: - print("PyInstaller 打包成功!") - print(result.stdout) - else: - print("PyInstaller 打包失败!") - print("错误信息:", result.stderr) +# ── 阶段 6: 清理临时文件 ──────────────────────────────────────────────────── - except FileNotFoundError: - print("错误:未找到 PyInstaller,请先安装 PyInstaller:pip install pyinstaller") - except Exception as e: - print(f"PyInstaller 执行出错: {e}") +def _stage_cleanup(mode: str) -> str: + """清理构建过程中的临时文件""" + build = Path("build") + dirs_to_remove = [ + "build_temp", "specs", "utils", + "pyarmor_runtime_000000", + "__pycache__", + ] + files_to_remove = ["main.py", "server.py"] + for d in dirs_to_remove: + target = build / d + if target.exists(): + shutil.rmtree(str(target), ignore_errors=True) -async def init_database(): - """ - 异步初始化数据库 + for f in files_to_remove: + target = build / f + if target.exists(): + target.unlink(missing_ok=True) - 该函数执行以下操作: - 1. 从配置中读取数据库连接信息 - 2. 创建异步数据库引擎和会话工厂 - 3. 设置 SQLite 优化参数(WAL 模式和同步策略) - 4. 创建所有 SQLModel 定义的数据库表 - 5. 初始化权限系统和默认管理员账户 + return "临时文件已清理" - Raises: - ValueError: 当数据库配置不存在时抛出 - OperationalError: 当数据库操作出现异常时抛出(已存在表的情况除外) +# ── 阶段 7: 数据库初始化 ──────────────────────────────────────────────────── - Returns: - None: 无返回值 - """ +async def _stage_init_database(): + """初始化数据库表结构和默认数据""" from sqlalchemy.ext.asyncio import create_async_engine from sqlmodel import SQLModel, text from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - - from ..app.task.dev_init_db import init_permission, init_default_roles, dev_init_admin + from ..app.task.dev_init_db import ( + init_permission, + init_default_roles, + dev_init_admin, + ) database = get_config()["database"] - database_type = database["type"] - database_config = database.get(database_type, None) - if database_config is None: - raise ValueError("配置不存在") - os.makedirs("./build/data", exist_ok=True) + db_type = database["type"] + db_config = database.get(db_type, {}) + if db_config is None: + return "❌ 数据库配置不存在" + + _ensure_dir("build/data") engine = create_async_engine( - "sqlite+aiosqlite:///./build/data/database.db", **database_config - ) - async_session_maker = async_sessionmaker( - engine, class_=AsyncSession, expire_on_commit=False + "sqlite+aiosqlite:///./build/data/database.db", + **db_config, ) + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with engine.begin() as conn: - if database_type == "sqlite": + if db_type == "sqlite": await conn.execute(text("PRAGMA journal_mode=WAL")) await conn.execute(text("PRAGMA synchronous=NORMAL")) try: await conn.run_sync( - lambda conn: SQLModel.metadata.create_all(bind=conn, checkfirst=True) + lambda c: SQLModel.metadata.create_all(bind=c, checkfirst=True) ) - except OperationalError as e: - if "already exists" in str(e): + if "already exists" not in str(e): raise - # 添加管理员用户 - async with async_session_maker() as session: - # await UserService.create_user(session, "admin", "admin", is_superuser=True) # type: ignore + + async with session_maker() as session: await init_permission(session) - # 再初始化默认角色及权限关联 await init_default_roles(session) - # 最后初始化管理员用户 await dev_init_admin(session) + db_size = _format_size("build/data/database.db") + return f"✅ 数据库初始化完成 ({db_size})" + +# ── 阶段 8: Vue 前端构建 ──────────────────────────────────────────────────── + +def _stage_build_vue() -> str: + """构建 Vue 前端并打包为 SQLite VFS""" + dist_dir = "./src/chongming-web/dist" + output_db = "./build/static.svfs" + fs_name = "frontend" + + # 1. npm build + result = _run_cmd( + ["npm", "run", "build"], + cwd="./src/chongming-web", + capture=True, + ) + if result.returncode != 0: + err = (result.stderr or "").strip()[:200] + return f"❌ Vue 构建失败: {err}" + + # 2. 检查产物 + if not Path(dist_dir).exists(): + return "❌ Vue 构建成功但未找到 dist 目录" + + # 3. 打包为 VFS + vfs = SQLiteVFS(output_db, compress=False) + packer = FolderPacker(sqlite_vfs=vfs, exclude_patterns=[], fs_name=fs_name) + try: + packer.pack_folder(dist_dir) + finally: + vfs.close() + + size = _format_size(output_db) + return f"✅ Vue 打包完成 ({size})" + +# ── 主流程 ────────────────────────────────────────────────────────────────── async def main(): - """ - 主构建流程函数 + """规范化构建主流程""" + mode = _resolve_mode() + report = BuildReport(mode=mode, start_time=time.time()) + config = get_config() - 该函数协调整个应用的构建过程,按顺序执行以下步骤: - 1. 加密打包 Python 模块(app 和 plugins)到 SQLite 数据库文件 - 2. 创建并准备 build 目录结构 - 3. 复制必要的源文件和配置文件到 build 目录 - 4. 注入动态生成的加密密钥到配置文件 - 5. 对关键文件进行代码混淆 - 6. 使用 PyInstaller 打包成可执行文件 - 7. 清理临时构建文件 - 8. 初始化数据库 - 9. 构建 Vue 前端项目 + async def run_step(name: str, fn, *args, **kwargs) -> str: + """执行一个构建步骤并记录结果(支持同步和异步函数)""" + start = time.time() + try: + result = fn(*args, **kwargs) + if asyncio.iscoroutine(result): + msg = await result + else: + msg = result + elapsed = time.time() - start + report.add_step(name, success=True, duration=elapsed, message=msg or "") + print(f" ✅ {name:<25s} {elapsed:>7.2f}s {msg}") + return msg + except Exception as e: + elapsed = time.time() - start + err_msg = str(e).strip()[:200] + report.add_step(name, success=False, duration=elapsed, message=err_msg) + print(f" ❌ {name:<25s} {elapsed:>7.2f}s {err_msg}") + return "" - Returns: - None: 无返回值 - """ - # 打包模块 - resources_path = Path("resources") - if not resources_path.exists(): - resources_path.mkdir() + print("\n" + "=" * 60) + print(" 🏗️ CHONGMING 构建开始") + print(f" 模式: {mode} | 平台: {platform.system()}") + print("=" * 60 + "\n") - mbank_list = get_config()["production"].get("module_system", {}).get("path", []) - for mbank in mbank_list: - key = Encryption.generate_key() - app_packer = PythonToSQLite(mbank[0], key) - app_packer.pack_directory(mbank[1], mbank[2]) - app_packer.verify_package_structure() - app_packer.packer_key(key) - app_packer.delete_source_code(None) + # 阶段 1: 模块打包 + await run_step("模块打包(module-bank)", _stage_pack_modules, config) - # 创建 build 目录 - build_path = Path("build") - if build_path.exists() and build_path.is_dir(): - shutil.rmtree("build") + # 阶段 2: 准备构建目录 + await run_step("准备构建目录", _stage_prepare_build_dir, config, mode) - os.makedirs("build", exist_ok=True) + # 阶段 3+4: 混淆 + 打包 + if mode == PYINSTALLER: + await run_step("PyArmor 混淆", _stage_pyarmor_obfuscate) + start = time.time() + try: + msg, exe_path = _stage_pyinstaller(config) + elapsed = time.time() - start + success = "❌" not in msg + report.add_step("PyInstaller 打包", success, elapsed, msg.replace("✅ PyInstaller ", "")) + print(f" {'✅' if success else '❌'} PyInstaller 打包{' ' * 12} {elapsed:>7.2f}s {msg}") + except Exception as e: + elapsed = time.time() - start + report.add_step("PyInstaller 打包", False, elapsed, str(e)[:200]) + print(f" ❌ PyInstaller 打包 {elapsed:>7.2f}s {str(e)[:200]}") + else: + await run_step("Nuitka 编译", lambda: _stage_nuitka(config)[0]) - # 复制 utils 文件夹到 build 文件夹 - utils_path = Path("utils") - if utils_path.exists(): - target_utils_path = Path("build") / "utils" - shutil.copytree(utils_path, target_utils_path, dirs_exist_ok=True) - print(f"已复制 utils 文件夹到 {target_utils_path}") + # 阶段 5: 清理 + await run_step("清理临时文件", _stage_cleanup, mode) - # 复制 resources 文件夹到 build 文件夹 - resources_path = Path("resources") - if resources_path.exists(): - target_resources_path = Path("build") / "resources" - shutil.copytree(resources_path, target_resources_path, dirs_exist_ok=True) - print(f"已复制 resources 文件夹到 {target_resources_path}") + # 阶段 6: 数据库初始化 + await run_step("初始化数据库", _stage_init_database) - print("模块打包完成") + # 阶段 7: Vue 前端构建 + await run_step("Vue 前端构建", _stage_build_vue) - # 复制 main.py 到 build 文件夹 - shutil.copy("public/main.py", "build") - shutil.copy("public/server.py", "build") - # 复制 config.toml 到 build 文件夹 - shutil.copy("public/config.toml", "build") - # 复制 utils 文件夹到 build 文件夹 - shutil.copytree("public/utils", "build/utils") + # 总结 + report.end_time = time.time() + report.print_summary() - jwt_key = Encryption.generate_key() - with open("./build/config.toml", "r", encoding="utf-8") as f: - context = f.read() - context = context.replace("your-secret-key-change-in-production", jwt_key) - with open("build/config.toml", "w", encoding="utf-8") as f: - f.write(context) - - if build_mode == PYINSTALLER: - # 混淆 - run_pyarmor_obfuscate() - # 运行 PyInstaller - run_pyinstaller() - # 清理临时文件 - shutil.rmtree(r"build/build_temp", ignore_errors=True) - shutil.rmtree(r"build/specs", ignore_errors=True) - shutil.rmtree(r"build/utils", ignore_errors=True) - shutil.rmtree(r"build/pyarmor_runtime_000000", ignore_errors=True) - os.remove(r"build/main.py") - os.remove(r"build/server.py") - elif build_mode == NUITKA: - run_nuitka() - # Nuitka 编译后清理临时文件 - shutil.rmtree(r"build/utils", ignore_errors=True) - # os.remove(r"build/main.py") - # os.remove(r"build/server.py") - - print("构建完成") - - # 初始化数据库 - await init_database() - - # 构建 vue 项目 - build_vue_web() + if not report.all_success: + sys.exit(1) def build(): - """ - 构建入口函数 - - 该函数作为整个构建流程的入口点,通过 asyncio.run() 启动异步主构建流程。 - - Returns: - None: 无返回值 - """ + """构建入口""" asyncio.run(main()) + + +if __name__ == "__main__": + build() diff --git a/src/chongming/scripts/code_content.py b/src/chongming/scripts/code_content.py index 164f79d..8b2284e 100644 --- a/src/chongming/scripts/code_content.py +++ b/src/chongming/scripts/code_content.py @@ -28,6 +28,8 @@ def generate_markdown_from_py_files(directory, output_file): file_path = os.path.join(root, file) if file == "output.md" or file == "package-lock.json": continue + if "generated" in file_path: + continue md_file.write(f"`{file_path}`\n") md_file.write("```\n") with open(file_path, "r", encoding="utf-8") as py_file: diff --git a/uv.lock b/uv.lock index c3cca52..ec5e039 100644 --- a/uv.lock +++ b/uv.lock @@ -471,7 +471,7 @@ requires-dist = [ { name = "diskcache" }, { name = "fastapi", extras = ["uvicorn"] }, { name = "gunicorn" }, - { name = "module-bank", specifier = "==0.2.4" }, + { name = "module-bank", specifier = "==0.3.1" }, { name = "pyjwt" }, { name = "python-multipart" }, { name = "sqlite-vfs" }, @@ -938,14 +938,14 @@ wheels = [ [[package]] name = "module-bank" -version = "0.2.4" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "pyarmor" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/e7/417d918069be3bcf74f2e37f93195ebd4e9e975e1ec690dcd04b902a8d74/module_bank-0.2.4.tar.gz", hash = "sha256:074cfed70a391762d5a2a560b581826fb599d63bee899c46ab03d3f221acdccf", size = 68693, upload-time = "2026-05-01T15:32:24.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/ba/480feb93002b8b0139c61357f4cea4c75fb478b5e4095f1ad2421e83b033/module_bank-0.2.4-py3-none-any.whl", hash = "sha256:255ac8b5378b2c3073eb3fe805c67e9ce759352d407deebf11bfa1277eceb62a", size = 12415, upload-time = "2026-05-01T15:32:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/31/f8/81015ad652132bccc33089c81e6f45ff8537e68cffefe789d213f7d45ca5/module_bank-0.3.1-py3-none-any.whl", hash = "sha256:29f32ec55b4effe5ccd8e6414e80e92a1c8584cfe90f731b3677e389c3af448b", size = 19037, upload-time = "2026-05-04T11:58:33.114Z" }, ] [[package]]