This commit is contained in:
h z
2026-02-12 15:45:48 +00:00
commit 343a4b8d67
34 changed files with 2321 additions and 0 deletions

192
api/setup.py Normal file
View File

@@ -0,0 +1,192 @@
import os
from typing import Optional
from urllib.parse import quote_plus
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from services.config_service import ConfigService
router = APIRouter(prefix="/api/setup", tags=["setup"])
PASSWORD_PLACEHOLDER = "********"
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class DatabaseConfig(BaseModel):
host: str
port: int = 3306
user: str = "root"
password: str = ""
database: str = "dialectica"
class KeycloakConfig(BaseModel):
host: str = ""
realm: str = ""
client_id: str = ""
class TlsConfig(BaseModel):
cert_path: str = ""
key_path: str = ""
force_https: bool = False
class FullConfig(BaseModel):
database: Optional[DatabaseConfig] = None
keycloak: Optional[KeycloakConfig] = None
tls: Optional[TlsConfig] = None
# ---------------------------------------------------------------------------
# Access control dependency
# ---------------------------------------------------------------------------
async def setup_guard(request: Request):
"""Three-phase access control for setup routes.
1. Not initialized → only localhost allowed
2. ENV_MODE=dev → open
3. ENV_MODE=prod → Keycloak admin JWT required
"""
config = ConfigService.load()
initialized = config.get("initialized", False)
env_mode = os.getenv("ENV_MODE", "dev")
if env_mode == "dev":
return # dev mode: no auth needed, even before initialisation
if not initialized:
# prod + not initialised: only localhost may configure
client_ip = request.client.host
if client_ip not in ("127.0.0.1", "::1"):
raise HTTPException(
status_code=403,
detail="初次设置仅允许从本机访问",
)
return
# prod → delegate to Keycloak middleware (Phase 3)
from app.middleware.auth import require_admin
await require_admin(request, config)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/status")
async def setup_status():
"""Return current system initialisation state, including KC info for OIDC."""
config = ConfigService.load()
env_mode = os.getenv("ENV_MODE", "dev")
result = {
"initialized": config.get("initialized", False),
"env_mode": env_mode,
"db_configured": ConfigService.is_db_configured(),
}
# Include Keycloak info so the frontend can build OIDC config
kc = config.get("keycloak", {})
if env_mode == "prod" and kc.get("host") and kc.get("realm"):
result["keycloak"] = {
"authority": f"{kc['host']}/realms/{kc['realm']}",
"client_id": kc.get("client_id", ""),
}
return result
@router.get("/config", dependencies=[Depends(setup_guard)])
async def get_config():
"""Return full config with passwords replaced by placeholder."""
config = ConfigService.load()
if "database" in config and config["database"].get("password"):
config["database"]["password"] = PASSWORD_PLACEHOLDER
return config
@router.put("/config", dependencies=[Depends(setup_guard)])
async def update_config(payload: FullConfig):
"""Merge submitted config sections into the YAML file."""
config = ConfigService.load()
if payload.database is not None:
db_dict = payload.database.model_dump()
# If password is the placeholder, keep the existing real password
if db_dict.get("password") == PASSWORD_PLACEHOLDER:
db_dict["password"] = config.get("database", {}).get("password", "")
config["database"] = db_dict
if payload.keycloak is not None:
config["keycloak"] = payload.keycloak.model_dump()
if payload.tls is not None:
config["tls"] = payload.tls.model_dump()
ConfigService.save(config)
return {"message": "配置已保存"}
@router.post("/test-db", dependencies=[Depends(setup_guard)])
async def test_db_connection(db_config: DatabaseConfig):
"""Test a database connection with the provided parameters (no save)."""
from sqlalchemy import create_engine, text
password = db_config.password
# If password is the placeholder, use the real password from config
if password == PASSWORD_PLACEHOLDER:
password = ConfigService.load().get("database", {}).get("password", "")
url = (
f"mysql+pymysql://{quote_plus(db_config.user)}:{quote_plus(password)}"
f"@{db_config.host}:{db_config.port}/{db_config.database}"
)
try:
engine = create_engine(url, pool_pre_ping=True)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
engine.dispose()
return {"success": True, "message": "数据库连接成功"}
except Exception as e:
return {"success": False, "message": str(e)}
@router.post("/test-keycloak", dependencies=[Depends(setup_guard)])
async def test_keycloak(kc_config: KeycloakConfig):
"""Test Keycloak connectivity by fetching the OIDC discovery document."""
import httpx
well_known = (
f"{kc_config.host}/realms/{kc_config.realm}"
f"/.well-known/openid-configuration"
)
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(well_known)
if resp.status_code == 200:
return {"success": True, "message": "Keycloak 连通正常"}
return {"success": False, "message": f"HTTP {resp.status_code}"}
except Exception as e:
return {"success": False, "message": str(e)}
@router.post("/initialize", dependencies=[Depends(setup_guard)])
async def initialize():
"""Mark system as initialised and reload DB connection."""
config = ConfigService.load()
if not ConfigService.is_db_configured():
raise HTTPException(status_code=400, detail="请先配置数据库连接")
# Reload DB engine so business routes can start working
from app.db_models import reload_db_connection
from app.storage.database import init_db
reload_db_connection()
init_db()
config["initialized"] = True
ConfigService.save(config)
return {"message": "系统初始化完成", "initialized": True}