rebuild(stage-2): establish runtime and persistence foundations
This commit is contained in:
@@ -1,19 +1,51 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from backend.bootstrap.container import build_container
|
||||
from backend.bootstrap.logging import configure_logging
|
||||
from backend.bootstrap.settings import Settings
|
||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||
from backend.http.errors import install_error_handlers
|
||||
from backend.http.request_context import install_request_context
|
||||
from backend.http.router import api_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_application(settings: Settings | None = None) -> FastAPI:
|
||||
runtime = settings or Settings.from_environment()
|
||||
container = build_container(runtime)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_application: FastAPI):
|
||||
runtime.data_directory.mkdir(parents=True, exist_ok=True)
|
||||
configure_logging(runtime.log_level, runtime.log_file)
|
||||
MigrationRunner(container.database).upgrade(MIGRATIONS)
|
||||
database_status = container.database_status.get()
|
||||
logger.info(
|
||||
"Application started",
|
||||
extra={
|
||||
"event": "application.started",
|
||||
"context": {
|
||||
"environment": runtime.environment,
|
||||
"schema_version": database_status.schema_version,
|
||||
},
|
||||
},
|
||||
)
|
||||
yield
|
||||
|
||||
application = FastAPI(
|
||||
title="小白复盘",
|
||||
version="0.1.0",
|
||||
docs_url="/api/docs" if runtime.debug else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
application.state.settings = runtime
|
||||
application.state.container = container
|
||||
install_request_context(application)
|
||||
install_error_handlers(application)
|
||||
application.include_router(api_router, prefix="/api")
|
||||
return application
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.bootstrap.settings import Settings
|
||||
from backend.database.connection import Database
|
||||
from backend.database.repositories.status import DatabaseStatusRepository
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationContainer:
|
||||
settings: Settings
|
||||
database: Database
|
||||
database_status: DatabaseStatusRepository
|
||||
|
||||
|
||||
def build_container(settings: Settings) -> ApplicationContainer:
|
||||
database = Database(settings.database_path)
|
||||
return ApplicationContainer(
|
||||
settings=settings,
|
||||
database=database,
|
||||
database_status=DatabaseStatusRepository(database),
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
SENSITIVE_KEYS = (
|
||||
"api_key",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"token",
|
||||
)
|
||||
SENSITIVE_VALUE_PATTERN = re.compile(
|
||||
r"(?i)\b(api[_-]?key|authorization|password|refresh[_-]?token|secret|token)"
|
||||
r"\s*[:=]\s*[^\s,;]+"
|
||||
)
|
||||
BEARER_PATTERN = re.compile(r"(?i)\bbearer\s+[a-z0-9._~+/-]{8,}")
|
||||
SHANGHAI_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def redact(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): "[REDACTED]" if _is_sensitive(str(key)) else redact(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [redact(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
safe = SENSITIVE_VALUE_PATTERN.sub(lambda match: f"{match.group(1)}=[REDACTED]", value)
|
||||
return BEARER_PATTERN.sub("Bearer [REDACTED]", safe)
|
||||
return value
|
||||
|
||||
|
||||
def _is_sensitive(key: str) -> bool:
|
||||
normalized = key.lower().replace("-", "_")
|
||||
return any(marker in normalized for marker in SENSITIVE_KEYS)
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"timestamp": datetime.now(SHANGHAI_TIMEZONE).isoformat(timespec="milliseconds"),
|
||||
"level": record.levelname.lower(),
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
for field in ("request_id", "event", "context"):
|
||||
if hasattr(record, field):
|
||||
payload[field] = redact(getattr(record, field))
|
||||
if record.exc_info:
|
||||
payload["exception"] = record.exc_info[0].__name__
|
||||
return json.dumps(redact(payload), ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def configure_logging(level: str, log_file: Path | None) -> None:
|
||||
handlers: list[logging.Handler] = [logging.StreamHandler()]
|
||||
if log_file is not None:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
handlers.append(
|
||||
RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
)
|
||||
)
|
||||
formatter = JsonFormatter()
|
||||
for handler in handlers:
|
||||
handler.setFormatter(formatter)
|
||||
logging.basicConfig(level=level, handlers=handlers, force=True)
|
||||
@@ -4,19 +4,84 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class ConfigurationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_path(raw_value: str, default: Path) -> Path:
|
||||
candidate = Path(raw_value).expanduser() if raw_value.strip() else default
|
||||
if not candidate.is_absolute():
|
||||
candidate = PROJECT_ROOT / candidate
|
||||
return candidate.resolve()
|
||||
|
||||
|
||||
def _parse_port(raw_value: str) -> int:
|
||||
try:
|
||||
port = int(raw_value)
|
||||
except ValueError as exc:
|
||||
raise ConfigurationError("APP_PORT must be an integer") from exc
|
||||
if not 1 <= port <= 65535:
|
||||
raise ConfigurationError("APP_PORT must be between 1 and 65535")
|
||||
return port
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Settings:
|
||||
environment: str
|
||||
debug: bool
|
||||
data_directory: Path
|
||||
database_path: Path
|
||||
log_file: Path | None
|
||||
log_level: str
|
||||
host: str
|
||||
port: int
|
||||
timezone: str = "Asia/Shanghai"
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> Settings:
|
||||
environment = os.getenv("APP_ENV", "development").strip().lower()
|
||||
data_directory = Path(os.getenv("APP_DATA_DIR", "data")).resolve()
|
||||
if environment not in {"development", "test", "production"}:
|
||||
raise ConfigurationError("APP_ENV must be development, test, or production")
|
||||
timezone = os.getenv("APP_TIMEZONE", "Asia/Shanghai").strip()
|
||||
if timezone != "Asia/Shanghai":
|
||||
raise ConfigurationError("APP_TIMEZONE must be Asia/Shanghai")
|
||||
|
||||
data_directory = _resolve_path(os.getenv("APP_DATA_DIR", ""), PROJECT_ROOT / "data")
|
||||
database_path = _resolve_path(
|
||||
os.getenv("APP_DATABASE_PATH", ""), data_directory / "xiaobai.db"
|
||||
)
|
||||
log_file = _resolve_path(
|
||||
os.getenv("APP_LOG_FILE", ""),
|
||||
data_directory / "logs" / "application.log",
|
||||
)
|
||||
log_level = os.getenv("APP_LOG_LEVEL", "INFO").strip().upper()
|
||||
if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
|
||||
raise ConfigurationError("APP_LOG_LEVEL is invalid")
|
||||
return cls(
|
||||
environment=environment,
|
||||
debug=environment == "development",
|
||||
data_directory=data_directory,
|
||||
database_path=database_path,
|
||||
log_file=log_file,
|
||||
log_level=log_level,
|
||||
host=os.getenv("APP_HOST", "127.0.0.1").strip() or "127.0.0.1",
|
||||
port=_parse_port(os.getenv("APP_PORT", "8780")),
|
||||
timezone=timezone,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def for_test(cls, data_directory: Path) -> Settings:
|
||||
root = data_directory.resolve()
|
||||
return cls(
|
||||
environment="test",
|
||||
debug=False,
|
||||
data_directory=root,
|
||||
database_path=root / "xiaobai-test.db",
|
||||
log_file=None,
|
||||
log_level="CRITICAL",
|
||||
host="127.0.0.1",
|
||||
port=8780,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user