Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions docs/api-specs/user-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 설명 |
|------------|:-----------:|------|
Expand All @@ -452,7 +490,7 @@
| `USER_SUSPENDED` | `403` | 일정 기간 이용 정지된 사용자 |
| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 |

### 4.2 사용자 에러 코드
### 5.2 사용자 에러 코드

| Error Code | HTTP Status | 설명 |
|------------|:-----------:|------|
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AdminUserSearchResponse> searchUsers(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return ApiResponse.onSuccess(adminUserService.searchUsers(keyword, page, size));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.swyp.picke.domain.admin.dto.user.response;

import java.util.List;

public record AdminUserSearchResponse(
List<AdminUserSummaryResponse> items,
boolean hasNext
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.swyp.picke.domain.admin.dto.user.response;

public record AdminUserSummaryResponse(
Long userId,
String userTag,
String nickname,
String email
) {}
Original file line number Diff line number Diff line change
@@ -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<User> byNicknameOrTag = userRepository.searchByNicknameOrUserTag(
keyword, PageRequest.of(pageNumber, pageSize));
List<UserSocialAccount> byEmail = userSocialAccountRepository.findByProviderEmailContaining(keyword);

Map<Long, User> merged = new LinkedHashMap<>();
byNicknameOrTag.getContent().forEach(user -> merged.put(user.getId(), user));
byEmail.forEach(socialAccount -> merged.put(socialAccount.getUser().getId(), socialAccount.getUser()));

List<User> users = merged.values().stream()
.sorted((a, b) -> Long.compare(b.getId(), a.getId()))
.limit(pageSize)
.toList();

Map<Long, String> emailByUserId = emailByUserId(users);

List<AdminUserSummaryResponse> 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<Long, String> emailByUserId(List<User> users) {
List<Long> userIds = users.stream().map(User::getId).toList();
Map<Long, String> emailByUserId = new LinkedHashMap<>();
for (UserSocialAccount socialAccount : userSocialAccountRepository.findByUser_IdIn(userIds)) {
emailByUserId.putIfAbsent(socialAccount.getUser().getId(), socialAccount.getProviderEmail());
}
return emailByUserId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserSocialAccount, Long> {
Expand All @@ -14,4 +15,8 @@ Optional<UserSocialAccount> findByProviderAndProviderUserId(
Optional<UserSocialAccount> findByUser(User user);

void deleteByUser(User user);

List<UserSocialAccount> findByProviderEmailContaining(String keyword);

List<UserSocialAccount> findByUser_IdIn(List<Long> userIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,4 +31,12 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findAllByStatus(UserStatus status);

List<User> 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<User> searchByNicknameOrUserTag(@Param("keyword") String keyword, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading