feat: scaffold FastAPI backend and Vue 3 frontend

Backend (backend/):
- FastAPI app with layered app/{api,core,crud,db,models,schemas} structure
- TiDB connection via SQLAlchemy. The URL is built with URL.create rather
  than string formatting: the password contains '@', which would otherwise be
  parsed as the user/host separator and silently truncate the credential.
- Alembic environment wired to Base.metadata and the app settings, so
  backend/.env stays the single source of truth for credentials
- /api/health endpoint reporting database reachability
- models/ and crud/ are intentionally empty: no model classes are defined and
  no tables are created, at import time or otherwise

Frontend (frontend/):
- Vue 3 + Vite + TypeScript scaffold (create-vue, --bare)
- axios instance with a normalized error shape
- vue-router with a home route and a catch-all 404
- pinia + pinia-plugin-persistedstate; the app store persists selected keys
- Element Plus with its icons registered globally
- dev proxy forwards /api to the FastAPI service on port 8000

The database paper_doc was created in TiDB out of band; no table exists yet.
This commit is contained in:
2026-09-17 12:51:37 +08:00
parent 4438b04ff1
commit 5128e1551c
43 changed files with 3321 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Cross-cutting configuration and infrastructure."""
+86
View File
@@ -0,0 +1,86 @@
"""Application settings, loaded from ``backend/.env`` and the environment.
The database URL is assembled with :func:`sqlalchemy.engine.URL.create` rather
than string formatting. That matters here: the TiDB password contains ``@``,
and interpolating it into a DSN by hand silently truncates the password at the
first ``@`` (it gets parsed as the user/host separator). ``URL.create``
percent-encodes each component correctly.
"""
from functools import lru_cache
from pathlib import Path
from typing import Union
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
# backend/app/core/config.py -> backend/
BASE_DIR = Path(__file__).resolve().parents[2]
class Settings(BaseSettings):
"""Runtime configuration for the API service."""
model_config = SettingsConfigDict(
env_file=BASE_DIR / ".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "paper-doc API"
api_prefix: str = "/api"
debug: bool = True
# The Vite dev server proxies /api to this service, so CORS is normally not
# exercised during development; it is configured for direct-origin setups.
cors_origins: list[str] = [
"http://127.0.0.1:5173",
"http://localhost:5173",
]
# --- Database (TiDB) -------------------------------------------------
db_host: str = "127.0.0.1"
db_port: int = 4000
db_user: str = "root"
db_password: str = ""
db_name: str = "paper_doc"
# When set, this wins over the DB_* parts above and is used verbatim.
database_url: str | None = None
# --- Connection pool -------------------------------------------------
db_echo: bool = False
db_pool_pre_ping: bool = True
db_pool_recycle: int = 3600
@property
def sqlalchemy_url(self) -> Union[URL, str]:
"""The SQLAlchemy connection URL.
Returns a :class:`~sqlalchemy.engine.URL` unless ``DATABASE_URL`` was
supplied explicitly, in which case that string is passed through.
"""
if self.database_url:
return self.database_url
return URL.create(
drivername="mysql+pymysql",
username=self.db_user,
password=self.db_password,
host=self.db_host,
port=self.db_port,
database=self.db_name,
)
@property
def safe_database_url(self) -> str:
"""The URL rendered with the password masked, for logs and diagnostics."""
url = self.sqlalchemy_url
if isinstance(url, str):
return URL.make_url(url).render_as_string(hide_password=True)
return url.render_as_string(hide_password=True)
@lru_cache
def get_settings() -> Settings:
"""Return the process-wide settings singleton."""
return Settings()