"""H5 登录相关 HTTP 接口。 Pydantic 模型负责校验请求体,FastAPI 的 Depends 负责在接口执行前完成身份验证。 """ from typing import Annotated, Any from fastapi import APIRouter, Depends, Request, Response from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, Field from app.api.responses import success_response from app.core.config import Settings from app.core.errors import AppError from app.domains.attribution.service import AttributionService from app.domains.identity.models import H5User from app.domains.identity.service import H5AuthService bearer_scheme = HTTPBearer(auto_error=False) H5_REFRESH_COOKIE = "zbt_h5_refresh" H5_REFRESH_COOKIE_PATH = "/api/v1/h5/auth" class MobileRequest(BaseModel): """请求验证码时的 JSON 请求体。""" mobile: str = Field(min_length=11, max_length=11) class H5LoginRequest(MobileRequest): code: str = Field(min_length=6, max_length=6) referral_code: str | None = Field(default=None, max_length=32) class RefreshRequest(BaseModel): refresh_token: str = Field(min_length=32) def create_h5_router( auth_service: H5AuthService, settings: Settings, attribution_service: AttributionService | None = None, ) -> APIRouter: """创建 H5 认证路由,并把认证服务放入各接口的闭包中。""" router = APIRouter(tags=["h5-auth"]) def bearer_token( credentials: Annotated[ HTTPAuthorizationCredentials | None, Depends(bearer_scheme), ], ) -> str: # Depends 会从 Authorization: Bearer 请求头中解析凭证。 if credentials is None: raise AppError("AUTH_REQUIRED", "请先登录", 401) return credentials.credentials def current_user( token: Annotated[str, Depends(bearer_token)], ) -> H5User: # 依赖可以继续依赖另一个依赖:先取 token,再查询当前用户。 return auth_service.authenticate_access(token) @router.post("/h5/auth/request-code") def request_code(payload: MobileRequest, request: Request) -> dict[str, Any]: return success_response(request, auth_service.request_code(payload.mobile)) def set_refresh_cookie(response: Response, refresh_token: str) -> None: response.set_cookie( key=H5_REFRESH_COOKIE, value=refresh_token, max_age=settings.refresh_token_days * 24 * 60 * 60, httponly=True, secure=settings.app_env == "production", samesite="lax", path=H5_REFRESH_COOKIE_PATH, ) @router.post("/h5/auth/login") def login( payload: H5LoginRequest, request: Request, response: Response, ) -> dict[str, Any]: # payload 已由 FastAPI 转成 H5LoginRequest,无需手动解析 JSON。 result = auth_service.login(payload.mobile, payload.code) if payload.referral_code and attribution_service is not None: user = auth_service.authenticate_access(str(result["tokens"]["access_token"])) result["attribution"] = attribution_service.capture_visit( user, payload.referral_code, ) set_refresh_cookie(response, result["tokens"]["refresh_token"]) return success_response(request, result) @router.post("/h5/auth/refresh") def refresh( request: Request, response: Response, payload: RefreshRequest | None = None, ) -> dict[str, Any]: refresh_token = ( payload.refresh_token if payload is not None else request.cookies.get(H5_REFRESH_COOKIE) ) if refresh_token is None: raise AppError("AUTH_REQUIRED", "缺少刷新凭证", 401) result = auth_service.refresh(refresh_token) set_refresh_cookie(response, result["tokens"]["refresh_token"]) return success_response(request, result) @router.post("/h5/auth/logout") def logout( request: Request, response: Response, token: Annotated[str, Depends(bearer_token)], ) -> dict[str, Any]: auth_service.logout(token) response.delete_cookie( key=H5_REFRESH_COOKIE, path=H5_REFRESH_COOKIE_PATH, samesite="lax", ) return success_response(request, {"logged_out": True}) @router.get("/h5/me") def me( request: Request, user: Annotated[H5User, Depends(current_user)], ) -> dict[str, Any]: return success_response( request, { "id": user.id, "mobile_masked": user.mobile_masked, "display_name": user.display_name, "status": user.status, }, ) return router