From 04726b42cb355991a000b06670a35012ea042039 Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 26 Feb 2026 13:33:02 +0800 Subject: [PATCH] refactor: Phase 1 - rename and simplify backend schemas --- backend/src/v1/auth/schemas.py | 32 ++++----- backend/src/v1/users/__init__.py | 1 + backend/src/v1/users/dependencies.py | 95 ++++++++++++++++++++++++ backend/src/v1/users/repository.py | 81 +++++++++++++++++++++ backend/src/v1/users/schemas.py | 43 +++++++++++ backend/src/v1/users/service.py | 103 +++++++++++++++++++++++++++ 6 files changed, 335 insertions(+), 20 deletions(-) create mode 100644 backend/src/v1/users/__init__.py create mode 100644 backend/src/v1/users/dependencies.py create mode 100644 backend/src/v1/users/repository.py create mode 100644 backend/src/v1/users/schemas.py create mode 100644 backend/src/v1/users/service.py diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 1c6798e..454334e 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -1,36 +1,34 @@ from __future__ import annotations -from typing import Literal - from pydantic import BaseModel, EmailStr, Field -class SignupStartRequest(BaseModel): +class VerificationCreateRequest(BaseModel): username: str = Field(min_length=3, max_length=30) email: EmailStr password: str = Field(min_length=6) redirect_to: str | None = None -class SignupVerifyRequest(BaseModel): +class VerificationResendRequest(BaseModel): + email: EmailStr + + +class VerificationVerifyRequest(BaseModel): email: EmailStr token: str = Field(pattern=r"^\d{6}$") -class SignupResendRequest(BaseModel): - email: EmailStr - - -class LoginRequest(BaseModel): +class SessionCreateRequest(BaseModel): email: EmailStr password: str = Field(min_length=6) -class RefreshRequest(BaseModel): +class SessionRefreshRequest(BaseModel): refresh_token: str = Field(min_length=1) -class LogoutRequest(BaseModel): +class SessionDeleteRequest(BaseModel): refresh_token: str = Field(min_length=1) @@ -39,7 +37,7 @@ class AuthUser(BaseModel): email: EmailStr -class AuthTokenResponse(BaseModel): +class SessionResponse(BaseModel): access_token: str refresh_token: str expires_in: int @@ -47,21 +45,15 @@ class AuthTokenResponse(BaseModel): user: AuthUser -class AuthUserByEmailResponse(BaseModel): +class UserByEmailResponse(BaseModel): id: str email: EmailStr created_at: str email_confirmed_at: str | None = None -class AuthSignupStartResponse(BaseModel): - status: Literal["pending_verification"] = "pending_verification" +class VerificationCreateResponse(BaseModel): email: EmailStr - message: str = "Verification code sent" - - -class AuthResendCodeResponse(BaseModel): - message: str = "If the email exists, a verification code has been sent" class PasswordResetRequest(BaseModel): diff --git a/backend/src/v1/users/__init__.py b/backend/src/v1/users/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/users/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/users/dependencies.py b/backend/src/v1/users/dependencies.py new file mode 100644 index 0000000..ac561f6 --- /dev/null +++ b/backend/src/v1/users/dependencies.py @@ -0,0 +1,95 @@ +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.auth.models import CurrentUser +from core.config.settings import config +from core.db import get_db +from core.logging import get_logger +from v1.users.repository import SQLAlchemyUserRepository +from v1.users.service import UserService + +logger = get_logger("v1.users.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)) + email = payload.get("email") if isinstance(payload.get("email"), str) else None + role = payload.get("role") if isinstance(payload.get("role"), str) else None + return CurrentUser(id=user_id, email=email, role=role) + + +def get_user_service( + session: Annotated[AsyncSession, Depends(get_db)], + user: Annotated[CurrentUser, Depends(get_current_user)], +) -> UserService: + repository = SQLAlchemyUserRepository(session) + return UserService(repository=repository, session=session, current_user=user) diff --git a/backend/src/v1/users/repository.py b/backend/src/v1/users/repository.py new file mode 100644 index 0000000..fecdd63 --- /dev/null +++ b/backend/src/v1/users/repository.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from sqlalchemy import select +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.users.repository") + + +class UserRepository(Protocol): + """Protocol defining the user repository interface.""" + + async def get_by_user_id(self, user_id: UUID) -> Profile | None: + """Get user by user ID.""" + ... + + async def get_by_username(self, username: str) -> Profile | None: + """Get user by username.""" + ... + + async def update_by_user_id( + self, user_id: UUID, update_data: dict[str, str | None] + ) -> Profile | None: + """Update user by user ID. Returns updated user or None if not found.""" + ... + + +class SQLAlchemyUserRepository(BaseRepository[Profile]): + """SQLAlchemy implementation of UserRepository. + + 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("User lookup failed", user_id=str(user_id)) + raise + + async def get_by_username(self, username: str) -> Profile | None: + try: + stmt = ( + select(Profile) + .where(Profile.username == username) + .where(Profile.deleted_at.is_(None)) + .order_by(Profile.created_at.asc()) + .limit(1) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + except SQLAlchemyError: + logger.exception("User 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("User update failed", user_id=str(user_id)) + raise diff --git a/backend/src/v1/users/schemas.py b/backend/src/v1/users/schemas.py new file mode 100644 index 0000000..ece06ad --- /dev/null +++ b/backend/src/v1/users/schemas.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import ClassVar + +from pydantic import ( + AnyHttpUrl, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + + +class UserResponse(BaseModel): + id: str + username: str + avatar_url: str | None = None + bio: str | None = None + + +class UserUpdateRequest(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + username: str | None = Field(default=None, min_length=3, max_length=30) + 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) -> "UserUpdateRequest": + if self.username is None and self.avatar_url is None and self.bio is None: + raise ValueError("At least one field must be provided") + return self diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py new file mode 100644 index 0000000..50a2a38 --- /dev/null +++ b/backend/src/v1/users/service.py @@ -0,0 +1,103 @@ +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.users.repository import UserRepository +from v1.users.schemas import UserResponse, UserUpdateRequest + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.users.service") + + +class UserService(BaseService): + """User service handling business logic and transactions. + + Responsibilities: + - Authorization checks + - Transaction boundary (commit/rollback) + - Converting ORM models to response schemas + """ + + _repository: UserRepository + _session: AsyncSession + + def __init__( + self, + repository: UserRepository, + session: AsyncSession, + current_user: CurrentUser | None, + ) -> None: + super().__init__(current_user=current_user) + self._repository = repository + self._session = session + + async def get_me(self) -> UserResponse: + user_id = self.require_user_id() + try: + user = await self._repository.get_by_user_id(user_id) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="User store unavailable") + + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return UserResponse( + id=str(user.id), + username=user.username, + avatar_url=user.avatar_url, + bio=user.bio, + ) + + async def update_me(self, update: UserUpdateRequest) -> UserResponse: + user_id = self.require_user_id() + update_data: dict[str, str | None] = { + key: value + for key, value in { + "username": update.username, + "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: + user = 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="User store unavailable") + + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + return UserResponse( + id=str(user.id), + username=user.username, + avatar_url=user.avatar_url, + bio=user.bio, + ) + + async def get_by_username(self, username: str) -> UserResponse: + try: + user = await self._repository.get_by_username(username) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="User store unavailable") + + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return UserResponse( + id=str(user.id), + username=user.username, + avatar_url=user.avatar_url, + bio=user.bio, + )