feat: 实现密码重置功能与用户搜索API,优化注册登录流程

- 新增忘记密码页面与重置密码确认流程(前端+后端)
- 修复注册验证码页登录跳转路由
- 新增用户搜索API(按邮箱查询)
- 简化infra脚本,统一为app.sh
- 补充密码重置与用户API测试覆盖
- 更新runtime文档与AGENTS配置
This commit is contained in:
qzl
2026-02-27 15:22:42 +08:00
parent 0d4811fee5
commit e4e995854d
37 changed files with 2101 additions and 222 deletions
+18 -2
View File
@@ -11,11 +11,21 @@ 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.auth.gateway import SupabaseAuthGateway
from v1.users.repository import SQLAlchemyUserRepository
from v1.users.service import UserService
from v1.users.service import AuthLookupAdapter, UserService
logger = get_logger("v1.users.dependencies")
_auth_gateway: SupabaseAuthGateway | None = None
def get_auth_gateway() -> SupabaseAuthGateway:
global _auth_gateway
if _auth_gateway is None:
_auth_gateway = SupabaseAuthGateway()
return _auth_gateway
def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser:
if not authorization:
@@ -98,4 +108,10 @@ def get_user_service(
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> UserService:
repository = SQLAlchemyUserRepository(session)
return UserService(repository=repository, session=session, current_user=user)
auth_gateway = AuthLookupAdapter(get_auth_gateway())
return UserService(
repository=repository,
session=session,
current_user=user,
auth_gateway=auth_gateway,
)
+25 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import select, or_
from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository
@@ -33,6 +33,10 @@ class UserRepository(Protocol):
"""Update user by user ID. Returns updated user or None if not found."""
...
async def search_users(self, query: str, limit: int = 20) -> list[Profile]:
"""Search users by username (ilike) or email (exact match)."""
...
class SQLAlchemyUserRepository(BaseRepository[Profile]):
"""SQLAlchemy implementation of UserRepository.
@@ -77,5 +81,24 @@ class SQLAlchemyUserRepository(BaseRepository[Profile]):
try:
return await self.update_by_id(user_id, update_data)
except SQLAlchemyError:
logger.exception("User update failed", user_id=str(user_id))
logger.exception("User update failed", user=str(user_id))
raise
async def search_users(self, query: str, limit: int = 20) -> list[Profile]:
try:
stmt = (
select(Profile)
.where(Profile.deleted_at.is_(None))
.where(
or_(
Profile.username.ilike(f"%{query}%"),
)
)
.order_by(Profile.created_at.asc())
.limit(limit)
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
except SQLAlchemyError:
logger.exception("User search failed", query=query)
raise
+7 -9
View File
@@ -2,10 +2,10 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Path
from fastapi import APIRouter, Depends
from v1.users.dependencies import get_user_service
from v1.users.schemas import UserResponse, UserUpdateRequest
from v1.users.schemas import UserResponse, UserSearchRequest, UserUpdateRequest
from v1.users.service import UserService
@@ -27,11 +27,9 @@ async def update_me(
return await service.update_me(payload)
@router.get("/{username}", response_model=UserResponse)
async def get_by_username(
username: Annotated[
str, Path(min_length=3, max_length=30, pattern="^[a-zA-Z0-9_]+$")
],
@router.post("/search", response_model=list[UserResponse])
async def search_users(
payload: UserSearchRequest,
service: Annotated[UserService, Depends(get_user_service)],
) -> UserResponse:
return await service.get_by_username(username)
) -> list[UserResponse]:
return await service.search_users(payload)
+11
View File
@@ -19,6 +19,17 @@ class UserResponse(BaseModel):
bio: str | None = None
class UserSearchRequest(BaseModel):
query: str = Field(min_length=1, max_length=100)
class UserSearchResult(BaseModel):
id: str
username: str
avatar_url: str | None = None
bio: str | None = None
class UserUpdateRequest(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
+80 -2
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import re
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
@@ -9,13 +11,37 @@ 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
from v1.users.schemas import UserResponse, UserSearchRequest, UserUpdateRequest
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from v1.auth.schemas import UserByEmailResponse
logger = get_logger("v1.users.service")
_EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class AuthLookupGateway(Protocol):
async def get_user_id_by_email(self, email: str) -> str | None: ...
class AuthByEmailGateway(Protocol):
async def get_user_by_email(self, email: str) -> "UserByEmailResponse": ...
class AuthLookupAdapter:
def __init__(self, gateway: AuthByEmailGateway) -> None:
self._gateway = gateway
async def get_user_id_by_email(self, email: str) -> str | None:
try:
response = await self._gateway.get_user_by_email(email)
return response.id
except HTTPException:
return None
class UserService(BaseService):
"""User service handling business logic and transactions.
@@ -28,16 +54,19 @@ class UserService(BaseService):
_repository: UserRepository
_session: AsyncSession
_auth_gateway: AuthLookupGateway | None
def __init__(
self,
repository: UserRepository,
session: AsyncSession,
current_user: CurrentUser | None,
auth_gateway: AuthLookupGateway | None = None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._session = session
self._auth_gateway = auth_gateway
async def get_me(self) -> UserResponse:
user_id = self.require_user_id()
@@ -101,3 +130,52 @@ class UserService(BaseService):
avatar_url=user.avatar_url,
bio=user.bio,
)
async def search_users(self, request: UserSearchRequest) -> list[UserResponse]:
query = request.query.strip()
if _EMAIL_PATTERN.match(query):
return await self._search_by_email(query)
return await self._search_by_username(query)
async def _search_by_email(self, email: str) -> list[UserResponse]:
if self._auth_gateway is None:
raise HTTPException(status_code=503, detail="Auth lookup unavailable")
user_id_str = await self._auth_gateway.get_user_id_by_email(email)
if user_id_str is None:
return []
try:
user = await self._repository.get_by_user_id(UUID(user_id_str))
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="User store unavailable")
if user is None:
return []
return [
UserResponse(
id=str(user.id),
username=user.username,
avatar_url=user.avatar_url,
bio=user.bio,
)
]
async def _search_by_username(self, query: str) -> list[UserResponse]:
try:
users = await self._repository.search_users(query, limit=20)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="User store unavailable")
return [
UserResponse(
id=str(user.id),
username=user.username,
avatar_url=user.avatar_url,
bio=user.bio,
)
for user in users
]