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 @@
"""paper-doc API application package."""
+1
View File
@@ -0,0 +1 @@
"""HTTP layer: routers and their dependencies."""
+8
View File
@@ -0,0 +1,8 @@
"""Aggregate router mounted under the configured API prefix."""
from fastapi import APIRouter
from app.api.routes import health
api_router = APIRouter()
api_router.include_router(health.router)
+1
View File
@@ -0,0 +1 @@
"""Route modules, one per resource. Each exposes ``router``."""
+33
View File
@@ -0,0 +1,33 @@
"""Health endpoint — also the smoke test for database connectivity."""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.db.session import get_db
from app.schemas.health import HealthResponse
router = APIRouter(tags=["health"])
@router.get("/health", response_model=HealthResponse, summary="Liveness and database probe")
def health(db: Session = Depends(get_db)) -> HealthResponse:
"""Report process liveness and whether TiDB answered a trivial query.
A database failure is reported in the body instead of raising, so that the
endpoint stays useful for diagnosing an unreachable database.
"""
settings = get_settings()
try:
db.execute(text("SELECT 1"))
database = "ok"
except Exception: # noqa: BLE001 - the failure mode is the payload
database = "unavailable"
return HealthResponse(
status="ok" if database == "ok" else "degraded",
app=settings.app_name,
database=database,
database_target=settings.safe_database_url,
)
+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()
+7
View File
@@ -0,0 +1,7 @@
"""Data-access helpers.
Functions here take a :class:`sqlalchemy.orm.Session` and operate on ORM
models. There are none yet — this package is a placeholder for that layer.
"""
__all__: list[str] = []
+1
View File
@@ -0,0 +1 @@
"""Database engine, session factory, and declarative base."""
+16
View File
@@ -0,0 +1,16 @@
"""Declarative base for ORM models.
No model classes are defined in this project yet, by design: the database
schema is introduced deliberately through Alembic migrations rather than being
created implicitly at import time.
"""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Common base class for every ORM model.
Subclass this to declare a model. New models must also be imported by
``app.models`` so that ``alembic revision --autogenerate`` can see them.
"""
+35
View File
@@ -0,0 +1,35 @@
"""Database engine, session factory, and the FastAPI session dependency."""
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
settings = get_settings()
engine = create_engine(
settings.sqlalchemy_url,
echo=settings.db_echo,
pool_pre_ping=settings.db_pool_pre_ping,
pool_recycle=settings.db_pool_recycle,
future=True,
)
SessionLocal = sessionmaker(
bind=engine,
class_=Session,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)
def get_db() -> Generator[Session, None, None]:
"""Yield a session per request and always close it."""
db = SessionLocal()
try:
yield db
finally:
db.close()
+29
View File
@@ -0,0 +1,29 @@
"""FastAPI application entry point.
Run locally with::
uvicorn app.main:app --reload --port 8000
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
settings = get_settings()
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.api_prefix)
+15
View File
@@ -0,0 +1,15 @@
"""SQLAlchemy ORM models.
This package is intentionally empty.
No model classes exist yet, and no tables are created by this project at
import time. The schema will be introduced through Alembic migrations:
alembic revision --autogenerate -m "describe the change"
alembic upgrade head
When the first model is written, add it here (or in a submodule imported from
here) so that ``Base.metadata`` — and therefore autogenerate — can see it.
"""
__all__: list[str] = []
+1
View File
@@ -0,0 +1 @@
"""Pydantic models describing request and response payloads."""
+12
View File
@@ -0,0 +1,12 @@
"""Response models for the health endpoint."""
from pydantic import BaseModel, Field
class HealthResponse(BaseModel):
"""Liveness report, including whether the database answered."""
status: str = Field(description="'ok' when the database responded, else 'degraded'")
app: str = Field(description="Configured application name")
database: str = Field(description="'ok' or 'unavailable'")
database_target: str = Field(description="Connection target with the password masked")