import os import shutil import sqlite3 import subprocess import tempfile import time import urllib.request from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles import db import minio_tool from app.api.router import include_api_routes from app.core.audit import admin_audit_middleware from app.core.config import configured_path, env_bool, settings from app.core.security import ensure_default_admin_user from app.services.crash_api_service import ensure_storage_dirs from app.services.device_credential_service import verify_device_credential from app.services.publish_service import UPLOAD_SPOOL_DIR SERVER_HOST = settings.server_host SERVER_PORT = settings.server_port VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN") MINIO_DATA_DIR = configured_path("MINIO_DATA_DIR", "minio_data") tempfile.tempdir = str(UPLOAD_SPOOL_DIR) @asynccontextmanager async def lifespan(app: FastAPI): print("===== 进程启动,开始初始化数据库 =====") db_file = db.DB_FILE sql_file_path = db.SQL_FILE conn = sqlite3.connect(db_file, check_same_thread=False) conn.row_factory = sqlite3.Row cur = conn.cursor() if sql_file_path.exists(): with open(sql_file_path, "r", encoding="utf-8") as f: full_sql = f.read() cur.executescript(full_sql) version_columns = {row[1] for row in cur.execute("PRAGMA table_info(versions)").fetchall()} if "client_protocol" not in version_columns: cur.execute("ALTER TABLE versions ADD COLUMN client_protocol INTEGER NOT NULL DEFAULT 1") device_columns = {row[1] for row in cur.execute("PRAGMA table_info(devices)").fetchall()} if "license_id" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN license_id TEXT NOT NULL DEFAULT ''") if "channel" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN channel TEXT NOT NULL DEFAULT 'stable'") license_columns = {row[1] for row in cur.execute("PRAGMA table_info(licenses)").fetchall()} if "license_key_cipher" not in license_columns: cur.execute("ALTER TABLE licenses ADD COLUMN license_key_cipher TEXT NOT NULL DEFAULT ''") policy_columns = {row[1] for row in cur.execute("PRAGMA table_info(version_policies)").fetchall()} if "git_tags_enabled" not in policy_columns: cur.execute("ALTER TABLE version_policies ADD COLUMN git_tags_enabled INTEGER NOT NULL DEFAULT 0") audit_columns = {row[1] for row in cur.execute("PRAGMA table_info(admin_audit_logs)").fetchall()} if "actor_username" not in audit_columns: cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN actor_username TEXT NOT NULL DEFAULT ''") if "actor_roles" not in audit_columns: cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN actor_roles TEXT NOT NULL DEFAULT ''") if "auth_type" not in audit_columns: cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN auth_type TEXT NOT NULL DEFAULT ''") for app_row in cur.execute("SELECT app_id FROM apps").fetchall(): if not cur.execute("SELECT 1 FROM channels WHERE app_id=? LIMIT 1", (app_row[0],)).fetchone(): cur.executemany( "INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)", [ (app_row[0], "stable", "正式版", 1, 10), (app_row[0], "preview", "预览版", 1, 20), (app_row[0], "dev", "开发版", 1, 30), ], ) conn.commit() print("✅ 成功执行tables.sql,创建数据表与测试数据") else: print("❌ 错误:当前目录找不到 tables.sql 文件!") conn.close() print("===== 数据库初始化完成 =====") ensure_storage_dirs() ensure_default_admin_user() start_minio_if_needed() yield print("服务进程正常退出") def is_minio_running(endpoint: str) -> bool: try: if endpoint.startswith("http://") or endpoint.startswith("https://"): base_url = endpoint.rstrip("/") else: scheme = "https" if env_bool("MINIO_SECURE") else "http" base_url = f"{scheme}://{endpoint.rstrip('/')}" health_url = base_url + "/minio/health/live" with urllib.request.urlopen(health_url, timeout=float(os.getenv("MINIO_HEALTH_TIMEOUT_SEC", "2"))) as resp: return resp.status == 200 except Exception: return False def download_minio_binary(target_path: Path) -> str | None: url = os.getenv("MINIO_DOWNLOAD_URL", "https://dl.min.io/server/minio/release/linux-amd64/minio") try: print(f"MinIO 二进制未找到,尝试下载到 {target_path}") target_path.parent.mkdir(parents=True, exist_ok=True) with urllib.request.urlopen(url, timeout=30) as response, open(target_path, "wb") as out_file: out_file.write(response.read()) target_path.chmod(0o755) abs_path = str(target_path.resolve()) print(f"已下载 MinIO 二进制到 {abs_path}") return abs_path except Exception as err: print(f"自动下载 MinIO 失败:{err}") return None def start_minio_if_needed(): if not env_bool("MINIO_AUTO_START", True): return endpoint = os.getenv("MINIO_ENDPOINT") if not endpoint: return if is_minio_running(endpoint): print(f"MinIO 已在 {endpoint} 运行,跳过启动") return minio_cmd = shutil.which("minio") if not minio_cmd: minio_bin = Path(os.getenv("MINIO_BIN_PATH", "minio")) minio_cmd = str(minio_bin.resolve()) if minio_bin.exists() else None if not minio_cmd and env_bool("MINIO_AUTO_DOWNLOAD", False): minio_cmd = download_minio_binary(minio_bin) if not minio_cmd: print("MinIO 二进制未找到,且未启用或未完成自动下载") return data_dir = MINIO_DATA_DIR data_dir.mkdir(parents=True, exist_ok=True) minio_address = os.getenv("MINIO_LISTEN_ADDRESS", ":9000") print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address {minio_address}") try: subprocess.Popen([minio_cmd, "server", str(data_dir), "--address", minio_address], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) time.sleep(float(os.getenv("MINIO_START_WAIT_SEC", "5"))) if is_minio_running(endpoint): print("MinIO 已启动成功") else: print("MinIO 启动失败,仍然无法连接") except Exception as err: print(f"启动 MinIO 失败:{err}") app = FastAPI(title=settings.service_title, lifespan=lifespan) include_api_routes(app) app.mount("/static", StaticFiles(directory=str(minio_tool.LOCAL_UPLOAD_ROOT)), name="static") cors_origins = [item.strip() for item in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if item.strip()] app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.middleware("http")(admin_audit_middleware) @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path skip_paths = ( "/admin", "/assets", "/docs", "/openapi.json", "/redoc", "/static", "/favicon.ico", "/platform-config.json", "/logo.svg", "/health", "/login", "/refresh-token", "/api/v1/health", "/api/v1/crash-reports", "/api/v1/symbols", ) if path == "/" or path.startswith(skip_paths): response = await call_next(request) return response client_token = request.headers.get("X-Client-Token") if client_token != VALID_CLIENT_TOKEN: return JSONResponse(status_code=401, content={"detail": "非法客户端,令牌校验失败"}) if path != "/api/v1/device/issue": credential = request.headers.get("X-Device-Credential", "") if not credential: return JSONResponse( status_code=401, content={"detail": {"error": "device_credential_required", "msg": "缺少设备身份凭证"}}, ) try: request.state.device_identity = verify_device_credential(credential) except HTTPException as exc: return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) response = await call_next(request) return response if __name__ == "__main__": import uvicorn uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1)