rebuild(stage-2): establish runtime and persistence foundations

This commit is contained in:
leefer
2026-07-30 01:02:04 +08:00
parent b3ba840d4e
commit d969d2c092
26 changed files with 865 additions and 33 deletions
+32
View File
@@ -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
+23
View File
@@ -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),
)
+79
View File
@@ -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)
+66 -1
View File
@@ -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,
)