From e95f8b03200aa7d8219a27ede99ee93a0163a7cf Mon Sep 17 00:00:00 2001 From: sehooh5 Date: Sun, 25 Jan 2026 17:33:21 +0900 Subject: [PATCH 1/3] add router for follower, following user list --- app/users/router.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/users/router.py b/app/users/router.py index 50e30ad9..a63596e2 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -5,7 +5,7 @@ from app.core.router import create_router from app.storage.schemas import ImageUrl -from .schemas import UserProvider, UserReadProfile, UserUpdate +from .schemas import UserProvider, UserRead, UserReadProfile, UserUpdate from .service import UserImageMeta, UserService router = create_router() @@ -81,3 +81,17 @@ 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, current_user: CurrentUser +) -> list[UserRead]: + return [] + + +@router.get("/{user_handle}/followings", status_code=status.HTTP_200_OK) +async def read_user_followings( + user_service: UserService, user_handle: str, current_user: CurrentUser +) -> list[UserRead]: + return [] From 99b6666c055a6d1871c0b5fff53551ed846702fd Mon Sep 17 00:00:00 2001 From: sehooh5 Date: Sun, 25 Jan 2026 17:53:41 +0900 Subject: [PATCH 2/3] update service, repository for follow list --- app/follow/repository.py | 24 +++++++++++++++++++++++- app/like/repository.py | 1 - app/users/router.py | 8 ++++++-- app/users/service.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/app/follow/repository.py b/app/follow/repository.py index 315d210f..55bc496a 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,25 @@ 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) -> list[User]: + result = await self.session.scalars( + 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), + ) + ) + return list(result.all()) + + async def find_followings_by_user_id(self, *, user_id: int) -> list[User]: + result = await self.session.scalars( + 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), + ) + ) + return list(result.all()) diff --git a/app/like/repository.py b/app/like/repository.py index dab958f8..dc629aa5 100644 --- a/app/like/repository.py +++ b/app/like/repository.py @@ -60,7 +60,6 @@ async def find_users_by_post_id(self, *, post_id: int) -> list[User]: post_like_table.c.post_id == post_id, User.deleted_at.is_(None), ) - .order_by(User.id) ) return list(result.all()) diff --git a/app/users/router.py b/app/users/router.py index a63596e2..013f4b59 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -87,11 +87,15 @@ async def unfollow_user( async def read_user_followers( user_service: UserService, user_handle: str, current_user: CurrentUser ) -> list[UserRead]: - return [] + return await user_service.get_user_followers( + user_handle=user_handle, 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, current_user: CurrentUser ) -> list[UserRead]: - return [] + return await user_service.get_user_followings( + user_handle=user_handle, current_user=current_user + ) diff --git a/app/users/service.py b/app/users/service.py index 3cf9ce33..a0dd4b97 100644 --- a/app/users/service.py +++ b/app/users/service.py @@ -24,6 +24,7 @@ UserCreate, UserImageMeta, UserProvider, + UserRead, UserReadProfile, UserUpdate, ) @@ -225,6 +226,36 @@ 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, current_user: CurrentUser + ) -> list[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="사용자를 찾을 수 없습니다.") + + followers = await self.follow_repository.find_followers_by_user_id( + user_id=user_id + ) + return [ + UserRead.from_user(user=follower, current_user_id=current_user.id) + for follower in followers + ] + + async def get_user_followings( + self, *, user_handle: str, current_user: CurrentUser + ) -> list[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="사용자를 찾을 수 없습니다.") + + followings = await self.follow_repository.find_followings_by_user_id( + user_id=user_id + ) + return [ + UserRead.from_user(user=following, current_user_id=current_user.id) + for following in followings + ] + async def _validate_target_user( self, to_user_handle: str, current_user: CurrentUser ): From f7b7da232946180b54952bd36fe7219de9aace85 Mon Sep 17 00:00:00 2001 From: sehooh5 Date: Sun, 25 Jan 2026 18:38:17 +0900 Subject: [PATCH 3/3] add pagination for user list response --- app/follow/repository.py | 26 +++++++++++++++++---- app/like/repository.py | 13 +++++++++-- app/posts/router.py | 12 +++++++--- app/posts/service.py | 27 ++++++++++++++++++---- app/users/router.py | 32 ++++++++++++++++++++------ app/users/schemas.py | 16 +++++++++++++ app/users/service.py | 49 ++++++++++++++++++++++++++++++++-------- 7 files changed, 145 insertions(+), 30 deletions(-) diff --git a/app/follow/repository.py b/app/follow/repository.py index 55bc496a..f9b83b01 100644 --- a/app/follow/repository.py +++ b/app/follow/repository.py @@ -60,24 +60,42 @@ async def remove_all_for_user(self, *, user_id: int): ) ) - async def find_followers_by_user_id(self, *, user_id: int) -> list[User]: - result = await self.session.scalars( + 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) -> list[User]: - result = await self.session.scalars( + 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 dc629aa5..8fd603d2 100644 --- a/app/like/repository.py +++ b/app/like/repository.py @@ -52,15 +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.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 013f4b59..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, UserRead, UserReadProfile, UserUpdate +from .schemas import UserListCursor, UserProvider, UserRead, UserReadProfile, UserUpdate from .service import UserImageMeta, UserService +USER_LIST_LIMIT = 20 + router = create_router() @@ -85,17 +89,31 @@ async def unfollow_user( @router.get("/{user_handle}/followers", status_code=status.HTTP_200_OK) async def read_user_followers( - user_service: UserService, user_handle: str, current_user: CurrentUser -) -> list[UserRead]: + 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, current_user=current_user + 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, current_user: CurrentUser -) -> list[UserRead]: + 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, current_user=current_user + 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 a0dd4b97..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,6 +23,7 @@ from .schemas import ( UserCreate, UserImageMeta, + UserListCursor, UserProvider, UserRead, UserReadProfile, @@ -227,35 +228,65 @@ async def unfollow(self, *, to_user_handle: str, current_user: CurrentUser): ) async def get_user_followers( - self, *, user_handle: str, current_user: CurrentUser - ) -> list[UserRead]: + 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 + user_id=user_id, cursor=cursor_id, limit=limit ) - return [ + + 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, current_user: CurrentUser - ) -> list[UserRead]: + 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 + user_id=user_id, cursor=cursor_id, limit=limit ) - return [ + + 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 ):