2026-02-26 13:33:02 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-02-27 15:22:42 +08:00
|
|
|
import re
|
2026-03-08 16:01:16 +08:00
|
|
|
from typing import TYPE_CHECKING, Protocol, cast
|
2026-02-27 15:22:42 +08:00
|
|
|
from uuid import UUID
|
2026-02-26 13:33:02 +08:00
|
|
|
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
|
|
2026-03-25 17:41:55 +08:00
|
|
|
from core.agentscope.caches.user_context_cache import (
|
2026-03-08 16:01:16 +08:00
|
|
|
create_user_context_cache,
|
|
|
|
|
)
|
2026-03-25 17:41:55 +08:00
|
|
|
from core.auth.models import CurrentUser
|
2026-02-26 13:33:02 +08:00
|
|
|
from core.db.base_service import BaseService
|
|
|
|
|
from core.logging import get_logger
|
2026-03-25 12:36:31 +08:00
|
|
|
from schemas.shared.user import UserContext, parse_profile_settings
|
2026-02-26 13:33:02 +08:00
|
|
|
from v1.users.repository import UserRepository
|
2026-03-15 17:14:15 +08:00
|
|
|
from v1.users.schemas import UserSearchRequest, UserUpdateRequest
|
2026-02-26 13:33:02 +08:00
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-03-25 12:36:31 +08:00
|
|
|
from schemas.shared.user import UserContext
|
2026-02-27 15:22:42 +08:00
|
|
|
|
2026-02-26 13:33:02 +08:00
|
|
|
logger = get_logger("v1.users.service")
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
_PHONE_QUERY_PATTERN = re.compile(r"^[+()\-\s\d]{4,32}$")
|
2026-02-27 15:22:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class AuthLookupGateway(Protocol):
|
2026-03-19 18:42:59 +08:00
|
|
|
async def search_user_ids_by_phone(
|
|
|
|
|
self, query: str, limit: int = 20
|
|
|
|
|
) -> list[str]: ...
|
2026-02-27 15:22:42 +08:00
|
|
|
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
class AuthByPhoneGateway(Protocol):
|
|
|
|
|
async def search_user_ids_by_phone(
|
|
|
|
|
self, query: str, limit: int = 20
|
|
|
|
|
) -> list[str]: ...
|
2026-02-27 15:22:42 +08:00
|
|
|
|
|
|
|
|
|
2026-03-08 16:01:16 +08:00
|
|
|
class UserContextInvalidator(Protocol):
|
|
|
|
|
async def invalidate_user(self, *, user_id: UUID) -> int: ...
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 15:22:42 +08:00
|
|
|
class AuthLookupAdapter:
|
2026-03-19 18:42:59 +08:00
|
|
|
def __init__(self, gateway: AuthByPhoneGateway) -> None:
|
2026-02-27 15:22:42 +08:00
|
|
|
self._gateway = gateway
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
|
2026-02-27 15:22:42 +08:00
|
|
|
try:
|
2026-03-19 18:42:59 +08:00
|
|
|
return await self._gateway.search_user_ids_by_phone(query, limit=limit)
|
2026-02-27 15:22:42 +08:00
|
|
|
except HTTPException:
|
2026-03-19 18:42:59 +08:00
|
|
|
return []
|
2026-02-27 15:22:42 +08:00
|
|
|
|
2026-02-26 13:33:02 +08:00
|
|
|
|
|
|
|
|
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
|
2026-02-27 15:22:42 +08:00
|
|
|
_auth_gateway: AuthLookupGateway | None
|
2026-03-08 16:01:16 +08:00
|
|
|
_user_context_cache: UserContextInvalidator
|
2026-02-26 13:33:02 +08:00
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
repository: UserRepository,
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
current_user: CurrentUser | None,
|
2026-02-27 15:22:42 +08:00
|
|
|
auth_gateway: AuthLookupGateway | None = None,
|
2026-03-08 16:01:16 +08:00
|
|
|
user_context_cache: UserContextInvalidator | None = None,
|
2026-02-26 13:33:02 +08:00
|
|
|
) -> None:
|
|
|
|
|
super().__init__(current_user=current_user)
|
|
|
|
|
self._repository = repository
|
|
|
|
|
self._session = session
|
2026-02-27 15:22:42 +08:00
|
|
|
self._auth_gateway = auth_gateway
|
2026-03-08 16:01:16 +08:00
|
|
|
self._user_context_cache = cast(
|
|
|
|
|
UserContextInvalidator,
|
|
|
|
|
user_context_cache or create_user_context_cache(),
|
|
|
|
|
)
|
2026-02-26 13:33:02 +08:00
|
|
|
|
2026-03-15 17:14:15 +08:00
|
|
|
async def get_me(self) -> UserContext:
|
2026-02-26 13:33:02 +08:00
|
|
|
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")
|
2026-03-19 18:42:59 +08:00
|
|
|
phone = self._current_user.phone if self._current_user else None
|
2026-03-15 17:14:15 +08:00
|
|
|
return UserContext(
|
2026-02-26 13:33:02 +08:00
|
|
|
id=str(user.id),
|
|
|
|
|
username=user.username,
|
2026-03-19 18:42:59 +08:00
|
|
|
phone=phone,
|
2026-02-26 13:33:02 +08:00
|
|
|
avatar_url=user.avatar_url,
|
|
|
|
|
bio=user.bio,
|
2026-03-15 17:14:15 +08:00
|
|
|
settings=parse_profile_settings(user.settings),
|
2026-02-26 13:33:02 +08:00
|
|
|
)
|
|
|
|
|
|
2026-03-13 01:01:54 +08:00
|
|
|
async def get_user_by_id(self, user_id: UUID) -> "UserContext":
|
2026-03-25 12:36:31 +08:00
|
|
|
from schemas.shared.user import UserContext
|
2026-03-11 20:57:49 +08:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
profile = await self._repository.get_by_user_id(user_id)
|
|
|
|
|
except SQLAlchemyError:
|
|
|
|
|
raise HTTPException(status_code=503, detail="User store unavailable")
|
|
|
|
|
|
|
|
|
|
if profile is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="User not found")
|
2026-03-13 01:01:54 +08:00
|
|
|
return UserContext(
|
2026-03-11 20:57:49 +08:00
|
|
|
id=str(profile.id),
|
|
|
|
|
username=profile.username,
|
|
|
|
|
avatar_url=profile.avatar_url,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-15 17:14:15 +08:00
|
|
|
async def update_me(self, update: UserUpdateRequest) -> UserContext:
|
2026-02-26 13:33:02 +08:00
|
|
|
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")
|
|
|
|
|
|
2026-03-08 16:01:16 +08:00
|
|
|
try:
|
|
|
|
|
await self._user_context_cache.invalidate_user(user_id=user_id)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Failed to invalidate user context cache after profile update",
|
|
|
|
|
user_id=str(user_id),
|
|
|
|
|
error=str(exc),
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
phone = self._current_user.phone if self._current_user else None
|
2026-03-15 17:14:15 +08:00
|
|
|
return UserContext(
|
2026-02-26 13:33:02 +08:00
|
|
|
id=str(user.id),
|
|
|
|
|
username=user.username,
|
2026-03-19 18:42:59 +08:00
|
|
|
phone=phone,
|
2026-02-26 13:33:02 +08:00
|
|
|
avatar_url=user.avatar_url,
|
|
|
|
|
bio=user.bio,
|
2026-03-15 17:14:15 +08:00
|
|
|
settings=parse_profile_settings(user.settings),
|
2026-02-26 13:33:02 +08:00
|
|
|
)
|
|
|
|
|
|
2026-03-15 17:14:15 +08:00
|
|
|
async def get_by_username(self, username: str) -> UserContext:
|
2026-02-26 13:33:02 +08:00
|
|
|
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")
|
2026-03-15 17:14:15 +08:00
|
|
|
return UserContext(
|
2026-02-26 13:33:02 +08:00
|
|
|
id=str(user.id),
|
|
|
|
|
username=user.username,
|
|
|
|
|
avatar_url=user.avatar_url,
|
|
|
|
|
bio=user.bio,
|
2026-03-15 17:14:15 +08:00
|
|
|
settings=parse_profile_settings(user.settings),
|
2026-02-26 13:33:02 +08:00
|
|
|
)
|
2026-02-27 15:22:42 +08:00
|
|
|
|
2026-03-15 17:14:15 +08:00
|
|
|
async def search_users(self, request: UserSearchRequest) -> list[UserContext]:
|
2026-02-27 15:22:42 +08:00
|
|
|
query = request.query.strip()
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
if _looks_like_phone_query(query):
|
|
|
|
|
phone_results = await self._search_by_phone(query)
|
|
|
|
|
if not query.isdigit():
|
|
|
|
|
return phone_results
|
|
|
|
|
username_results = await self._search_by_username(query)
|
|
|
|
|
if not phone_results:
|
|
|
|
|
return username_results
|
|
|
|
|
merged_by_id = {result.id: result for result in phone_results}
|
|
|
|
|
for result in username_results:
|
|
|
|
|
merged_by_id.setdefault(result.id, result)
|
|
|
|
|
return list(merged_by_id.values())
|
2026-02-27 15:22:42 +08:00
|
|
|
|
|
|
|
|
return await self._search_by_username(query)
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
async def _search_by_phone(self, phone: str) -> list[UserContext]:
|
2026-02-27 15:22:42 +08:00
|
|
|
if self._auth_gateway is None:
|
|
|
|
|
raise HTTPException(status_code=503, detail="Auth lookup unavailable")
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
user_id_values = await self._auth_gateway.search_user_ids_by_phone(
|
|
|
|
|
phone, limit=20
|
|
|
|
|
)
|
|
|
|
|
if not user_id_values:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
user_ids: list[UUID] = []
|
|
|
|
|
for raw_id in user_id_values:
|
|
|
|
|
try:
|
|
|
|
|
user_ids.append(UUID(raw_id))
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
|
|
|
|
if not user_ids:
|
2026-02-27 15:22:42 +08:00
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
try:
|
2026-03-19 18:42:59 +08:00
|
|
|
users_by_id = await self._repository.get_by_user_ids(user_ids)
|
2026-02-27 15:22:42 +08:00
|
|
|
except SQLAlchemyError:
|
|
|
|
|
raise HTTPException(status_code=503, detail="User store unavailable")
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
results: list[UserContext] = []
|
|
|
|
|
for user_id in user_ids:
|
|
|
|
|
user = users_by_id.get(user_id)
|
|
|
|
|
if user is None:
|
|
|
|
|
continue
|
|
|
|
|
results.append(
|
|
|
|
|
UserContext(
|
|
|
|
|
id=str(user.id),
|
|
|
|
|
username=user.username,
|
|
|
|
|
avatar_url=user.avatar_url,
|
|
|
|
|
bio=user.bio,
|
|
|
|
|
settings=parse_profile_settings(user.settings),
|
|
|
|
|
)
|
2026-02-27 15:22:42 +08:00
|
|
|
)
|
2026-03-19 18:42:59 +08:00
|
|
|
return results
|
2026-02-27 15:22:42 +08:00
|
|
|
|
2026-03-15 17:14:15 +08:00
|
|
|
async def _search_by_username(self, query: str) -> list[UserContext]:
|
2026-02-27 15:22:42 +08:00
|
|
|
try:
|
|
|
|
|
users = await self._repository.search_users(query, limit=20)
|
|
|
|
|
except SQLAlchemyError:
|
|
|
|
|
raise HTTPException(status_code=503, detail="User store unavailable")
|
|
|
|
|
|
|
|
|
|
return [
|
2026-03-15 17:14:15 +08:00
|
|
|
UserContext(
|
2026-02-27 15:22:42 +08:00
|
|
|
id=str(user.id),
|
|
|
|
|
username=user.username,
|
|
|
|
|
avatar_url=user.avatar_url,
|
|
|
|
|
bio=user.bio,
|
2026-03-15 17:14:15 +08:00
|
|
|
settings=parse_profile_settings(user.settings),
|
2026-02-27 15:22:42 +08:00
|
|
|
)
|
|
|
|
|
for user in users
|
|
|
|
|
]
|
2026-03-19 18:42:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_phone_query(query: str) -> bool:
|
|
|
|
|
if not _PHONE_QUERY_PATTERN.fullmatch(query):
|
|
|
|
|
return False
|
|
|
|
|
digits_count = sum(char.isdigit() for char in query)
|
|
|
|
|
return digits_count >= 4
|