Skip to content

Commit 1b56c3f

Browse files
authored
Merge pull request #238 from CSE-Shaco/develop
fix(user-admin): 사용자 목록 정렬 및 권한별 접근 로직 개선
2 parents ecde934 + 3af9d71 commit 1b56c3f

File tree

3 files changed

+65
-92
lines changed

3 files changed

+65
-92
lines changed

src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import inha.gdgoc.domain.user.dto.request.UpdateRoleRequest;
44
import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest;
55
import inha.gdgoc.domain.user.dto.response.UserSummaryResponse;
6+
import inha.gdgoc.domain.user.enums.TeamType;
7+
import inha.gdgoc.domain.user.enums.UserRole;
68
import inha.gdgoc.domain.user.service.UserAdminService;
79
import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails;
810
import inha.gdgoc.global.dto.response.ApiResponse;
@@ -11,10 +13,7 @@
1113
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
1214
import jakarta.validation.Valid;
1315
import lombok.RequiredArgsConstructor;
14-
import org.springframework.data.domain.Page;
15-
import org.springframework.data.domain.PageRequest;
16-
import org.springframework.data.domain.Pageable;
17-
import org.springframework.data.domain.Sort;
16+
import org.springframework.data.domain.*;
1817
import org.springframework.http.ResponseEntity;
1918
import org.springframework.security.access.prepost.PreAuthorize;
2019
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -27,37 +26,57 @@ public class UserAdminController {
2726

2827
private final UserAdminService userAdminService;
2928

29+
// q(검색) + role/team(필터) + pageable
3030
@Operation(summary = "사용자 요약 목록 조회", security = {@SecurityRequirement(name = "BearerAuth")})
3131
@PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team")
3232
@GetMapping
33-
public ResponseEntity<ApiResponse<Page<UserSummaryResponse>, PageMeta>> list(@RequestParam(required = false) String q, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "name") String sort, @RequestParam(defaultValue = "ASC") String dir) {
33+
public ResponseEntity<ApiResponse<Page<UserSummaryResponse>, PageMeta>> list(
34+
@RequestParam(required = false) String q,
35+
@RequestParam(required = false) UserRole role,
36+
@RequestParam(required = false) TeamType team,
37+
@RequestParam(defaultValue = "0") int page,
38+
@RequestParam(defaultValue = "20") int size,
39+
@RequestParam(defaultValue = "name") String sort,
40+
@RequestParam(defaultValue = "ASC") String dir
41+
) {
3442
Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
3543
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort));
3644

37-
Page<UserSummaryResponse> result = userAdminService.listUsers(q, pageable);
45+
Page<UserSummaryResponse> result = userAdminService.listUsers(q, role, team, pageable);
3846
return ResponseEntity.ok(ApiResponse.ok("USER_SUMMARY_LIST_RETRIEVED", result, PageMeta.of(result)));
3947
}
4048

