diff --git a/app/follow/repository.py b/app/follow/repository.py index 315d210f..f9b83b01 100644 --- a/app/follow/repository.py +++ b/app/follow/repository.py @@ -2,7 +2,7 @@ from sqlalchemy.dialects.postgresql import insert from app.database.deps import SessionDep -from app.users.models import user_follow_table +from app.users.models import User, user_follow_table from app.utils.dependency import dependency @@ -59,3 +59,43 @@ async def remove_all_for_user(self, *, user_id: int): | (user_follow_table.c.following_id == user_id) ) ) + + async def find_followers_by_user_id( + self, *, user_id: int, cursor: int | None = None, limit: int = 10 + ) -> list[User]: + stmt = ( + select(User) + .join(user_follow_table, User.id == user_follow_table.c.follower_id) + .where( + user_follow_table.c.following_id == user_id, + User.deleted_at.is_(None), + ) + .order_by(User.id.desc()) + .limit(limit) + ) + + if cursor is not None: + stmt = stmt.where(User.id < cursor) + + result = await self.session.scalars(stmt) + return list(result.all()) + + async def find_followings_by_user_id( + self, *, user_id: int, cursor: int | None = None, limit: int = 10 + ) -> list[User]: + stmt = ( + select(User) + .join(user_follow_table, User.id == user_follow_table.c.following_id) + .where( + user_follow_table.c.follower_id == user_id, + User.deleted_at.is_(None), + ) + .order_by(User.id.desc()) + .limit(limit) + ) + + if cursor is not None: + stmt = stmt.where(User.id < cursor) + + result = await self.session.scalars(stmt) + return list(result.all()) diff --git a/app/like/repository.py b/app/like/repository.py index dab958f8..8fd603d2 100644 --- a/app/like/repository.py +++ b/app/like/repository.py @@ -52,16 +52,24 @@ async def remove_all_by_user(self, *, user_id: int): delete(post_like_table).where(post_like_table.c.user_id == user_id) ) - async def find_users_by_post_id(self, *, post_id: int) -> list[User]: - result = await self.session.scalars( + async def find_users_by_post_id( + self, *, post_id: int, cursor: int | None = None, limit: int = 10 + ) -> list[User]: + stmt = ( select(User) .join(post_like_table, User.id == post_like_table.c.user_id) .where( post_like_table.c.post_id == post_id, User.deleted_at.is_(None), ) - .order_by(User.id) + .order_by(User.id.desc()) + .limit(limit) ) + + if cursor is not None: + stmt = stmt.where(User.id < cursor) + + result = await self.session.scalars(stmt) return list(result.all()) diff --git a/app/posts/router.py b/app/posts/router.py index 5e955bd4..1291659d 100644 --- a/app/posts/router.py +++ b/app/posts/router.py @@ -10,7 +10,7 @@ from app.core.router import create_router from app.storage.schemas import ImageUrl from app.storage.service import ImageMetadata, create_presigned_upload_url -from app.users.schemas import UserRead +from app.users.schemas import UserListCursor, UserRead from .schemas import ( PostCompactRead, @@ -25,6 +25,7 @@ router.include_router(comments_router, prefix="/{post_id}/comments") POST_CURSOR_LIMIT = 10 +USER_LIST_LIMIT = 20 @router.get("/home") @@ -165,8 +166,13 @@ async def unlike_post( async def read_post_liked_users( post_service: PostService, post_id: int, + pagination: PaginationDep, current_user: CurrentUser, -) -> list[UserRead]: +) -> Page[UserRead]: + cursor = UserListCursor.decode(pagination.cursor) return await post_service.get_post_liked_users( - post_id=post_id, current_user=current_user + post_id=post_id, + cursor=cursor, + limit=USER_LIST_LIMIT, + current_user=current_user, ) diff --git a/app/posts/service.py b/app/posts/service.py index 392bc94e..25426758 100644 --- a/app/posts/service.py +++ b/app/posts/service.py @@ -12,7 +12,7 @@ from app.storage.deps import S3ClientDep from app.storage.service import get_image_metadata from app.users.models import User -from app.users.schemas import UserRead +from app.users.schemas import UserListCursor, UserRead from app.utils.dependency import dependency from .models import Post, PostImage @@ -283,13 +283,30 @@ async def unlike_post(self, *, post_id: int, current_user: User): ) async def get_post_liked_users( - self, *, post_id: int, current_user: User - ) -> list[UserRead]: + self, + *, + post_id: int, + cursor: UserListCursor | None, + limit: int, + current_user: User, + ) -> Page[UserRead]: post = await self.post_repository.find_by_id(post_id=post_id) if not post: raise HTTPException(status_code=404, detail="게시물을 찾을 수 없습니다.") - users = await self.post_like_repository.find_users_by_post_id(post_id=post_id) - return [ + cursor_id = cursor.user_id if cursor else None + users = await self.post_like_repository.find_users_by_post_id( + post_id=post_id, cursor=cursor_id, limit=limit + ) + + user_reads = [ UserRead.from_user(user, current_user_id=current_user.id) for user in users ] + + next_cursor = ( + UserListCursor(user_id=users[-1].id).encode() + if len(users) == limit + else None + ) + + return Page[UserRead](list=user_reads, next_cursor=next_cursor) diff --git a/app/users/router.py b/app/users/router.py index 50e30ad9..9ca108ac 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -2,12 +2,16 @@ from app.auth.deps import CurrentUser from app.auth.schemas import AuthDeleteRequest +from app.common.deps import PaginationDep +from app.common.schemas import Page from app.core.router import create_router from app.storage.schemas import ImageUrl -from .schemas import UserProvider, UserReadProfile, UserUpdate +from .schemas import UserListCursor, UserProvider, UserRead, UserReadProfile, UserUpdate from .service import UserImageMeta, UserService +USER_LIST_LIMIT = 20 + router = create_router() @@ -81,3 +85,35 @@ async def unfollow_user( await user_service.unfollow( to_user_handle=to_user_handle, current_user=current_user ) + + +@router.get("/{user_handle}/followers", status_code=status.HTTP_200_OK) +async def read_user_followers( + user_service: UserService, + user_handle: str, + pagination: PaginationDep, + current_user: CurrentUser, +) -> Page[UserRead]: + cursor = UserListCursor.decode(pagination.cursor) + return await user_service.get_user_followers( + user_handle=user_handle, + cursor=cursor, + limit=USER_LIST_LIMIT, + current_user=current_user, + ) + + +@router.get("/{user_handle}/followings", status_code=status.HTTP_200_OK) +async def read_user_followings( + user_service: UserService, + user_handle: str, + pagination: PaginationDep, + current_user: CurrentUser, +) -> Page[UserRead]: + cursor = UserListCursor.decode(pagination.cursor) + return await user_service.get_user_followings( + user_handle=user_handle, + cursor=cursor, + limit=USER_LIST_LIMIT, + current_user=current_user, + ) diff --git a/app/users/schemas.py b/app/users/schemas.py index 3c43a12e..eac250d7 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -1,3 +1,4 @@ +import base64 from datetime import date from typing import Annotated @@ -144,3 +145,18 @@ class UserProvider(APISchema): email: bool = False kakao: bool = False apple: bool = False + + +class UserListCursor(APISchema): + user_id: int + + def encode(self) -> str: + payload = self.model_dump_json().encode() + return base64.urlsafe_b64encode(payload).decode() + + @classmethod + def decode(cls, token: str | None) -> "UserListCursor | None": + if not token: + return None + data = base64.urlsafe_b64decode(token.encode()).decode() + return cls.model_validate_json(data) diff --git a/app/users/service.py b/app/users/service.py index 3cf9ce33..aff54571 100644 --- a/app/users/service.py +++ b/app/users/service.py @@ -8,7 +8,7 @@ from app.auth.schemas import AuthDeleteRequest from app.auth.service import AuthInfoService from app.comments.repository import CommentRepository -from app.common.schemas import ValidationResult +from app.common.schemas import Page, ValidationResult from app.follow.repository import FollowRepository from app.like.repository import CommentLikeRepository, PostLikeRepository from app.posts.repository import PostRepository @@ -23,7 +23,9 @@ from .schemas import ( UserCreate, UserImageMeta, + UserListCursor, UserProvider, + UserRead, UserReadProfile, UserUpdate, ) @@ -225,6 +227,66 @@ async def unfollow(self, *, to_user_handle: str, current_user: CurrentUser): to_user_id=target_user_id, ) + async def get_user_followers( + self, + *, + user_handle: str, + cursor: UserListCursor | None, + limit: int, + current_user: CurrentUser, + ) -> Page[UserRead]: + user_id = await self.user_repository.find_id_by_handle(user_handle=user_handle) + if not user_id: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + + cursor_id = cursor.user_id if cursor else None + followers = await self.follow_repository.find_followers_by_user_id( + user_id=user_id, cursor=cursor_id, limit=limit + ) + + user_reads = [ + UserRead.from_user(user=follower, current_user_id=current_user.id) + for follower in followers + ] + + next_cursor = ( + UserListCursor(user_id=followers[-1].id).encode() + if len(followers) == limit + else None + ) + + return Page[UserRead](list=user_reads, next_cursor=next_cursor) + + async def get_user_followings( + self, + *, + user_handle: str, + cursor: UserListCursor | None, + limit: int, + current_user: CurrentUser, + ) -> Page[UserRead]: + user_id = await self.user_repository.find_id_by_handle(user_handle=user_handle) + if not user_id: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + + cursor_id = cursor.user_id if cursor else None + followings = await self.follow_repository.find_followings_by_user_id( + user_id=user_id, cursor=cursor_id, limit=limit + ) + + user_reads = [ + UserRead.from_user(user=following, current_user_id=current_user.id) + for following in followings + ] + + next_cursor = ( + UserListCursor(user_id=followings[-1].id).encode() + if len(followings) == limit + else None + ) + + return Page[UserRead](list=user_reads, next_cursor=next_cursor) + async def _validate_target_user( self, to_user_handle: str, current_user: CurrentUser ):