rebuild(stage-2): establish runtime and persistence foundations
This commit is contained in:
@@ -19,6 +19,7 @@ test-results/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
node_modules/
|
node_modules/
|
||||||
next/.venv/
|
next/.venv/
|
||||||
|
next/data/
|
||||||
next/frontend/dist/
|
next/frontend/dist/
|
||||||
next/frontend/.vite/
|
next/frontend/.vite/
|
||||||
next/frontend/coverage/
|
next/frontend/coverage/
|
||||||
|
|||||||
@@ -23,6 +23,28 @@ npm.cmd run dev
|
|||||||
|
|
||||||
浏览器访问`http://127.0.0.1:5173`。开发服务器将`/api`转发到后端8780端口。
|
浏览器访问`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
|
```powershell
|
||||||
|
|||||||
@@ -1,19 +1,51 @@
|
|||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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.bootstrap.settings import Settings
|
||||||
|
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||||
from backend.http.errors import install_error_handlers
|
from backend.http.errors import install_error_handlers
|
||||||
|
from backend.http.request_context import install_request_context
|
||||||
from backend.http.router import api_router
|
from backend.http.router import api_router
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_application(settings: Settings | None = None) -> FastAPI:
|
def create_application(settings: Settings | None = None) -> FastAPI:
|
||||||
runtime = settings or Settings.from_environment()
|
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(
|
application = FastAPI(
|
||||||
title="小白复盘",
|
title="小白复盘",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
docs_url="/api/docs" if runtime.debug else None,
|
docs_url="/api/docs" if runtime.debug else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
application.state.settings = runtime
|
application.state.settings = runtime
|
||||||
|
application.state.container = container
|
||||||
|
install_request_context(application)
|
||||||
install_error_handlers(application)
|
install_error_handlers(application)
|
||||||
application.include_router(api_router, prefix="/api")
|
application.include_router(api_router, prefix="/api")
|
||||||
return application
|
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 dataclasses import dataclass
|
||||||
from pathlib import Path
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
environment: str
|
environment: str
|
||||||
debug: bool
|
debug: bool
|
||||||
data_directory: Path
|
data_directory: Path
|
||||||
|
database_path: Path
|
||||||
|
log_file: Path | None
|
||||||
|
log_level: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
timezone: str = "Asia/Shanghai"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_environment(cls) -> Settings:
|
def from_environment(cls) -> Settings:
|
||||||
environment = os.getenv("APP_ENV", "development").strip().lower()
|
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(
|
return cls(
|
||||||
environment=environment,
|
environment=environment,
|
||||||
debug=environment == "development",
|
debug=environment == "development",
|
||||||
data_directory=data_directory,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
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:
|
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)
|
@application.exception_handler(Exception)
|
||||||
async def unexpected_error(_request: Request, _error: Exception) -> JSONResponse:
|
async def unexpected_error(request: Request, error: Exception) -> JSONResponse:
|
||||||
correlation_id = uuid.uuid4().hex
|
logger.exception(
|
||||||
return JSONResponse(
|
"Unhandled request failure",
|
||||||
status_code=500,
|
exc_info=(type(error), error, error.__traceback__),
|
||||||
content={
|
extra={"request_id": _request_id(request), "event": "request.failed"},
|
||||||
"error": {
|
)
|
||||||
"code": "internal_error",
|
return _response(
|
||||||
"message": "服务暂时不可用,请稍后重试。",
|
request,
|
||||||
"correlation_id": correlation_id,
|
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):
|
class HealthResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
environment: str
|
environment: str
|
||||||
|
components: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse)
|
@router.get("/health", response_model=HealthResponse)
|
||||||
def health(request: Request) -> HealthResponse:
|
def health(request: Request) -> HealthResponse:
|
||||||
|
database_status = request.app.state.container.database_status.get()
|
||||||
return HealthResponse(
|
return HealthResponse(
|
||||||
status="ok",
|
status="ok" if database_status.available else "degraded",
|
||||||
environment=request.app.state.settings.environment,
|
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(
|
vi.fn().mockResolvedValue(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: { code: "unavailable", message: "暂不可用", correlation_id: "abc" },
|
error: { code: "unavailable", message: "暂不可用", request_id: "abc" },
|
||||||
}),
|
}),
|
||||||
{ status: 503, headers: { "Content-Type": "application/json" } },
|
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||||
),
|
),
|
||||||
@@ -42,7 +42,7 @@ describe("api client", () => {
|
|||||||
message: "暂不可用",
|
message: "暂不可用",
|
||||||
status: 503,
|
status: 503,
|
||||||
code: "unavailable",
|
code: "unavailable",
|
||||||
correlationId: "abc",
|
requestId: "abc",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ type ApiErrorPayload = {
|
|||||||
error?: {
|
error?: {
|
||||||
code?: string;
|
code?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
correlation_id?: string;
|
request_id?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export class ApiError extends Error {
|
|||||||
message: string,
|
message: string,
|
||||||
readonly status: number,
|
readonly status: number,
|
||||||
readonly code: string,
|
readonly code: string,
|
||||||
readonly correlationId?: string,
|
readonly requestId?: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
payload.error?.message ?? "请求失败,请稍后重试。",
|
payload.error?.message ?? "请求失败,请稍后重试。",
|
||||||
response.status,
|
response.status,
|
||||||
payload.error?.code ?? "request_failed",
|
payload.error?.code ?? "request_failed",
|
||||||
payload.error?.correlation_id,
|
payload.error?.request_id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi==0.141.0
|
fastapi==0.141.0
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
|
tzdata==2026.3
|
||||||
uvicorn==0.52.0
|
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.application import create_application
|
||||||
from backend.bootstrap.settings import Settings
|
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:
|
def test_health_reports_runtime_environment(tmp_path) -> None:
|
||||||
application = create_application(
|
application = create_application(Settings.for_test(tmp_path))
|
||||||
Settings(environment="test", debug=False, data_directory=tmp_path)
|
response = request(application, "/api/health")
|
||||||
)
|
|
||||||
|
|
||||||
with TestClient(application) as client:
|
|
||||||
response = client.get("/api/health")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
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:
|
def test_unknown_failure_uses_safe_error_contract(tmp_path) -> None:
|
||||||
application = create_application(
|
application = create_application(Settings.for_test(tmp_path))
|
||||||
Settings(environment="test", debug=False, data_directory=tmp_path)
|
|
||||||
)
|
|
||||||
|
|
||||||
@application.get("/api/test/failure")
|
@application.get("/api/test/failure")
|
||||||
def fail() -> None:
|
def fail() -> None:
|
||||||
raise RuntimeError("secret provider details")
|
raise RuntimeError("secret provider details")
|
||||||
|
|
||||||
with TestClient(application, raise_server_exceptions=False) as client:
|
response = request(application, "/api/test/failure")
|
||||||
response = client.get("/api/test/failure")
|
|
||||||
|
|
||||||
payload = response.json()["error"]
|
payload = response.json()["error"]
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
assert payload["code"] == "internal_error"
|
assert payload["code"] == "internal_error"
|
||||||
assert payload["message"] == "服务暂时不可用,请稍后重试。"
|
assert payload["message"] == "服务暂时不可用,请稍后重试。"
|
||||||
assert "secret provider details" not in response.text
|
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