4149
@Operation(summary = "사용자 역할/팀 수정", security = {@SecurityRequirement(name = "BearerAuth")})
4250
@PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')")
4351
@PatchMapping("/{userId}/role-team")
44-
public ResponseEntity<ApiResponse<Void, Void>> updateRoleTeam(@AuthenticationPrincipal CustomUserDetails me, @PathVariable Long userId, @RequestBody UpdateUserRoleTeamRequest req) {
52+
public ResponseEntity<ApiResponse<Void, Void>> updateRoleTeam(
53+
@AuthenticationPrincipal CustomUserDetails me,
54+
@PathVariable Long userId,
55+
@RequestBody UpdateUserRoleTeamRequest req
56+
) {
4557
userAdminService.updateRoleAndTeam(me, userId, req);
4658
return ResponseEntity.ok(ApiResponse.ok("USER_ROLE_TEAM_UPDATED"));
4759
}
4860

4961
@Operation(summary = "사용자 역할 수정", security = {@SecurityRequirement(name = "BearerAuth")})
50-
@PatchMapping("/{userId}/role")
5162
@PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team")
52-
public ResponseEntity<ApiResponse<Void, Void>> updateUserRole(@AuthenticationPrincipal CustomUserDetails me, @PathVariable Long userId, @RequestBody @Valid UpdateRoleRequest req) {
63+
@PatchMapping("/{userId}/role")
64+
public ResponseEntity<ApiResponse<Void, Void>> updateUserRole(
65+
@AuthenticationPrincipal CustomUserDetails me,
66+
@PathVariable Long userId,
67+
@RequestBody @Valid UpdateRoleRequest req
68+
) {
5369
userAdminService.updateUserRoleWithRules(me, userId, req.role());
5470
return ResponseEntity.ok(ApiResponse.ok("USER_ROLE_UPDATED"));
5571
}
5672

5773
@Operation(summary = "사용자 삭제", security = {@SecurityRequirement(name = "BearerAuth")})
58-
@PreAuthorize("hasAnyRole('LEAD', 'ORGANIZER', 'ADMIN')")
74+
@PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')")
5975
@DeleteMapping("/{userId}")
60-
public ResponseEntity<ApiResponse<Void, Void>> deleteUser(@AuthenticationPrincipal CustomUserDetails me, @PathVariable Long userId) {
76+
public ResponseEntity<ApiResponse<Void, Void>> deleteUser(
77+
@AuthenticationPrincipal CustomUserDetails me,
78+
@PathVariable Long userId
79+
) {
6180
userAdminService.deleteUserWithRules(me, userId);
6281
return ResponseEntity.ok(ApiResponse.ok("USER_DELETED"));
6382
}

src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
2121

2222
boolean existsByNameAndEmail(String name, String email);
23+
2324
boolean existsByEmail(String email);
2425

2526
/* ===== 출석/팀 뷰용 기본 쿼리 ===== */
@@ -37,13 +38,22 @@ public interface UserRepository extends JpaRepository<User, Long>, UserRepositor
3738
List<User> findByTeam(TeamType team);
3839

3940
@Query("""
40-
select new inha.gdgoc.domain.user.dto.response.UserSummaryResponse(
41-
u.id, u.name, u.major, u.studentId, u.email, u.userRole, u.team
42-
)
43-
from User u
44-
where (:q is null or :q = '' or u.name like concat('%', :q, '%'))
45-
""")
46-
Page<UserSummaryResponse> findSummaries(@Param("q") String q, Pageable pageable);
41+
select new inha.gdgoc.domain.user.dto.response.UserSummaryResponse(
42+
u.id, u.name, u.major, u.studentId, u.email, u.userRole, u.team
43+
)
44+
from User u
45+
where
46+
(
47+
:q is null or :q = '' or
48+
lower(u.name) like lower(concat('%', :q, '%')) or
49+
lower(u.email) like lower(concat('%', :q, '%')) or
50+
u.studentId like concat('%', :q, '%') or
51+
lower(u.major) like lower(concat('%', :q, '%'))
52+
)
53+
and (:role is null or u.userRole = :role)
54+
and (:team is null or u.team = :team)
55+
""")
56+
Page<UserSummaryResponse> findSummaries(@Param("q") String q, @Param("role") inha.gdgoc.domain.user.enums.UserRole role, @Param("team") inha.gdgoc.domain.user.enums.TeamType team, Pageable pageable);
4757

4858
@NotNull Optional<User> findById(@NotNull Long id);
4959
}

src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java

Lines changed: 18 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ public class UserAdminService {
2626

2727
private final UserRepository userRepository;
2828

29+
/* ======================= 목록 ======================= */
30+
2931
@Transactional(readOnly = true)
30-
public Page<UserSummaryResponse> listUsers(String q, Pageable pageable) {
32+
public Page<UserSummaryResponse> listUsers(String q, UserRole role, TeamType team, Pageable pageable) {
3133
Pageable fixed = rewriteSort(pageable);
32-
return userRepository.findSummaries(q, fixed);
34+
// 레포지토리에 role/team 조건 추가한 메서드가 있어야 함
35+
return userRepository.findSummaries(q, role, team, fixed);
3336
}
3437

3538
private Pageable rewriteSort(Pageable pageable) {
@@ -45,40 +48,23 @@ private Pageable rewriteSort(Pageable pageable) {
4548

4649
if ("userRole".equals(prop)) {
4750
hasUserRoleOrder = true;
48-
String roleRankCase = "CASE u.userRole " +
49-
"WHEN 'GUEST' THEN 0 " +
50-
"WHEN 'MEMBER' THEN 1 " +
51-
"WHEN 'CORE' THEN 2 " +
52-
"WHEN 'LEAD' THEN 3 " +
53-
"WHEN 'ORGANIZER' THEN 4 " +
54-
"WHEN 'ADMIN' THEN 5 " +
55-
"ELSE -1 END";
51+
String roleRankCase = "CASE u.userRole " + "WHEN 'GUEST' THEN 0 " + "WHEN 'MEMBER' THEN 1 " + "WHEN 'CORE' THEN 2 " + "WHEN 'LEAD' THEN 3 " + "WHEN 'ORGANIZER' THEN 4 " + "WHEN 'ADMIN' THEN 5 " + "ELSE -1 END";
5652
composed = composed.and(JpaSort.unsafe(dir, roleRankCase));
5753
} else {
5854
composed = composed.and(Sort.by(new Sort.Order(dir, prop)));
5955
}
6056
}
6157

62-
// ROLE 정렬 요청이 있었다면, 같은 권한 내에서 name ASC로 안정화
58+
// ROLE 정렬이 있으면 같은 권한 name ASC로 안정화
6359
if (hasUserRoleOrder) {
6460
composed = composed.and(Sort.by("name").ascending());
6561
}
6662

6763
return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), composed);
6864
}
6965

70-
/**
71-
* 역할/팀 동시 수정 API (PATCH /admin/users/{userId}/role-team)
72-
* - 공통 규칙:
73-
* 에디터의 role은 타겟의 현재/신규 role보다 "엄격히 높아야" 함
74-
* - ADMIN: 자기 자신 강등 금지
75-
* - ORGANIZER: ADMIN 대상 수정 금지
76-
* - LEAD:
77-
* - MEMBER/CORE만 수정 가능, 변경도 MEMBER/CORE로만
78-
* - HR-LEAD: 자기 자신 제외 타인의 팀 변경 가능
79-
* - 그 외 LEAD: 같은 팀 구성원만 수정 가능, 팀 변경 불가
80-
* - 팀 보유 가능 역할: CORE, LEAD (그 외는 팀 자동 null)
81-
*/
66+
/* ======================= 수정 ======================= */
67+
8268
@Transactional
8369
public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, UpdateUserRoleTeamRequest req) {
8470
User editorUser = getEditor(editor);
@@ -91,10 +77,10 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat
9177
UserRole newRole = (req.role() != null ? req.role() : targetCurrentRole);
9278
TeamType requestedTeam = (req.team() != null ? req.team() : target.getTeam());
9379

94-
// 팀 보유 가능한 역할만 팀 유지/지정 (CORE, LEAD만 가능)
80+
// 팀 보유 가능한 역할만 팀 허용 (CORE, LEAD)
9581
TeamType newTeam = isTeamAssignableRole(newRole) ? requestedTeam : null;
9682

97-
// 공통: 에디터는 타겟의 현재/신규 role보다 높아야 함 (동급 불가)
83+
// 공통: 에디터는 대상의 현재/신규 role보다 엄격히 높아야 함
9884
if (!(editorRole.rank() > targetCurrentRole.rank())) {
9985
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "동급/상위 사용자의 정보는 변경할 수 없습니다.");
10086
}
@@ -104,14 +90,11 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat
10490

10591
switch (editorRole) {
10692
case ADMIN -> {
107-
// 자기 자신 강등 금지
10893
if (editorUser.getId().equals(target.getId()) && newRole.rank() < UserRole.ADMIN.rank()) {
10994
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신을 강등할 수 없습니다.");
11095
}
111-
// ADMIN은 팀 변경 제한 없음 (위 정규화로 팀 자동 정리)
11296
}
11397
case ORGANIZER -> {
114-
// ADMIN 대상은 수정 금지
11598
if (targetCurrentRole == UserRole.ADMIN) {
11699
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 수정할 수 없습니다.");
117100
}
@@ -120,7 +103,6 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat
120103
if (editor.getTeam() == null) {
121104
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD 토큰에 팀 정보가 없습니다.");
122105
}
123-
// LEAD는 MEMBER/CORE만 수정 가능, 변경도 MEMBER/CORE로만
124106
if (!(targetCurrentRole == UserRole.MEMBER || targetCurrentRole == UserRole.CORE)) {
125107
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 수정할 수 있습니다.");
126108
}
@@ -129,15 +111,13 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat
129111
}
130112

131113
if (editor.getTeam() == TeamType.HR) {
132-
// HR-LEAD: 본인 제외 타인 팀 변경 가능
114+
// HR-LEAD: 본인 제외 타인지원 팀 변경 가능
133115
if (editorUser.getId().equals(target.getId())) {
134-
// 본인은 팀 변경 불가
135116
if (req.team() != null && !Objects.equals(req.team(), target.getTeam())) {
136117
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-LEAD도 자기 자신의 팀은 변경할 수 없습니다.");
137118
}
138119
}
139120
} else {
140-
// 일반 LEAD: 같은 팀 구성원만, 팀 변경 불가
141121
if (target.getTeam() != editor.getTeam()) {
142122
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 수정할 수 없습니다.");
143123
}
@@ -149,16 +129,9 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat
149129
default -> throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER);
150130
}
151131

152-
// ✅ 최종 반영 (역할이 팀 불가면 팀은 자동 null 처리)
153132
targetChange(target, newRole, newTeam);
154133
}
155134

156-
/**
157-
* 역할만 수정 API (PATCH /admin/users/{userId}/role)
158-
* - HR-CORE 특례: GUEST -> MEMBER 가능 (그 외 불가)
159-
* - 일반 규칙: 에디터의 role은 대상의 현재/신규 role보다 엄격히 높아야 함
160-
* - 역할이 팀 불가가 되면 팀은 자동 null 처리
161-
*/
162135
@Transactional
163136
public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, UserRole newRole) {
164137
var meRole = me.getRole();
@@ -173,82 +146,61 @@ public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, Use
173146

174147
UserRole current = target.getUserRole();
175148

176-
// HR-CORE 특례: GUEST MEMBER 만 허용
149+
// HR-CORE 특례: GUEST -> MEMBER
177150
boolean isHrCore = (meRole == UserRole.CORE) && (meTeam == TeamType.HR);
178151
if (isHrCore) {
179152
if (current == UserRole.GUEST && newRole == UserRole.MEMBER) {
180153
target.changeRole(UserRole.MEMBER);
181-
// MEMBER는 팀 불가 → 자동 null
182-
if (!isTeamAssignableRole(UserRole.MEMBER)) {
183-
target.changeTeam(null);
184-
}
154+
if (!isTeamAssignableRole(UserRole.MEMBER)) target.changeTeam(null);
185155
userRepository.save(target);
186156
return;
187157
}
188158
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-CORE는 GUEST→MEMBER 변경만 가능");
189159
}
190160

191-
// 일반 규칙: 나의 권한은 대상의 현재/신규 권한보다 엄격히 높아야 함
192161
boolean higherThanCurrent = meRole.rank() > current.rank();
193162
boolean higherThanNew = meRole.rank() > newRole.rank();
194163
if (!higherThanCurrent || !higherThanNew) {
195164
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "요청한 역할 변경 권한이 없습니다.");
196165
}
197166

198167
target.changeRole(newRole);
199-
// 역할이 팀 불가면 팀 자동 해제
200-
if (!isTeamAssignableRole(newRole)) {
201-
target.changeTeam(null);
202-
}
168+
if (!isTeamAssignableRole(newRole)) target.changeTeam(null);
203169
userRepository.save(target);
204170
}
205171

206172
@Transactional
207173
public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) {
208174
User editor = userRepository.findById(me.getUserId())
209175
.orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER));
210-
211176
User target = userRepository.findById(targetUserId)
212177
.orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND));
213178

214-
// 자기 자신 삭제 금지
215179
if (Objects.equals(editor.getId(), target.getId())) {
216180
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신은 삭제할 수 없습니다.");
217181
}
218182

219183
UserRole editorRole = editor.getUserRole();
220184
TeamType editorTeam = editor.getTeam();
221-
222185
UserRole targetRole = target.getUserRole();
223186
TeamType targetTeam = target.getTeam();
224187

225-
// 공통: '나'는 대상의 현재 role보다 "엄격히" 높아야 함
226188
if (!(editorRole.rank() > targetRole.rank())) {
227189
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "동급/상급 사용자는 삭제할 수 없습니다.");
228190
}
229191

230192
switch (editorRole) {
231-
case ADMIN -> {
232-
// ADMIN: 모두 삭제 가능(단, 자기 자신은 위에서 금지)
233-
// 추가 보호가 필요하면 여기서 ADMIN→ADMIN 삭제 금지도 가능
234-
}
193+
case ADMIN -> {}
235194
case ORGANIZER -> {
236-
// ORGANIZER: ADMIN 삭제 불가(공통 검사로 이미 걸러짐). 그 외 삭제 가능
237195
if (targetRole == UserRole.ADMIN) {
238196
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 삭제할 수 없습니다.");
239197
}
240198
}
241199
case LEAD -> {
242-
// LEAD: MEMBER/CORE만 삭제 가능
243200
if (!(targetRole == UserRole.MEMBER || targetRole == UserRole.CORE)) {
244201
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 삭제할 수 있습니다.");
245202
}
246-
247-
// HR-LEAD 특례: 본인 제외 누구든 팀 무관 삭제 가능
248-
if (editorTeam == TeamType.HR) {
249-
// 자기 자신은 위에서 이미 금지
250-
} else {
251-
// 일반 LEAD: 같은 팀만 삭제 가능
203+
if (editorTeam != TeamType.HR) {
252204
if (editorTeam == null || targetTeam != editorTeam) {
253205
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 삭제할 수 없습니다.");
254206
}
@@ -260,14 +212,9 @@ public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) {
260212
userRepository.delete(target);
261213
}
262214

263-
/**
264-
* 실제 반영 (역할 변경 후 역할 정책에 따라 팀도 정리)
265-
*/
266215
private void targetChange(User target, UserRole newRole, TeamType newTeam) {
267216
target.changeRole(newRole);
268-
if (!isTeamAssignableRole(newRole)) {
269-
newTeam = null; // 방어적 정리
270-
}
217+
if (!isTeamAssignableRole(newRole)) newTeam = null;
271218
target.changeTeam(newTeam);
272219
userRepository.save(target);
273220
}
@@ -277,9 +224,6 @@ private User getEditor(CustomUserDetails editor) {
277224
.orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER));
278225
}
279226

280-
/**
281-
* 팀을 가질 수 있는 역할만 true (CORE, LEAD)
282-
*/
283227
private boolean isTeamAssignableRole(UserRole role) {
284228
return role == UserRole.CORE || role == UserRole.LEAD;
285229
}

0 commit comments

Comments
 (0)