Files

222 lines
8.7 KiB
Python
Raw Permalink Normal View History

2026-07-09 09:25:04 +00:00
import os
import shutil
2026-07-09 09:25:04 +00:00
import sqlite3
import subprocess
import tempfile
import time
2026-07-09 09:25:04 +00:00
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
2026-07-09 09:25:04 +00:00
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
2026-07-09 09:25:04 +00:00
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
2026-07-09 09:25:04 +00:00
SERVER_HOST = settings.server_host
SERVER_PORT = settings.server_port
2026-07-09 09:25:04 +00:00
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN")
MINIO_DATA_DIR = configured_path("MINIO_DATA_DIR", "minio_data")
tempfile.tempdir = str(UPLOAD_SPOOL_DIR)
2026-07-09 09:25:04 +00:00
@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 ''")
2026-07-09 09:25:04 +00:00
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),
],
)
2026-07-09 09:25:04 +00:00
conn.commit()
print("✅ 成功执行tables.sql,创建数据表与测试数据")
2026-07-09 09:25:04 +00:00
else:
print("❌ 错误:当前目录找不到 tables.sql 文件!")
2026-07-09 09:25:04 +00:00
conn.close()
print("===== 数据库初始化完成 =====")
ensure_storage_dirs()
ensure_default_admin_user()
2026-07-09 09:25:04 +00:00
start_minio_if_needed()
yield
print("服务进程正常退出")
2026-07-09 09:25:04 +00:00
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:
2026-07-09 09:25:04 +00:00
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")
2026-07-09 09:25:04 +00:00
if not endpoint:
return
if is_minio_running(endpoint):
print(f"MinIO 已在 {endpoint} 运行,跳过启动")
return
minio_cmd = shutil.which("minio")
2026-07-09 09:25:04 +00:00
if not minio_cmd:
minio_bin = Path(os.getenv("MINIO_BIN_PATH", "minio"))
2026-07-09 09:25:04 +00:00
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)
2026-07-09 09:25:04 +00:00
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)
2026-07-09 09:25:04 +00:00
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)
2026-07-09 09:25:04 +00:00
@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",
)
2026-07-09 09:25:04 +00:00
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": "缺少设备身份凭证"}},
)
2026-07-09 09:25:04 +00:00
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
2026-07-09 09:25:04 +00:00
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1)