Skip to content

Commit 69aee67

Browse files
Merge pull request #191 from SSASINSA/feature/149_admin_user_managment
feat: 관리자 계정 관리 로직 추가 (#149)
2 parents 1ac7834 + 7e6e19b commit 69aee67

File tree

16 files changed

+433
-7
lines changed

16 files changed

+433
-7
lines changed

src/main/java/com/ssasinsa/wearagain/domain/auth/repository/AdminUserRepository.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import com.ssasinsa.wearagain.domain.auth.entity.AdminUser;
44
import java.util.Optional;
55
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
67

7-
public interface AdminUserRepository extends JpaRepository<AdminUser, Long> {
8+
public interface AdminUserRepository extends JpaRepository<AdminUser, Long>, JpaSpecificationExecutor<AdminUser> {
89

910
Optional<AdminUser> findByEmail(String email);
1011

src/main/java/com/ssasinsa/wearagain/domain/event/docs/EventExamples.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ private EventExamples() {
407407
"displayOrder": 1
408408
}
409409
],
410+
"optionDepth": 3,
410411
"options": [
411412
{
412413
"optionId": 2001,

src/main/java/com/ssasinsa/wearagain/domain/event/dto/response/EventDetailResponse.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ public record EventDetailResponse(
3636
@Schema(description = "이미지 목록")
3737
List<EventDetailImageResponse> images,
3838

39+
@Schema(description = "옵션 최대 깊이", example = "2")
40+
int optionDepth,
41+
3942
@Schema(description = "옵션 트리")
4043
List<EventDetailOptionResponse> options
4144
) {

src/main/java/com/ssasinsa/wearagain/domain/event/service/EventUserServiceImpl.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ private EventDetailResponse mapToDetail(
465465
List<EventOption> rootOptions,
466466
Map<Long, Long> counts
467467
) {
468+
int optionDepth = calculateOptionDepth(rootOptions);
468469
List<EventDetailImageResponse> images = event.getImages()
469470
.stream()
470471
.sorted(IMAGE_ORDER)
@@ -495,6 +496,7 @@ private EventDetailResponse mapToDetail(
495496
event.getEndDate(),
496497
event.getStatus().name(),
497498
images,
499+
optionDepth,
498500
options
499501
);
500502
}
@@ -522,6 +524,24 @@ private EventDetailOptionResponse mapOption(EventOption option, Map<Long, Long>
522524
);
523525
}
524526

527+
private int calculateOptionDepth(List<EventOption> rootOptions) {
528+
if (rootOptions == null || rootOptions.isEmpty()) {
529+
return 0;
530+
}
531+
int maxDepth = 0;
532+
Deque<OptionLevel> stack = new ArrayDeque<>();
533+
rootOptions.forEach(option -> stack.push(new OptionLevel(option, 1)));
534+
while (!stack.isEmpty()) {
535+
OptionLevel current = stack.pop();
536+
maxDepth = Math.max(maxDepth, current.depth());
537+
List<EventOption> children = toDistinctOptions(current.option().getChildOptions());
538+
for (EventOption child : children) {
539+
stack.push(new OptionLevel(child, current.depth() + 1));
540+
}
541+
}
542+
return maxDepth;
543+
}
544+
525545
private Integer safeToInteger(long value) {
526546
if (value > Integer.MAX_VALUE) {
527547
return Integer.MAX_VALUE;
@@ -609,4 +629,7 @@ private Map<Long, String> loadThumbnails(List<Event> events) {
609629
return thumbnails;
610630
}
611631

632+
private record OptionLevel(EventOption option, int depth) {
633+
}
634+
612635
}

src/main/java/com/ssasinsa/wearagain/domain/user/controller/UserAdminController.java

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@
77
import com.ssasinsa.wearagain.domain.user.dto.admin.AdminParticipantStatsResponse;
88
import com.ssasinsa.wearagain.domain.user.dto.admin.AdminParticipantSuspensionRequest;
99
import com.ssasinsa.wearagain.domain.user.dto.admin.AdminParticipantUpdateRequest;
10+
import com.ssasinsa.wearagain.domain.user.dto.admin.AdminManagedUserListResponse;
11+
import com.ssasinsa.wearagain.domain.user.service.AdminManagedUserService;
1012
import com.ssasinsa.wearagain.domain.user.service.UserAdminService;
13+
import com.ssasinsa.wearagain.domain.auth.entity.AdminStatus;
14+
import com.ssasinsa.wearagain.domain.auth.infrastructure.security.AdminAuthenticatedUser;
15+
import com.ssasinsa.wearagain.domain.user.dto.admin.AdminManagedUserKeywordScope;
1116
import jakarta.validation.Valid;
1217
import lombok.RequiredArgsConstructor;
1318
import org.springframework.data.domain.PageRequest;
1419
import org.springframework.data.domain.Pageable;
1520
import org.springframework.http.ResponseEntity;
1621
import org.springframework.security.access.prepost.PreAuthorize;
22+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
23+
import org.springframework.web.bind.annotation.DeleteMapping;
1724
import org.springframework.web.bind.annotation.GetMapping;
1825
import org.springframework.web.bind.annotation.PathVariable;
1926
import org.springframework.web.bind.annotation.PutMapping;
@@ -24,16 +31,17 @@
2431
import io.swagger.v3.oas.annotations.tags.Tag;
2532

2633
@RestController
27-
@RequestMapping("/api/v1/admin/participants")
34+
@RequestMapping("/api/v1/admin")
2835
@PreAuthorize("hasAnyRole('ADMIN','SUPER_ADMIN')")
2936
@RequiredArgsConstructor
3037
@Tag(name = AdminParticipantApiDocs.TAG_NAME, description = AdminParticipantApiDocs.TAG_DESCRIPTION)
3138
public class UserAdminController {
3239

3340
private final UserAdminService userAdminService;
41+
private final AdminManagedUserService adminManagedUserService;
3442

3543
@AdminParticipantApiDocs.GetParticipants
36-
@GetMapping
44+
@GetMapping("/participants")
3745
public ResponseEntity<AdminParticipantListResponse> getParticipants(
3846
@RequestParam(value = "suspended", required = false) Boolean suspended,
3947
@RequestParam(value = "sortBy", defaultValue = "CREATED_DESC") String sortBy,
@@ -54,7 +62,7 @@ public ResponseEntity<AdminParticipantListResponse> getParticipants(
5462
}
5563

5664
@AdminParticipantApiDocs.GetParticipantDetail
57-
@GetMapping("/{participantId}")
65+
@GetMapping("/participants/{participantId}")
5866
public ResponseEntity<AdminParticipantDetailResponse> getParticipantDetail(
5967
@PathVariable Long participantId
6068
) {
@@ -63,14 +71,14 @@ public ResponseEntity<AdminParticipantDetailResponse> getParticipantDetail(
6371
}
6472

6573
@AdminParticipantApiDocs.GetParticipantStats
66-
@GetMapping("/stats")
74+
@GetMapping("/participants/stats")
6775
public ResponseEntity<AdminParticipantStatsResponse> getParticipantStats() {
6876
AdminParticipantStatsResponse response = userAdminService.getParticipantStats();
6977
return ResponseEntity.ok(response);
7078
}
7179

7280
@AdminParticipantApiDocs.UpdateParticipantBalance
73-
@PutMapping("/{participantId}")
81+
@PutMapping("/participants/{participantId}")
7482
public ResponseEntity<AdminParticipantDetailResponse> updateParticipant(
7583
@PathVariable Long participantId,
7684
@Valid @RequestBody AdminParticipantUpdateRequest request
@@ -79,7 +87,7 @@ public ResponseEntity<AdminParticipantDetailResponse> updateParticipant(
7987
return ResponseEntity.ok(response);
8088
}
8189

82-
@PutMapping("/{participantId}/suspension")
90+
@PutMapping("/participants/{participantId}/suspension")
8391
@AdminParticipantApiDocs.UpdateSuspension
8492
public ResponseEntity<AdminParticipantDetailResponse> updateSuspension(
8593
@PathVariable Long participantId,
@@ -88,4 +96,35 @@ public ResponseEntity<AdminParticipantDetailResponse> updateSuspension(
8896
AdminParticipantDetailResponse response = userAdminService.updateSuspension(participantId, request);
8997
return ResponseEntity.ok(response);
9098
}
99+
100+
@AdminParticipantApiDocs.GetAdminUsers
101+
@GetMapping("/admin-users")
102+
public ResponseEntity<AdminManagedUserListResponse> getAdminUsers(
103+
@RequestParam(value = "status", required = false) AdminStatus status,
104+
@RequestParam(value = "sortBy", defaultValue = "CREATED_DESC") String sortBy,
105+
@RequestParam(value = "keyword", required = false) String keyword,
106+
@RequestParam(value = "keywordScope", required = false) String keywordScope,
107+
@RequestParam(value = "page", defaultValue = "0") int page,
108+
@RequestParam(value = "size", defaultValue = "20") int size
109+
) {
110+
AdminManagedUserListResponse response = adminManagedUserService.getAdminUsers(
111+
status,
112+
keyword,
113+
AdminManagedUserKeywordScope.from(keywordScope),
114+
page,
115+
size,
116+
sortBy
117+
);
118+
return ResponseEntity.ok(response);
119+
}
120+
121+
@AdminParticipantApiDocs.DeleteAdminUser
122+
@DeleteMapping("/admin-users/{adminUserId}")
123+
public ResponseEntity<Void> deleteAdminUser(
124+
@PathVariable Long adminUserId,
125+
@AuthenticationPrincipal AdminAuthenticatedUser principal
126+
) {
127+
adminManagedUserService.softDeleteAdminUser(adminUserId, principal.adminId());
128+
return ResponseEntity.noContent().build();
129+
}
91130
}

src/main/java/com/ssasinsa/wearagain/domain/user/docs/AdminParticipantApiDocs.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,31 @@ private AdminParticipantApiDocs() {
8888
)
8989
public @interface UpdateSuspension {
9090
}
91+
92+
@SecurityRequirement(name = "adminJWT")
93+
@Target(ElementType.METHOD)
94+
@Retention(RetentionPolicy.RUNTIME)
95+
@ApiDoc(
96+
summary = "관리자 계정 목록 조회",
97+
description = """
98+
ADMIN 또는 SUPER_ADMIN 권한으로 관리자 계정 목록을 조회합니다.
99+
status(상태), keyword/keywordScope(EMAIL|NAME|ALL), sortBy(CREATED_DESC, CREATED_ASC, NAME_ASC, NAME_DESC),
100+
page/size 파라미터를 지원합니다.
101+
""",
102+
responseSchema = com.ssasinsa.wearagain.domain.user.dto.admin.AdminManagedUserListResponse.class,
103+
responseExample = AdminParticipantExamples.ADMIN_USER_LIST_RESPONSE
104+
)
105+
public @interface GetAdminUsers {
106+
}
107+
108+
@SecurityRequirement(name = "adminJWT")
109+
@Target(ElementType.METHOD)
110+
@Retention(RetentionPolicy.RUNTIME)
111+
@ApiDoc(
112+
summary = "관리자 계정 비활성화",
113+
description = "다른 관리자 계정을 소프트 삭제(INACTIVE) 처리합니다. SUPER_ADMIN 계정이나 자기 자신은 삭제할 수 없습니다.",
114+
responseSchema = Void.class
115+
)
116+
public @interface DeleteAdminUser {
117+
}
91118
}

src/main/java/com/ssasinsa/wearagain/domain/user/docs/AdminParticipantExamples.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,36 @@ public class AdminParticipantExamples {
167167
]
168168
}
169169
""";
170+
171+
public static final String ADMIN_USER_LIST_RESPONSE = """
172+
{
173+
"content": [
174+
{
175+
"adminUserId": 1,
176+
"email": "super@wearagain.kr",
177+
"name": "최고 관리자",
178+
"role": "SUPER_ADMIN",
179+
"status": "ACTIVE",
180+
"lastLoginAt": "2025-12-07T05:20:00Z",
181+
"createdAt": "2025-11-01T02:00:00Z",
182+
"updatedAt": "2025-12-05T09:00:00Z"
183+
},
184+
{
185+
"adminUserId": 5,
186+
"email": "event.admin@wearagain.kr",
187+
"name": "행사 관리자",
188+
"role": "ADMIN",
189+
"status": "ACTIVE",
190+
"lastLoginAt": "2025-11-25T10:00:00Z",
191+
"createdAt": "2025-11-15T08:30:00Z",
192+
"updatedAt": "2025-11-20T10:10:00Z"
193+
}
194+
],
195+
"page": 0,
196+
"size": 20,
197+
"totalElements": 6,
198+
"totalPages": 1,
199+
"hasNext": false
200+
}
201+
""";
170202
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.ssasinsa.wearagain.domain.user.dto.admin;
2+
3+
public enum AdminManagedUserKeywordScope {
4+
ALL,
5+
EMAIL,
6+
NAME;
7+
8+
public static AdminManagedUserKeywordScope from(String value) {
9+
if (value == null || value.isBlank()) {
10+
return ALL;
11+
}
12+
try {
13+
return AdminManagedUserKeywordScope.valueOf(value.trim().toUpperCase());
14+
} catch (IllegalArgumentException exception) {
15+
return ALL;
16+
}
17+
}
18+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.ssasinsa.wearagain.domain.user.dto.admin;
2+
3+
import java.util.List;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
@Schema(description = "관리자 계정 목록 응답")
8+
public record AdminManagedUserListResponse(
9+
@Schema(description = "관리자 계정 목록")
10+
List<AdminManagedUserResponse> content,
11+
@Schema(description = "현재 페이지 번호", example = "0")
12+
int page,
13+
@Schema(description = "페이지 크기", example = "20")
14+
int size,
15+
@Schema(description = "전체 요소 수", example = "6")
16+
long totalElements,
17+
@Schema(description = "전체 페이지 수", example = "1")
18+
int totalPages,
19+
@Schema(description = "다음 페이지 존재 여부", example = "false")
20+
boolean hasNext
21+
) {
22+
23+
public static AdminManagedUserListResponse of(
24+
List<AdminManagedUserResponse> content,
25+
int page,
26+
int size,
27+
long totalElements,
28+
int totalPages,
29+
boolean hasNext
30+
) {
31+
return new AdminManagedUserListResponse(content, page, size, totalElements, totalPages, hasNext);
32+
}
33+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.ssasinsa.wearagain.domain.user.dto.admin;
2+
3+
import com.ssasinsa.wearagain.domain.auth.entity.AdminRole;
4+
import com.ssasinsa.wearagain.domain.auth.entity.AdminStatus;
5+
import com.ssasinsa.wearagain.domain.auth.entity.AdminUser;
6+
import java.time.OffsetDateTime;
7+
import java.time.ZoneOffset;
8+
9+
public record AdminManagedUserResponse(
10+
Long adminUserId,
11+
String email,
12+
String name,
13+
AdminRole role,
14+
AdminStatus status,
15+
OffsetDateTime lastLoginAt,
16+
OffsetDateTime createdAt,
17+
OffsetDateTime updatedAt
18+
) {
19+
20+
public static AdminManagedUserResponse from(AdminUser adminUser) {
21+
return new AdminManagedUserResponse(
22+
adminUser.getId(),
23+
adminUser.getEmail(),
24+
adminUser.getName(),
25+
adminUser.getRole(),
26+
adminUser.getStatus(),
27+
adminUser.getLastLoginAt() == null ? null : adminUser.getLastLoginAt().atOffset(ZoneOffset.UTC),
28+
adminUser.getCreatedAt() == null ? null : adminUser.getCreatedAt().atOffset(ZoneOffset.UTC),
29+
adminUser.getUpdatedAt() == null ? null : adminUser.getUpdatedAt().atOffset(ZoneOffset.UTC)
30+
);
31+
}
32+
}

0 commit comments

Comments
 (0)