refactor: align backend layout and supabase infra

Consolidate backend modules/tests under the backend package while syncing Supabase compose/env config and related plans.
This commit is contained in:
qzl
2026-02-05 15:13:06 +08:00
parent 3cfcb11240
commit ad06fe7de4
111 changed files with 5540 additions and 1362 deletions
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
from typing import Annotated
from uuid import UUID
import jwt
from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.config.settings import config
from core.db import get_db
from core.logging import get_logger
from core.auth.models import CurrentUser
from v1.profile.repository import SQLAlchemyProfileRepository
from v1.profile.service import ProfileService
logger = get_logger("v1.profile.dependencies")
def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser:
if not authorization:
logger.warning("JWT validation failed: missing authorization header")
raise HTTPException(status_code=401, detail="Unauthorized")
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
logger.warning("JWT validation failed: invalid authorization scheme")
raise HTTPException(status_code=401, detail="Unauthorized")
secret = config.supabase.jwt_secret
if not secret:
logger.error("JWT validation failed: secret not configured")
raise HTTPException(status_code=503, detail="JWT secret not configured")
supabase_url = config.supabase.public_url.rstrip("/")
expected_issuer = f"{supabase_url}/auth/v1"
try:
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
audience="authenticated",
issuer=expected_issuer,
options={
"verify_aud": True,
"verify_iss": True,
"verify_exp": True,
"require": ["sub", "aud", "iss", "exp"],
},
)
except jwt.ExpiredSignatureError:
logger.warning("JWT validation failed: token expired")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidAudienceError:
logger.warning("JWT validation failed: invalid audience")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidIssuerError:
logger.warning("JWT validation failed: invalid issuer")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidSignatureError:
logger.warning("JWT validation failed: invalid signature")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.DecodeError:
logger.warning("JWT validation failed: malformed token")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.PyJWTError as exc:
logger.warning(
"JWT validation failed: unknown error", error_type=type(exc).__name__
)
raise HTTPException(status_code=401, detail="Unauthorized") from exc
subject = payload.get("sub")
if not isinstance(subject, str) or not subject:
logger.warning("JWT validation failed: missing or invalid subject claim")
raise HTTPException(status_code=401, detail="Unauthorized")
try:
user_id = UUID(subject)
except ValueError:
logger.warning("JWT validation failed: invalid UUID in subject")
raise HTTPException(status_code=401, detail="Unauthorized")
logger.debug("JWT validation successful", user_id=str(user_id))
return CurrentUser(id=user_id)
def get_profile_service(
session: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> ProfileService:
repository = SQLAlchemyProfileRepository(session)
return ProfileService(repository=repository, session=session, current_user=user)
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository
from core.logging import get_logger
from models.profile import Profile
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.profile.repository")
class ProfileRepository(Protocol):
"""Protocol defining the profile repository interface."""
async def get_by_user_id(self, user_id: UUID) -> Profile | None:
"""Get profile by user ID."""
...
async def get_by_username(self, username: str) -> Profile | None:
"""Get profile by username."""
...
async def update_by_user_id(
self, user_id: UUID, update_data: dict[str, str | None]
) -> Profile | None:
"""Update profile by user ID. Returns updated profile or None if not found."""
...
class SQLAlchemyProfileRepository(BaseRepository[Profile]):
"""SQLAlchemy implementation of ProfileRepository.
Note: This repository only performs CRUD operations.
- No commit (only flush) - service layer handles transactions
- No auth logic - service layer handles authorization
- No HTTP exceptions - returns None or raises SQLAlchemyError
"""
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, Profile)
async def get_by_user_id(self, user_id: UUID) -> Profile | None:
try:
return await self.get_by_id(user_id)
except SQLAlchemyError:
logger.exception("Profile lookup failed", user_id=str(user_id))
raise
async def get_by_username(self, username: str) -> Profile | None:
try:
return await self.get_one(Profile.username == username)
except SQLAlchemyError:
logger.exception("Profile lookup failed", username=username)
raise
async def update_by_user_id(
self, user_id: UUID, update_data: dict[str, str | None]
) -> Profile | None:
if not update_data:
return await self.get_by_user_id(user_id)
try:
return await self.update_by_id(user_id, update_data)
except SQLAlchemyError:
logger.exception("Profile update failed", user_id=str(user_id))
raise
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Path
from v1.profile.dependencies import get_profile_service
from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest
from v1.profile.service import ProfileService
router = APIRouter(prefix="/profile", tags=["profile"])
@router.get("/me", response_model=ProfileResponse)
async def get_me(
service: Annotated[ProfileService, Depends(get_profile_service)],
) -> ProfileResponse:
return await service.get_me()
@router.patch("/me", response_model=ProfileResponse)
async def update_me(
payload: ProfileUpdateRequest,
service: Annotated[ProfileService, Depends(get_profile_service)],
) -> ProfileResponse:
return await service.update_me(payload)
@router.get("/{username}", response_model=ProfileResponse)
async def get_by_username(
username: Annotated[
str, Path(min_length=3, max_length=30, pattern="^[a-zA-Z0-9_]+$")
],
service: Annotated[ProfileService, Depends(get_profile_service)],
) -> ProfileResponse:
return await service.get_by_username(username)
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from pydantic import AnyHttpUrl, BaseModel, Field, field_validator, model_validator
class ProfileResponse(BaseModel):
id: str
username: str
display_name: str | None = None
avatar_url: str | None = None
bio: str | None = None
class ProfileUpdateRequest(BaseModel):
display_name: str | None = Field(default=None, max_length=50)
avatar_url: str | None = Field(default=None)
bio: str | None = Field(default=None, max_length=200)
@field_validator("avatar_url", mode="before")
@classmethod
def validate_avatar_url(cls, v: str | None) -> str | None:
if v is None:
return None
parsed = AnyHttpUrl(v)
if parsed.scheme not in ("http", "https"):
raise ValueError("avatar_url must use http or https scheme")
return str(parsed)
@model_validator(mode="after")
def require_one_field(self) -> "ProfileUpdateRequest":
if self.display_name is None and self.avatar_url is None and self.bio is None:
raise ValueError("At least one field must be provided")
return self
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from core.db.base_service import BaseService
from core.logging import get_logger
from v1.profile.repository import ProfileRepository
from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.profile.service")
class ProfileService(BaseService):
"""Profile service handling business logic and transactions.
Responsibilities:
- Authorization checks
- Transaction boundary (commit/rollback)
- Converting ORM models to response schemas
"""
_repository: ProfileRepository
_session: AsyncSession
def __init__(
self,
repository: ProfileRepository,
session: AsyncSession,
current_user: CurrentUser | None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._session = session
async def get_me(self) -> ProfileResponse:
user_id = self.require_user_id()
try:
profile = await self._repository.get_by_user_id(user_id)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Profile store unavailable")
if profile is None:
raise HTTPException(status_code=404, detail="Profile not found")
return ProfileResponse(
id=str(profile.id),
username=profile.username,
display_name=profile.display_name,
avatar_url=profile.avatar_url,
bio=profile.bio,
)
async def update_me(self, update: ProfileUpdateRequest) -> ProfileResponse:
user_id = self.require_user_id()
update_data: dict[str, str | None] = {
key: value
for key, value in {
"display_name": update.display_name,
"avatar_url": update.avatar_url,
"bio": update.bio,
}.items()
if value is not None
}
if not update_data:
raise HTTPException(status_code=400, detail="No fields to update")
try:
profile = await self._repository.update_by_user_id(user_id, update_data)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(status_code=503, detail="Profile store unavailable")
if profile is None:
raise HTTPException(status_code=404, detail="Profile not found")
return ProfileResponse(
id=str(profile.id),
username=profile.username,
display_name=profile.display_name,
avatar_url=profile.avatar_url,
bio=profile.bio,
)
async def get_by_username(self, username: str) -> ProfileResponse:
try:
profile = await self._repository.get_by_username(username)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Profile store unavailable")
if profile is None:
raise HTTPException(status_code=404, detail="Profile not found")
return ProfileResponse(
id=str(profile.id),
username=profile.username,
display_name=profile.display_name,
avatar_url=profile.avatar_url,
bio=profile.bio,
)