From f2d3c8b544e4ab7063f120b6aa28e57e52708058 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 26 Aug 2026 15:57:31 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B2=80=EC=83=89=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/repository/UserSocialAccountRepository.java | 5 +++++ .../picke/domain/user/repository/UserRepository.java | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java b/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java index e1a75c98..b3c91eac 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java +++ b/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java @@ -4,6 +4,7 @@ import com.swyp.picke.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface UserSocialAccountRepository extends JpaRepository { @@ -14,4 +15,8 @@ Optional findByProviderAndProviderUserId( Optional findByUser(User user); void deleteByUser(User user); + + List findByProviderEmailContaining(String keyword); + + List findByUser_IdIn(List userIds); } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java index bc4a352f..0911ecc3 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java @@ -5,6 +5,8 @@ import com.swyp.picke.domain.user.enums.UserStatus; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -29,4 +31,12 @@ public interface UserRepository extends JpaRepository { List findAllByStatus(UserStatus status); List findAllByRole(UserRole role); + + @Query(""" + select u from User u + where u.nickname like concat('%', :keyword, '%') + or u.userTag like concat('%', :keyword, '%') + order by u.id desc + """) + Slice searchByNicknameOrUserTag(@Param("keyword") String keyword, Pageable pageable); } From 1f06460401fe1b2dfca52160a216aab4f6fc850f Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 26 Aug 2026 15:57:35 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B2=80=EC=83=89=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dto/user/response/AdminUserSearchResponse.java | 8 ++++++++ .../admin/dto/user/response/AdminUserSummaryResponse.java | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java new file mode 100644 index 00000000..b0592eef --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.user.response; + +import java.util.List; + +public record AdminUserSearchResponse( + List items, + boolean hasNext +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java new file mode 100644 index 00000000..7273638a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.user.response; + +public record AdminUserSummaryResponse( + Long userId, + String userTag, + String nickname, + String email +) {} From 6563d8500ad06ba0cc47a049a6afbf118aad8b2f Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 26 Aug 2026 15:57:39 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B2=80=EC=83=89=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/service/AdminUserService.java | 71 ++++++++++++ .../admin/service/AdminUserServiceTest.java | 107 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java create mode 100644 src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java new file mode 100644 index 00000000..67c16989 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java @@ -0,0 +1,71 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSummaryResponse; +import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminUserService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final UserRepository userRepository; + private final UserSocialAccountRepository userSocialAccountRepository; + + public AdminUserSearchResponse searchUsers(String keyword, int page, int size) { + int pageNumber = Math.max(0, page); + int pageSize = size <= 0 ? DEFAULT_PAGE_SIZE : size; + + // 닉네임/유저태그(로컬+소셜 유저 공통)와 이메일(소셜 로그인 유저만 보유)은 + // 서로 다른 테이블에서 나오는 결과라 각각 조회 후 userId 기준으로 합친다. + Slice byNicknameOrTag = userRepository.searchByNicknameOrUserTag( + keyword, PageRequest.of(pageNumber, pageSize)); + List byEmail = userSocialAccountRepository.findByProviderEmailContaining(keyword); + + Map merged = new LinkedHashMap<>(); + byNicknameOrTag.getContent().forEach(user -> merged.put(user.getId(), user)); + byEmail.forEach(socialAccount -> merged.put(socialAccount.getUser().getId(), socialAccount.getUser())); + + List users = merged.values().stream() + .sorted((a, b) -> Long.compare(b.getId(), a.getId())) + .limit(pageSize) + .toList(); + + Map emailByUserId = emailByUserId(users); + + List items = users.stream() + .map(user -> new AdminUserSummaryResponse( + user.getId(), + user.getUserTag(), + user.getNickname(), + emailByUserId.get(user.getId()) + )) + .toList(); + + boolean hasNext = byNicknameOrTag.hasNext() || merged.size() > pageSize; + + return new AdminUserSearchResponse(items, hasNext); + } + + private Map emailByUserId(List users) { + List userIds = users.stream().map(User::getId).toList(); + Map emailByUserId = new LinkedHashMap<>(); + for (UserSocialAccount socialAccount : userSocialAccountRepository.findByUser_IdIn(userIds)) { + emailByUserId.putIfAbsent(socialAccount.getUser().getId(), socialAccount.getProviderEmail()); + } + return emailByUserId; + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java new file mode 100644 index 00000000..2c959ab6 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AdminUserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserSocialAccountRepository userSocialAccountRepository; + + @InjectMocks + private AdminUserService adminUserService; + + private User newUser(long id, String userTag, String nickname) { + User user = User.builder() + .userTag(userTag) + .nickname(nickname) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Test + @DisplayName("닉네임 또는 유저태그로 매칭된 유저를 조회한다") + void searchUsers_matchesByNicknameOrUserTag() { + User user = newUser(1L, "picke_abcd", "민초러버"); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())).thenReturn(List.of()); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of()); + + AdminUserSearchResponse response = adminUserService.searchUsers("민초", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).userId()).isEqualTo(1L); + assertThat(response.items().get(0).nickname()).isEqualTo("민초러버"); + assertThat(response.items().get(0).email()).isNull(); + } + + @Test + @DisplayName("이메일로 매칭된 소셜 로그인 유저를 조회하고 이메일을 함께 내려준다") + void searchUsers_matchesByEmail() { + User user = newUser(2L, "picke_efgh", "질문왕"); + UserSocialAccount socialAccount = UserSocialAccount.builder() + .user(user) + .provider("GOOGLE") + .providerUserId("google-1") + .providerEmail("user@gmail.com") + .build(); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())) + .thenReturn(List.of(socialAccount)); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of(socialAccount)); + + AdminUserSearchResponse response = adminUserService.searchUsers("gmail", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).userId()).isEqualTo(2L); + assertThat(response.items().get(0).email()).isEqualTo("user@gmail.com"); + } + + @Test + @DisplayName("로컬 로그인 유저는 소셜 계정이 없어도 닉네임/유저태그로 조회된다") + void searchUsers_localLoginUser_hasNoEmail() { + User user = newUser(3L, "picke_ijkl", "로컬유저"); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())).thenReturn(List.of()); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of()); + + AdminUserSearchResponse response = adminUserService.searchUsers("로컬", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).email()).isNull(); + } +} From afd2f53cd6eed7036d80f250453576d8c2eb5a51 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 26 Aug 2026 15:57:43 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B2=80=EC=83=89=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminUserController.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java new file mode 100644 index 00000000..cdd59638 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java @@ -0,0 +1,33 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.admin.service.AdminUserService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 유저 API", description = "관리자 페이지에서 유저를 조회하기 위한 API") +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminUserController { + + private final AdminUserService adminUserService; + + @Operation(summary = "유저 검색", description = "닉네임/유저태그/이메일(소셜 로그인 유저만 해당)로 유저를 검색한다. 로컬 로그인 유저는 닉네임/유저태그로만 검색된다.") + @GetMapping("/search") + public ApiResponse searchUsers( + @RequestParam String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess(adminUserService.searchUsers(keyword, page, size)); + } +} From a626b96404789903822f814ab2f667262b65b393 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 26 Aug 2026 15:57:47 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B2=80=EC=83=89=20API=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/user-api.md | 44 +++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/api-specs/user-api.md b/docs/api-specs/user-api.md index 78594f02..16c49d2f 100644 --- a/docs/api-specs/user-api.md +++ b/docs/api-specs/user-api.md @@ -438,9 +438,47 @@ --- -## 4. 에러 코드 +## 4. 관리자 API -### 4.1 공통 에러 코드 +`ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다 (`Authorization: Bearer {access_token}`). + +### 4.1 `GET /api/v1/admin/users/search` + +닉네임 / 유저태그 / 이메일로 유저를 검색합니다. 어드민 페이지에서 특정 유저에게 테스트 알림을 보내는 등, 내부 `userId`를 알아야 하는 다른 관리자 API의 입력값을 찾기 위한 용도입니다. + +이메일은 소셜 로그인 유저의 `providerEmail`만 검색 대상이며, **로컬 로그인 유저는 이메일 필드 자체가 없어 닉네임/유저태그로만 검색**됩니다. + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `keyword` | `string` | Y | 닉네임/유저태그/이메일 중 하나라도 부분 일치하면 매칭 | +| `page` | `integer` | N | 페이지 번호 (기본값 `0`) | +| `size` | `integer` | N | 페이지 크기 (기본값 `20`) | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { "userId": 123, "userTag": "picke_abcd", "nickname": "민초러버", "email": "user@gmail.com" }, + { "userId": 124, "userTag": "picke_efgh", "nickname": "로컬유저", "email": null } + ], + "hasNext": false + }, + "error": null +} +``` + +`email`은 소셜 계정이 없는 유저(로컬 로그인)의 경우 `null`로 내려갑니다. + +--- + +## 5. 에러 코드 + +### 5.1 공통 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|------| @@ -452,7 +490,7 @@ | `USER_SUSPENDED` | `403` | 일정 기간 이용 정지된 사용자 | | `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | -### 4.2 사용자 에러 코드 +### 5.2 사용자 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|------|