2026-07-09 09:25:04 +00:00
|
|
|
|
import os
|
2026-07-14 01:38:41 +00:00
|
|
|
|
import shutil
|
2026-07-09 09:25:04 +00:00
|
|
|
|
import sqlite3
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import tempfile
|
2026-07-14 01:38:41 +00:00
|
|
|
|
import time
|
2026-07-09 09:25:04 +00:00
|
|
|
|
import urllib.request
|
2026-07-14 01:38:41 +00:00
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
from pathlib import Path
|
2026-07-09 09:25:04 +00:00
|
|
|
|
|
2026-07-14 01:38:41 +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
|
|
|
|
|
2026-07-14 01:38:41 +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
|
2026-07-15 06:33:33 +00:00
|
|
|
|
from app.core.security import ensure_default_admin_user
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 01:38:41 +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")
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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()}
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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 ''")
|
2026-07-20 06:45:26 +00:00
|
|
|
|
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")
|
2026-07-15 06:33:33 +00:00
|
|
|
|
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():
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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()
|
2026-07-14 01:38:41 +00:00
|
|
|
|
print("✅ 成功执行tables.sql,创建数据表与测试数据")
|
2026-07-09 09:25:04 +00:00
|
|
|
|
else:
|
2026-07-14 01:38:41 +00:00
|
|
|
|
print("❌ 错误:当前目录找不到 tables.sql 文件!")
|
2026-07-09 09:25:04 +00:00
|
|
|
|
conn.close()
|
|
|
|
|
|
print("===== 数据库初始化完成 =====")
|
|
|
|
|
|
|
2026-07-14 01:38:41 +00:00
|
|
|
|
ensure_storage_dirs()
|
2026-07-15 06:33:33 +00:00
|
|
|
|
ensure_default_admin_user()
|
2026-07-09 09:25:04 +00:00
|
|
|
|
start_minio_if_needed()
|
|
|
|
|
|
|
|
|
|
|
|
yield
|
|
|
|
|
|
print("服务进程正常退出")
|
|
|
|
|
|
|
2026-07-14 01:38:41 +00:00
|
|
|
|
|
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)
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-14 01:38:41 +00:00
|
|
|
|
minio_cmd = shutil.which("minio")
|
2026-07-09 09:25:04 +00:00
|
|
|
|
if not minio_cmd:
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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:
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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=["*"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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
|
2026-07-14 01:38:41 +00:00
|
|
|
|
skip_paths = (
|
|
|
|
|
|
"/admin",
|
|
|
|
|
|
"/assets",
|
|
|
|
|
|
"/docs",
|
|
|
|
|
|
"/openapi.json",
|
|
|
|
|
|
"/redoc",
|
|
|
|
|
|
"/static",
|
|
|
|
|
|
"/favicon.ico",
|
|
|
|
|
|
"/platform-config.json",
|
|
|
|
|
|
"/logo.svg",
|
|
|
|
|
|
"/health",
|
2026-07-15 06:33:33 +00:00
|
|
|
|
"/login",
|
|
|
|
|
|
"/refresh-token",
|
2026-07-14 01:38:41 +00:00
|
|
|
|
"/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:
|
2026-07-14 01:38:41 +00:00
|
|
|
|
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-14 01:38:41 +00:00
|
|
|
|
|
2026-07-09 09:25:04 +00:00
|
|
|
|
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1)
|