rebuild(stage-2): establish runtime and persistence foundations
This commit is contained in:
@@ -19,6 +19,7 @@ test-results/
|
||||
playwright-report/
|
||||
node_modules/
|
||||
next/.venv/
|
||||
next/data/
|
||||
next/frontend/dist/
|
||||
next/frontend/.vite/
|
||||
next/frontend/coverage/
|
||||
|
||||
@@ -23,6 +23,28 @@ npm.cmd run dev
|
||||
|
||||
浏览器访问`http://127.0.0.1:5173`。开发服务器将`/api`转发到后端8780端口。
|
||||
|
||||
## 运行配置
|
||||
|
||||
配置只从进程环境读取,不在源码或浏览器中保存密钥。基础变量:
|
||||
|
||||
- `APP_ENV`:`development`、`test`或`production`。
|
||||
- `APP_DATA_DIR`:持久化数据目录;相对路径以`next/`为基准。
|
||||
- `APP_DATABASE_PATH`:SQLite文件路径,默认位于数据目录。
|
||||
- `APP_LOG_FILE`:轮转日志路径;单文件10MB,保留3份。
|
||||
- `APP_LOG_LEVEL`:`DEBUG`、`INFO`、`WARNING`、`ERROR`或`CRITICAL`。
|
||||
- `APP_HOST`、`APP_PORT`:监听地址和端口;迁移开发端口为8780。
|
||||
- `APP_TIMEZONE`:固定为`Asia/Shanghai`,其他值会拒绝启动。
|
||||
|
||||
数据库维护:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m tools.database status
|
||||
.\.venv\Scripts\python.exe -m tools.database upgrade
|
||||
.\.venv\Scripts\python.exe -m tools.database downgrade --target 0 --confirm-downgrade
|
||||
```
|
||||
|
||||
回退只允许显式执行;代码回退不会自动回退数据库。
|
||||
|
||||
## 最小质量门禁
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from backend.database.connection import Database
|
||||
from backend.database.migrations import MIGRATIONS, Migration, MigrationError, MigrationRunner
|
||||
|
||||
__all__ = ["MIGRATIONS", "Database", "Migration", "MigrationError", "MigrationRunner"]
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Database:
|
||||
path: Path
|
||||
timeout_seconds: float = 20.0
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout_seconds)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
connection.execute(f"PRAGMA busy_timeout = {int(self.timeout_seconds * 1000)}")
|
||||
return connection
|
||||
|
||||
@contextmanager
|
||||
def read(self) -> Iterator[sqlite3.Connection]:
|
||||
connection = self.connect()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator[sqlite3.Connection]:
|
||||
connection = self.connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
yield connection
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,4 @@
|
||||
from backend.database.migrations.registry import MIGRATIONS
|
||||
from backend.database.migrations.runner import Migration, MigrationError, MigrationRunner
|
||||
|
||||
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from backend.database.migrations.runner import Migration
|
||||
|
||||
MIGRATIONS: tuple[Migration, ...] = ()
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from backend.database.connection import Database
|
||||
|
||||
MigrationAction = Callable[[sqlite3.Connection], None]
|
||||
|
||||
|
||||
class MigrationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Migration:
|
||||
version: int
|
||||
name: str
|
||||
signature: str
|
||||
upgrade: MigrationAction
|
||||
downgrade: MigrationAction
|
||||
|
||||
@property
|
||||
def checksum(self) -> str:
|
||||
source = f"{self.version}:{self.name}:{self.signature}"
|
||||
return hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class MigrationRunner:
|
||||
def __init__(self, database: Database) -> None:
|
||||
self._database = database
|
||||
|
||||
def upgrade(self, migrations: Iterable[Migration]) -> tuple[int, ...]:
|
||||
ordered = self._validate(migrations)
|
||||
with self._database.transaction() as connection:
|
||||
self._ensure_ledger(connection)
|
||||
applied = self._applied(connection)
|
||||
self._verify_history(ordered, applied)
|
||||
completed: list[int] = []
|
||||
for migration in ordered:
|
||||
if migration.version in applied:
|
||||
continue
|
||||
self._run_action(connection, migration, migration.upgrade, "upgrade")
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO schema_migrations (version, name, checksum, applied_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.checksum,
|
||||
datetime.now(UTC).isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
completed.append(migration.version)
|
||||
return tuple(completed)
|
||||
|
||||
def downgrade(self, migrations: Iterable[Migration], target_version: int) -> tuple[int, ...]:
|
||||
if target_version < 0:
|
||||
raise MigrationError("Target version cannot be negative")
|
||||
ordered = self._validate(migrations)
|
||||
by_version = {migration.version: migration for migration in ordered}
|
||||
with self._database.transaction() as connection:
|
||||
self._ensure_ledger(connection)
|
||||
applied = self._applied(connection)
|
||||
self._verify_history(ordered, applied)
|
||||
pending = sorted(
|
||||
(version for version in applied if version > target_version), reverse=True
|
||||
)
|
||||
rolled_back: list[int] = []
|
||||
for version in pending:
|
||||
migration = by_version[version]
|
||||
self._run_action(connection, migration, migration.downgrade, "downgrade")
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = ?", (version,))
|
||||
rolled_back.append(version)
|
||||
return tuple(rolled_back)
|
||||
|
||||
@staticmethod
|
||||
def _validate(migrations: Iterable[Migration]) -> tuple[Migration, ...]:
|
||||
ordered = tuple(sorted(migrations, key=lambda migration: migration.version))
|
||||
versions = [migration.version for migration in ordered]
|
||||
if any(version <= 0 for version in versions):
|
||||
raise MigrationError("Migration versions must be positive integers")
|
||||
if len(versions) != len(set(versions)):
|
||||
raise MigrationError("Migration versions must be unique")
|
||||
return ordered
|
||||
|
||||
@staticmethod
|
||||
def _ensure_ledger(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _applied(connection: sqlite3.Connection) -> dict[int, str]:
|
||||
return {
|
||||
int(row["version"]): str(row["checksum"])
|
||||
for row in connection.execute(
|
||||
"SELECT version, checksum FROM schema_migrations ORDER BY version"
|
||||
)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _verify_history(migrations: tuple[Migration, ...], applied: dict[int, str]) -> None:
|
||||
by_version = {migration.version: migration for migration in migrations}
|
||||
known_versions = sorted(by_version)
|
||||
applied_versions = sorted(applied)
|
||||
unknown = sorted(set(applied) - set(known_versions))
|
||||
if unknown:
|
||||
values = ", ".join(f"{version:04d}" for version in unknown)
|
||||
raise MigrationError(f"Database contains unknown migrations: {values}")
|
||||
if applied_versions != known_versions[: len(applied_versions)]:
|
||||
raise MigrationError("Database migration history is not contiguous")
|
||||
for version in applied_versions:
|
||||
if applied[version] != by_version[version].checksum:
|
||||
raise MigrationError(
|
||||
f"Migration checksum changed: {version:04d} {by_version[version].name}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _run_action(
|
||||
connection: sqlite3.Connection,
|
||||
migration: Migration,
|
||||
action: MigrationAction,
|
||||
direction: str,
|
||||
) -> None:
|
||||
savepoint = f"migration_{migration.version:04d}_{direction}"
|
||||
connection.execute(f"SAVEPOINT {savepoint}")
|
||||
try:
|
||||
action(connection)
|
||||
connection.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||
except Exception as exc:
|
||||
connection.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
||||
connection.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||
raise MigrationError(
|
||||
f"Migration {direction} failed: {migration.version:04d} {migration.name}"
|
||||
) from exc
|
||||
@@ -0,0 +1,3 @@
|
||||
from backend.database.repositories.status import DatabaseStatus, DatabaseStatusRepository
|
||||
|
||||
__all__ = ["DatabaseStatus", "DatabaseStatusRepository"]
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.database.connection import Database
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatabaseStatus:
|
||||
available: bool
|
||||
schema_version: int
|
||||
|
||||
|
||||
class DatabaseStatusRepository:
|
||||
def __init__(self, database: Database) -> None:
|
||||
self._database = database
|
||||
|
||||
def get(self) -> DatabaseStatus:
|
||||
try:
|
||||
with self._database.read() as connection:
|
||||
connection.execute("SELECT 1").fetchone()
|
||||
row = connection.execute(
|
||||
"SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations"
|
||||
).fetchone()
|
||||
return DatabaseStatus(available=True, schema_version=int(row["version"]))
|
||||
except (OSError, sqlite3.Error):
|
||||
return DatabaseStatus(available=False, schema_version=0)
|
||||
+73
-12
@@ -1,22 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppError(Exception):
|
||||
code: str
|
||||
message: str
|
||||
status_code: int = HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def _request_id(request: Request) -> str:
|
||||
return str(getattr(request.state, "request_id", "unavailable"))
|
||||
|
||||
|
||||
def _response(request: Request, status_code: int, code: str, message: str) -> JSONResponse:
|
||||
request_id = _request_id(request)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
headers={"X-Request-ID": request_id},
|
||||
content={
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"request_id": request_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def install_error_handlers(application: FastAPI) -> None:
|
||||
@application.exception_handler(AppError)
|
||||
async def application_error(request: Request, error: AppError) -> JSONResponse:
|
||||
return _response(request, error.status_code, error.code, error.message)
|
||||
|
||||
@application.exception_handler(RequestValidationError)
|
||||
async def validation_error(request: Request, _error: RequestValidationError) -> JSONResponse:
|
||||
return _response(
|
||||
request,
|
||||
HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
"invalid_request",
|
||||
"请求内容不符合要求,请检查后重试。",
|
||||
)
|
||||
|
||||
@application.exception_handler(HTTPException)
|
||||
async def http_error(request: Request, error: HTTPException) -> JSONResponse:
|
||||
code = {
|
||||
HTTPStatus.UNAUTHORIZED: "authentication_required",
|
||||
HTTPStatus.FORBIDDEN: "access_denied",
|
||||
HTTPStatus.NOT_FOUND: "not_found",
|
||||
HTTPStatus.CONFLICT: "conflict",
|
||||
}.get(error.status_code, "request_failed")
|
||||
message = {
|
||||
HTTPStatus.UNAUTHORIZED: "请先登录。",
|
||||
HTTPStatus.FORBIDDEN: "当前账号无权执行此操作。",
|
||||
HTTPStatus.NOT_FOUND: "请求的内容不存在。",
|
||||
HTTPStatus.CONFLICT: "当前状态已发生变化,请刷新后重试。",
|
||||
}.get(error.status_code, "请求失败,请稍后重试。")
|
||||
return _response(request, error.status_code, code, message)
|
||||
|
||||
@application.exception_handler(Exception)
|
||||
async def unexpected_error(_request: Request, _error: Exception) -> JSONResponse:
|
||||
correlation_id = uuid.uuid4().hex
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": {
|
||||
"code": "internal_error",
|
||||
"message": "服务暂时不可用,请稍后重试。",
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
},
|
||||
async def unexpected_error(request: Request, error: Exception) -> JSONResponse:
|
||||
logger.exception(
|
||||
"Unhandled request failure",
|
||||
exc_info=(type(error), error, error.__traceback__),
|
||||
extra={"request_id": _request_id(request), "event": "request.failed"},
|
||||
)
|
||||
return _response(
|
||||
request,
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"服务暂时不可用,请稍后重试。",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
|
||||
def install_request_context(application: FastAPI) -> None:
|
||||
@application.middleware("http")
|
||||
async def request_context(request: Request, call_next):
|
||||
request_id = uuid.uuid4().hex
|
||||
request.state.request_id = request_id
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
@@ -7,11 +7,17 @@ router = APIRouter(tags=["health"])
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
environment: str
|
||||
components: dict[str, str]
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
def health(request: Request) -> HealthResponse:
|
||||
database_status = request.app.state.container.database_status.get()
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
status="ok" if database_status.available else "degraded",
|
||||
environment=request.app.state.settings.environment,
|
||||
components={
|
||||
"process": "ok",
|
||||
"database": "ok" if database_status.available else "unavailable",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("api client", () => {
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { code: "unavailable", message: "暂不可用", correlation_id: "abc" },
|
||||
error: { code: "unavailable", message: "暂不可用", request_id: "abc" },
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
@@ -42,7 +42,7 @@ describe("api client", () => {
|
||||
message: "暂不可用",
|
||||
status: 503,
|
||||
code: "unavailable",
|
||||
correlationId: "abc",
|
||||
requestId: "abc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ type ApiErrorPayload = {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
correlation_id?: string;
|
||||
request_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export class ApiError extends Error {
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
readonly correlationId?: string,
|
||||
readonly requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
payload.error?.message ?? "请求失败,请稍后重试。",
|
||||
response.status,
|
||||
payload.error?.code ?? "request_failed",
|
||||
payload.error?.correlation_id,
|
||||
payload.error?.request_id,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fastapi==0.141.0
|
||||
pydantic==2.13.4
|
||||
tzdata==2026.3
|
||||
uvicorn==0.52.0
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import pytest
|
||||
|
||||
from tools.database import main
|
||||
|
||||
|
||||
def test_status_uses_configured_data_directory(tmp_path, monkeypatch, capsys) -> None:
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("APP_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("APP_DATABASE_PATH", raising=False)
|
||||
|
||||
assert main(["status"]) == 0
|
||||
|
||||
assert capsys.readouterr().out.strip() == "available=true schema_version=0"
|
||||
assert (tmp_path / "xiaobai.db").exists()
|
||||
|
||||
|
||||
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("APP_DATA_DIR", str(tmp_path))
|
||||
|
||||
with pytest.raises(SystemExit, match="confirm-downgrade"):
|
||||
main(["downgrade", "--target", "0"])
|
||||
+67
-14
@@ -1,36 +1,89 @@
|
||||
from fastapi.testclient import TestClient
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
|
||||
from backend.bootstrap.application import create_application
|
||||
from backend.bootstrap.settings import Settings
|
||||
from backend.http.errors import AppError
|
||||
|
||||
|
||||
def request(application, path: str) -> httpx.Response:
|
||||
async def run() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=application, raise_app_exceptions=False)
|
||||
async with application.router.lifespan_context(application):
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://testserver"
|
||||
) as client:
|
||||
return await client.get(path)
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_health_reports_runtime_environment(tmp_path) -> None:
|
||||
application = create_application(
|
||||
Settings(environment="test", debug=False, data_directory=tmp_path)
|
||||
)
|
||||
|
||||
with TestClient(application) as client:
|
||||
response = client.get("/api/health")
|
||||
application = create_application(Settings.for_test(tmp_path))
|
||||
response = request(application, "/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "environment": "test"}
|
||||
assert response.json() == {
|
||||
"status": "ok",
|
||||
"environment": "test",
|
||||
"components": {"process": "ok", "database": "ok"},
|
||||
}
|
||||
assert len(response.headers["X-Request-ID"]) == 32
|
||||
|
||||
|
||||
def test_unknown_failure_uses_safe_error_contract(tmp_path) -> None:
|
||||
application = create_application(
|
||||
Settings(environment="test", debug=False, data_directory=tmp_path)
|
||||
)
|
||||
application = create_application(Settings.for_test(tmp_path))
|
||||
|
||||
@application.get("/api/test/failure")
|
||||
def fail() -> None:
|
||||
raise RuntimeError("secret provider details")
|
||||
|
||||
with TestClient(application, raise_server_exceptions=False) as client:
|
||||
response = client.get("/api/test/failure")
|
||||
response = request(application, "/api/test/failure")
|
||||
|
||||
payload = response.json()["error"]
|
||||
assert response.status_code == 500
|
||||
assert payload["code"] == "internal_error"
|
||||
assert payload["message"] == "服务暂时不可用,请稍后重试。"
|
||||
assert "secret provider details" not in response.text
|
||||
assert len(payload["correlation_id"]) == 32
|
||||
assert len(payload["request_id"]) == 32
|
||||
assert response.headers["X-Request-ID"] == payload["request_id"]
|
||||
|
||||
|
||||
def test_application_error_uses_business_message(tmp_path) -> None:
|
||||
application = create_application(Settings.for_test(tmp_path))
|
||||
|
||||
@application.get("/api/test/locked")
|
||||
def locked() -> None:
|
||||
raise AppError("membership_required", "该功能仅对会员开放。", 403)
|
||||
|
||||
response = request(application, "/api/test/locked")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["code"] == "membership_required"
|
||||
assert response.json()["error"]["message"] == "该功能仅对会员开放。"
|
||||
|
||||
|
||||
def test_framework_404_uses_the_same_safe_contract(tmp_path) -> None:
|
||||
application = create_application(Settings.for_test(tmp_path))
|
||||
|
||||
response = request(application, "/api/does-not-exist")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error"]["code"] == "not_found"
|
||||
assert response.json()["error"]["message"] == "请求的内容不存在。"
|
||||
|
||||
|
||||
def test_validation_error_does_not_expose_framework_details(tmp_path) -> None:
|
||||
application = create_application(Settings.for_test(tmp_path))
|
||||
|
||||
@application.get("/api/test/validated")
|
||||
def validated(value: int = Query(ge=1)) -> dict[str, int]:
|
||||
return {"value": value}
|
||||
|
||||
response = request(application, "/api/test/validated?value=wrong")
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["error"]["code"] == "invalid_request"
|
||||
assert "integer" not in response.text.lower()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from backend.bootstrap.logging import JsonFormatter, redact
|
||||
|
||||
|
||||
def test_nested_sensitive_values_are_redacted() -> None:
|
||||
payload = {
|
||||
"account": "leefer",
|
||||
"api_key": "private-key",
|
||||
"nested": {"refresh-token": "private-token", "count": 3},
|
||||
}
|
||||
|
||||
assert redact(payload) == {
|
||||
"account": "leefer",
|
||||
"api_key": "[REDACTED]",
|
||||
"nested": {"refresh-token": "[REDACTED]", "count": 3},
|
||||
}
|
||||
|
||||
|
||||
def test_json_formatter_keeps_context_without_secret_values() -> None:
|
||||
record = logging.LogRecord(
|
||||
"test", logging.INFO, __file__, 1, "saved token=plain-secret", (), None
|
||||
)
|
||||
record.request_id = "request-1"
|
||||
record.context = {"token": "hidden", "feature": "health"}
|
||||
|
||||
payload = json.loads(JsonFormatter().format(record))
|
||||
|
||||
assert payload["timestamp"].endswith("+08:00")
|
||||
assert payload["request_id"] == "request-1"
|
||||
assert payload["message"] == "saved token=[REDACTED]"
|
||||
assert payload["context"] == {"token": "[REDACTED]", "feature": "health"}
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.database import Database, Migration, MigrationError, MigrationRunner
|
||||
from backend.database.repositories import DatabaseStatusRepository
|
||||
|
||||
|
||||
def create_example(connection: sqlite3.Connection) -> None:
|
||||
connection.execute("CREATE TABLE example (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
|
||||
|
||||
|
||||
def drop_example(connection: sqlite3.Connection) -> None:
|
||||
connection.execute("DROP TABLE example")
|
||||
|
||||
|
||||
def example_migration(signature: str = "example:v1") -> Migration:
|
||||
return Migration(1, "create_example", signature, create_example, drop_example)
|
||||
|
||||
|
||||
def table_names(database: Database) -> set[str]:
|
||||
with database.read() as connection:
|
||||
return {
|
||||
str(row["name"])
|
||||
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
|
||||
|
||||
def test_connection_enables_wal_foreign_keys_and_busy_timeout(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
|
||||
with database.read() as connection:
|
||||
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
|
||||
assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1
|
||||
assert connection.execute("PRAGMA busy_timeout").fetchone()[0] == 20_000
|
||||
|
||||
|
||||
def test_migration_can_upgrade_idempotently_and_downgrade(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
runner = MigrationRunner(database)
|
||||
migration = example_migration()
|
||||
|
||||
assert runner.upgrade((migration,)) == (1,)
|
||||
assert runner.upgrade((migration,)) == ()
|
||||
assert "example" in table_names(database)
|
||||
assert DatabaseStatusRepository(database).get().schema_version == 1
|
||||
|
||||
assert runner.downgrade((migration,), target_version=0) == (1,)
|
||||
assert "example" not in table_names(database)
|
||||
assert DatabaseStatusRepository(database).get().schema_version == 0
|
||||
|
||||
|
||||
def test_failed_migration_is_atomic_and_not_recorded(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
|
||||
def fail(connection: sqlite3.Connection) -> None:
|
||||
connection.execute("CREATE TABLE should_rollback (id INTEGER)")
|
||||
raise RuntimeError("stop")
|
||||
|
||||
migration = Migration(1, "failure", "failure:v1", fail, lambda connection: None)
|
||||
with pytest.raises(MigrationError, match="upgrade failed"):
|
||||
MigrationRunner(database).upgrade((migration,))
|
||||
|
||||
assert "should_rollback" not in table_names(database)
|
||||
assert DatabaseStatusRepository(database).get().schema_version == 0
|
||||
|
||||
|
||||
def test_applied_migration_checksum_cannot_change(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
runner = MigrationRunner(database)
|
||||
runner.upgrade((example_migration(),))
|
||||
|
||||
with pytest.raises(MigrationError, match="checksum changed"):
|
||||
runner.upgrade((example_migration("example:v2"),))
|
||||
with pytest.raises(MigrationError, match="checksum changed"):
|
||||
runner.downgrade((example_migration("example:v2"),), target_version=0)
|
||||
|
||||
|
||||
def test_unknown_database_migration_is_rejected(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
runner = MigrationRunner(database)
|
||||
runner.upgrade((example_migration(),))
|
||||
|
||||
with pytest.raises(MigrationError, match="unknown migrations"):
|
||||
runner.upgrade(())
|
||||
|
||||
|
||||
def test_non_contiguous_database_history_is_rejected(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
first = example_migration()
|
||||
second = Migration(
|
||||
2,
|
||||
"second",
|
||||
"second:v1",
|
||||
lambda connection: None,
|
||||
lambda connection: None,
|
||||
)
|
||||
runner = MigrationRunner(database)
|
||||
runner.upgrade((first, second))
|
||||
with database.transaction() as connection:
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = 1")
|
||||
|
||||
with pytest.raises(MigrationError, match="not contiguous"):
|
||||
runner.upgrade((first, second))
|
||||
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.bootstrap.settings import PROJECT_ROOT, ConfigurationError, Settings
|
||||
|
||||
|
||||
def test_relative_data_directory_is_anchored_to_project(monkeypatch) -> None:
|
||||
monkeypatch.setenv("APP_DATA_DIR", "var/test-data")
|
||||
monkeypatch.delenv("APP_DATABASE_PATH", raising=False)
|
||||
|
||||
settings = Settings.from_environment()
|
||||
|
||||
assert settings.data_directory == (PROJECT_ROOT / "var/test-data").resolve()
|
||||
assert settings.database_path == settings.data_directory / "xiaobai.db"
|
||||
assert settings.timezone == "Asia/Shanghai"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "value"),
|
||||
[("APP_PORT", "70000"), ("APP_PORT", "wrong"), ("APP_TIMEZONE", "UTC")],
|
||||
)
|
||||
def test_invalid_runtime_configuration_fails_fast(monkeypatch, name: str, value: str) -> None:
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
with pytest.raises(ConfigurationError):
|
||||
Settings.from_environment()
|
||||
|
||||
|
||||
def test_test_settings_do_not_create_log_files(tmp_path: Path) -> None:
|
||||
settings = Settings.for_test(tmp_path)
|
||||
|
||||
assert settings.log_file is None
|
||||
assert settings.database_path.parent == tmp_path
|
||||
@@ -0,0 +1 @@
|
||||
"""Maintenance commands for the rebuilt application."""
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
|
||||
from backend.bootstrap.settings import Settings
|
||||
from backend.database import MIGRATIONS, Database, MigrationRunner
|
||||
from backend.database.repositories import DatabaseStatusRepository
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Manage the rebuilt application database")
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
subcommands.add_parser("status", help="Show database availability and schema version")
|
||||
subcommands.add_parser("upgrade", help="Apply all pending migrations")
|
||||
downgrade = subcommands.add_parser("downgrade", help="Roll back to a schema version")
|
||||
downgrade.add_argument("--target", type=int, required=True)
|
||||
downgrade.add_argument("--confirm-downgrade", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main(arguments: Sequence[str] | None = None) -> int:
|
||||
parsed = build_parser().parse_args(arguments)
|
||||
settings = Settings.from_environment()
|
||||
database = Database(settings.database_path)
|
||||
runner = MigrationRunner(database)
|
||||
|
||||
if parsed.command == "status":
|
||||
runner.upgrade(())
|
||||
status = DatabaseStatusRepository(database).get()
|
||||
print(f"available={str(status.available).lower()} schema_version={status.schema_version}")
|
||||
return 0
|
||||
if parsed.command == "upgrade":
|
||||
applied = runner.upgrade(MIGRATIONS)
|
||||
print("applied=" + (",".join(f"{version:04d}" for version in applied) or "none"))
|
||||
return 0
|
||||
if not parsed.confirm_downgrade:
|
||||
raise SystemExit("downgrade requires --confirm-downgrade")
|
||||
rolled_back = runner.downgrade(MIGRATIONS, parsed.target)
|
||||
print("rolled_back=" + (",".join(f"{version:04d}" for version in rolled_back) or "none"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user