diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index efe3f48..52ca638 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -1,15 +1,5 @@ package inha.gdgoc.domain.auth.controller; -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.ACCESS_TOKEN_REFRESH_SUCCESS; -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.CODE_CREATION_SUCCESS; -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.LOGIN_WITH_PASSWORD_SUCCESS; -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.LOGOUT_SUCCESS; -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.OAUTH_LOGIN_SIGNUP_SUCCESS; -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.PASSWORD_CHANGE_SUCCESS; -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.PASSWORD_RESET_VERIFICATION_SUCCESS; -import static inha.gdgoc.domain.auth.exception.AuthErrorCode.UNAUTHORIZED_USER; -import static inha.gdgoc.domain.auth.exception.AuthErrorCode.USER_NOT_FOUND; - import inha.gdgoc.domain.auth.dto.request.CodeVerificationRequest; import inha.gdgoc.domain.auth.dto.request.PasswordResetRequest; import inha.gdgoc.domain.auth.dto.request.SendingCodeRequest; @@ -24,29 +14,32 @@ import inha.gdgoc.domain.auth.service.MailService; import inha.gdgoc.domain.auth.service.RefreshTokenService; import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.config.jwt.TokenProvider; import inha.gdgoc.global.dto.response.ApiResponse; +import inha.gdgoc.global.exception.GlobalErrorCode; import jakarta.servlet.http.HttpServletResponse; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.Map; -import java.util.Optional; - import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.CookieValue; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.Optional; + +import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*; +import static inha.gdgoc.domain.auth.exception.AuthErrorCode.UNAUTHORIZED_USER; +import static inha.gdgoc.domain.auth.exception.AuthErrorCode.USER_NOT_FOUND; @Slf4j @RequestMapping("/api/v1/auth") @@ -61,19 +54,14 @@ public class AuthController { private final AuthCodeService authCodeService; @GetMapping("/oauth2/google/callback") - public ResponseEntity, Void>> handleGoogleCallback( - @RequestParam String code, - HttpServletResponse response - ) { + public ResponseEntity, Void>> handleGoogleCallback(@RequestParam String code, HttpServletResponse response) { Map data = authService.processOAuthLogin(code, response); return ResponseEntity.ok(ApiResponse.ok(OAUTH_LOGIN_SIGNUP_SUCCESS, data)); } @PostMapping("/refresh") - public ResponseEntity refreshAccessToken( - @CookieValue(value = "refresh_token", required = false) String refreshToken - ) { + public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token", required = false) String refreshToken) { log.info("리프레시 토큰 요청 받음. 토큰 존재 여부: {}", refreshToken != null); if (refreshToken == null) { @@ -84,8 +72,7 @@ public ResponseEntity refreshAccessToken( String newAccessToken = refreshTokenService.refreshAccessToken(refreshToken); AccessTokenResponse accessTokenResponse = new AccessTokenResponse(newAccessToken); - return ResponseEntity.ok( - ApiResponse.ok(ACCESS_TOKEN_REFRESH_SUCCESS, accessTokenResponse, null)); + return ResponseEntity.ok(ApiResponse.ok(ACCESS_TOKEN_REFRESH_SUCCESS, accessTokenResponse, null)); } catch (Exception e) { log.error("리프레시 토큰 처리 중 오류: {}", e.getMessage(), e); throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); @@ -93,10 +80,7 @@ public ResponseEntity refreshAccessToken( } @PostMapping("/login") - public ResponseEntity> login( - @Valid @RequestBody UserLoginRequest req, - HttpServletResponse response - ) throws NoSuchAlgorithmException, InvalidKeyException { + public ResponseEntity> login(@Valid @RequestBody UserLoginRequest req, HttpServletResponse response) throws NoSuchAlgorithmException, InvalidKeyException { String email = req.email().trim(); LoginResponse loginResponse = authService.loginWithPassword(email, req.password(), response); return ResponseEntity.ok(ApiResponse.ok(LOGIN_WITH_PASSWORD_SUCCESS, loginResponse)); @@ -109,9 +93,7 @@ public ResponseEntity> logout() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 1) 익명 방어 - if (authentication == null - || !authentication.isAuthenticated() - || "anonymousUser".equals(authentication.getName())) { + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getName())) { throw new AuthException(UNAUTHORIZED_USER); } @@ -142,12 +124,9 @@ public ResponseEntity> logout() { } @PostMapping("/password-reset/request") - public ResponseEntity> responseResponseEntity( - @RequestBody SendingCodeRequest sendingCodeRequest - ) { + public ResponseEntity> responseResponseEntity(@RequestBody SendingCodeRequest sendingCodeRequest) { // TODO 서비스로 넘기기 - if (userRepository.existsByNameAndEmail(sendingCodeRequest.name(), - sendingCodeRequest.email())) { + if (userRepository.existsByNameAndEmail(sendingCodeRequest.name(), sendingCodeRequest.email())) { String code = mailService.sendAuthCode(sendingCodeRequest.email()); authCodeService.saveAuthCode(sendingCodeRequest.email(), code); @@ -157,9 +136,7 @@ public ResponseEntity> responseResponseEntity( } @PostMapping("/password-reset/verify") - public ResponseEntity> verifyCode( - @RequestBody CodeVerificationRequest request - ) { + public ResponseEntity> verifyCode(@RequestBody CodeVerificationRequest request) { // TODO 서비스 단 DTO 추가 boolean verified = authCodeService.verify(request.email(), request.code()); CodeVerificationResponse response = new CodeVerificationResponse(verified); @@ -168,9 +145,7 @@ public ResponseEntity> verifyCode( } @PostMapping("/password-reset/confirm") - public ResponseEntity> resetPassword( - @RequestBody PasswordResetRequest passwordResetRequest - ) throws NoSuchAlgorithmException, InvalidKeyException { + public ResponseEntity> resetPassword(@RequestBody PasswordResetRequest passwordResetRequest) throws NoSuchAlgorithmException, InvalidKeyException { // TODO 서비스 단으로 Optional user = userRepository.findByEmail(passwordResetRequest.email()); if (user.isEmpty()) { @@ -183,4 +158,42 @@ public ResponseEntity> resetPassword( return ResponseEntity.ok(ApiResponse.ok(PASSWORD_CHANGE_SUCCESS)); } + + /** + * 요구 권한(role) 이상이면 200, 아니면 403 + * 미인증이면 401 + + * 예) /api/v1/auth/LEAD, /api/v1/auth/ORGANIZER, /api/v1/auth/ADMIN + */ + @GetMapping("/{role}") + public ResponseEntity> checkRoleOrTeam(@AuthenticationPrincipal TokenProvider.CustomUserDetails me, @PathVariable UserRole role, @RequestParam(value = "team", required = false) TeamType requiredTeam) { + // 1) 인증 체크 + if (me == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(GlobalErrorCode.UNAUTHORIZED_USER.getStatus() + .value(), GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), null)); + } + + // 2) role check + final boolean roleOk = UserRole.hasAtLeast(me.getRole(), role); + + // 3) team check if team parameter exists + boolean teamOk = false; + if (requiredTeam != null) { + if (UserRole.hasAtLeast(me.getRole(), UserRole.ORGANIZER)) { + teamOk = true; + } else { + teamOk = (me.getTeam() != null && me.getTeam() == requiredTeam); + } + } + + // 4) OR 조건으로 최종 판정 + if (roleOk || teamOk) { + return ResponseEntity.ok(ApiResponse.ok("ROLE_OR_TEAM_CHECK_PASSED", null)); + } + + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(GlobalErrorCode.FORBIDDEN_USER.getStatus() + .value(), GlobalErrorCode.FORBIDDEN_USER.getMessage(), null)); + } } diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java index a5cc84c..552ce67 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java @@ -51,12 +51,14 @@ public ResponseEntity> listDates() { return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_LIST_RETRIEVED_SUCCESS, new DateListResponse(service.getDates()))); } + @PreAuthorize("hasAnyRole('ORGANIZER', 'ADMIN')") @PostMapping public ResponseEntity> createDate(@Valid @RequestBody CreateDateRequest request) { service.addDate(request.getDate()); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_CREATED_SUCCESS, new DateListResponse(service.getDates()))); } + @PreAuthorize("hasAnyRole('ORGANIZER', 'ADMIN')") @DeleteMapping("/{date}") public ResponseEntity> deleteDate(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { service.deleteDate(date.toString()); @@ -66,7 +68,7 @@ public ResponseEntity> deleteDate(@PathVaria /* ===== 팀 목록 (리드=본인 팀만 / 관리자=전체) ===== */ @GetMapping("/teams") public ResponseEntity, PageMeta>> getTeams(@AuthenticationPrincipal CustomUserDetails me) { - List list = (me.getRole() == UserRole.LEAD) ? service.getTeamsForLead(requiredTeamFrom(me)) : service.getTeamsForOrganizerOrAdmin(); + List list = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? service.getTeamsForLead(requiredTeamFrom(me)) : service.getTeamsForOrganizerOrAdmin(); var page = new PageImpl<>(list, PageRequest.of(0, Math.max(1, list.size()), Sort.by(Sort.Direction.DESC, "createdAt")), list.size()); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list, PageMeta.of(page))); @@ -77,7 +79,7 @@ public ResponseEntity, PageMeta>> getTeams(@Authe @GetMapping("/{date}/members") public ResponseEntity>, Void>> membersOfMeeting(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team // 관리자만 사용, 리드는 무시 ) { - TeamType effectiveTeam = (me.getRole() == UserRole.LEAD) ? requiredTeamFrom(me) : team; + TeamType effectiveTeam = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? requiredTeamFrom(me) : team; var list = service.getMembersWithPresence(date.toString(), effectiveTeam); // list 원소 예시: { "userId": "123", "name": "홍길동", "present": true, "lastModifiedAt": "..." } return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list)); @@ -86,11 +88,11 @@ public ResponseEntity>, Void>> membersOfMee /* ===== 특정 날짜 출석 일괄 저장 (멱등 스냅샷) ===== */ // Body: { "userIds": ["1","2",...], "present": true } → presentUserIds만 보내는 구조로도 쉽게 변환 가능 @PutMapping("/{date}/attendance") - public ResponseEntity, Void>> saveAttendanceSnapshot(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team, // 관리자만 사용, 리드는 무시 - @RequestBody @Valid SetAttendanceRequest req) { + public ResponseEntity, Void>> saveAttendanceSnapshot(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestBody @Valid SetAttendanceRequest req) { var userIds = req.safeUserIds(); - if (me.getRole() == UserRole.LEAD) { + // LEAD → 본인 팀 검증 + if (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) { TeamType myTeam = requiredTeamFrom(me); var validation = service.filterUserIdsNotInTeam(myTeam, userIds); if (validation.validIds().isEmpty()) { @@ -100,32 +102,42 @@ public ResponseEntity, Void>> saveAttendanceSnap return okUpdated(updated, validation.invalidIds()); } - // ORGANIZER / ADMIN - TeamType effectiveTeam = (team != null) ? team : service.inferTeamFromUserIds(userIds) - .orElseThrow(() -> new IllegalArgumentException("userIds로 팀을 추론할 수 없습니다.")); - - var validation = service.filterUserIdsNotInTeam(effectiveTeam, userIds); - if (validation.validIds().isEmpty()) { - return okUpdated(0L, validation.invalidIds()); - } - long updated = service.setAttendance(date.toString(), validation.validIds(), req.presentValue()); - return okUpdated(updated, validation.invalidIds()); + // ORGANIZER / ADMIN → 팀 추론/검증 없이 바로 업서트 + long updated = service.setAttendance(date.toString(), userIds, req.presentValue()); + return okUpdated(updated, List.of()); } /* ===== 날짜 요약(JSON) ===== */ @GetMapping("/{date}/summary") public ResponseEntity> summary(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) { - DaySummaryResponse body = (me.getRole() == UserRole.LEAD) ? service.summary(date.toString(), requiredTeamFrom(me)) : service.summary(date.toString(), team); + DaySummaryResponse body = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? service.summary(date.toString(), requiredTeamFrom(me)) : service.summary(date.toString(), team); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.SUMMARY_RETRIEVED_SUCCESS, body)); } /* ===== 날짜 요약(CSV) ===== */ @GetMapping(value = "/{date}/summary.csv", produces = "text/csv; charset=UTF-8") public ResponseEntity summaryCsv(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) { - TeamType effective = (me.getRole() == UserRole.LEAD) ? requiredTeamFrom(me) : team; - String csv = service.buildSummaryCsv(date.toString(), effective); // 서비스에 구현 + TeamType effective = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? requiredTeamFrom(me) : team; + String csv = service.buildSummaryCsv(date.toString(), effective); return ResponseEntity.ok() .header("Content-Disposition", "attachment; filename=\"attendance-" + date + ".csv\"") .body(csv); } + + @GetMapping(value = "/summary.csv", produces = "text/csv; charset=UTF-8") + public ResponseEntity summaryCsvAll( + @AuthenticationPrincipal CustomUserDetails me, + @RequestParam(required = false) TeamType team + ) { + // LEAD & not HR → 자신의 팀만 + TeamType effective = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) + ? requiredTeamFrom(me) + : team; + + String csv = service.buildFullMatrixCsv(effective); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"attendance-summary.csv\"") + .body(csv); + } + } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java b/src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java index fe4a1ce..383ca50 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java @@ -26,11 +26,12 @@ public interface AttendanceRecordRepository extends JpaRepository userIds, @Param("present") boolean present); + int upsertBatchByMeetingId(@Param("meetingId") Long meetingId, @Param("userIds") Long[] userIds, // 👈 배열 + @Param("present") boolean present); } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java b/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java index bc05530..f0a1428 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java @@ -30,6 +30,14 @@ public class CoreAttendanceService { /* ===================== Meetings (dates) ===================== */ + private static String escape(String s) { + if (s == null) return ""; + String needsQuote = ",\"\n"; + boolean mustQuote = s.chars().anyMatch(ch -> needsQuote.indexOf(ch) >= 0); + if (!mustQuote) return s; + return "\"" + s.replace("\"", "\"\"") + "\""; + } + @Transactional(readOnly = true) public List getDates() { return meetingRepository.findAll(Sort.by(Sort.Direction.DESC, "meetingDate")) @@ -45,6 +53,8 @@ public void addDate(String date) { .orElseGet(() -> meetingRepository.save(Meeting.builder().meetingDate(d).build())); } + /* ===================== Teams ===================== */ + @Transactional public void deleteDate(String date) { LocalDate d = LocalDate.parse(date); @@ -54,8 +64,6 @@ public void deleteDate(String date) { }); } - /* ===================== Teams ===================== */ - @Transactional(readOnly = true) public List getTeamsForLead(TeamType leadTeam) { var roles = List.of(UserRole.CORE, UserRole.LEAD); @@ -65,33 +73,32 @@ public List getTeamsForLead(TeamType leadTeam) { @Transactional(readOnly = true) public List getTeamsForOrganizerOrAdmin() { - var roles = List.of(UserRole.CORE, UserRole.LEAD); + var roles = List.of(UserRole.CORE, UserRole.LEAD, UserRole.ORGANIZER); List users = userRepository.findByUserRoleIn(roles); return toTeamResponsesGrouped(users); } + /* ===================== Attendance ===================== */ + private List toTeamResponsesGrouped(List users) { Map> grouped = users.stream() .filter(u -> u.getTeam() != null) .collect(Collectors.groupingBy(User::getTeam, LinkedHashMap::new, Collectors.toList())); - return grouped.entrySet().stream() - .map(e -> { - TeamType team = e.getKey(); - List members = e.getValue().stream() - .sorted(Comparator.comparing(User::getName)) - .map(u -> new MemberResponse(String.valueOf(u.getId()), u.getName())) - .toList(); - return new TeamResponse(team.name(), team.getLabel(), members); - }) - .toList(); + return grouped.entrySet().stream().map(e -> { + TeamType team = e.getKey(); + List members = e.getValue() + .stream() + .sorted(Comparator.comparing(User::getName)) + .map(u -> new MemberResponse(String.valueOf(u.getId()), u.getName())) + .toList(); + return new TeamResponse(team.name(), team.getLabel(), members); + }).toList(); } - /* ===================== Attendance ===================== */ - - public record UserIdValidationResult(List validIds, List invalidIds) {} - - /** 주어진 userIds 중 team 소속만 골라냄 */ + /** + * 주어진 userIds 중 team 소속만 골라냄 + */ @Transactional(readOnly = true) public UserIdValidationResult filterUserIdsNotInTeam(TeamType team, List userIds) { if (userIds == null || userIds.isEmpty()) { @@ -107,50 +114,50 @@ public UserIdValidationResult filterUserIdsNotInTeam(TeamType team, List u return new UserIdValidationResult(valid, invalid); } - /** 배치로 출석 true/false 반영 (고성능 UPSERT) */ + /** + * 배치로 출석 true/false 반영 (고성능 UPSERT) + */ @Transactional public long setAttendance(String date, List userIds, boolean present) { if (userIds == null || userIds.isEmpty()) return 0L; LocalDate d = LocalDate.parse(date); Long meetingId = ensureMeetingAndGetId(d); - int affected = attendanceRecordRepository.upsertBatchByMeetingId(meetingId, userIds, present); + + Long[] arr = userIds.toArray(Long[]::new); // ✅ List -> Array + int affected = attendanceRecordRepository.upsertBatchByMeetingId(meetingId, arr, present); return Math.max(affected, 0); } - /** 특정 날짜에 대해 팀원 + 현재 출석 여부 목록 */ + /** + * 특정 날짜에 대해 팀원 + 현재 출석 여부 목록 + */ @Transactional(readOnly = true) public List> getMembersWithPresence(String date, TeamType teamOrNull) { LocalDate d = LocalDate.parse(date); Map day = getPresenceMap(d); - var roles = List.of(UserRole.CORE, UserRole.LEAD); - List users = (teamOrNull == null) - ? userRepository.findByUserRoleIn(roles) - : userRepository.findByTeamAndUserRoleIn(teamOrNull, roles); - - return users.stream() - .filter(u -> u.getTeam() != null) - .sorted(Comparator.comparing(User::getName)) - .map(u -> { - Map row = new LinkedHashMap<>(); - row.put("userId", String.valueOf(u.getId())); - row.put("name", u.getName()); - row.put("team", u.getTeam().getLabel()); - row.put("present", day.getOrDefault(u.getId(), false)); - row.put("lastModifiedAt", null); // 추후 updatedAt/updatedBy 확장 시 채우기 - return row; - }) - .toList(); + var roles = List.of(UserRole.CORE, UserRole.LEAD, UserRole.ORGANIZER); + List users = (teamOrNull == null) ? userRepository.findByUserRoleIn(roles) : userRepository.findByTeamAndUserRoleIn(teamOrNull, roles); + + return users.stream().filter(u -> u.getTeam() != null).sorted(Comparator.comparing(User::getName)).map(u -> { + Map row = new LinkedHashMap<>(); + row.put("userId", String.valueOf(u.getId())); + row.put("name", u.getName()); + row.put("team", u.getTeam().getLabel()); + row.put("present", day.getOrDefault(u.getId(), false)); + row.put("lastModifiedAt", null); // 추후 updatedAt/updatedBy 확장 시 채우기 + return row; + }).toList(); } - /** userIds 로부터 단일 팀을 추론 (다르면 empty) */ + /** + * userIds 로부터 단일 팀을 추론 (다르면 empty) + */ @Transactional(readOnly = true) public Optional inferTeamFromUserIds(List userIds) { if (userIds == null || userIds.isEmpty()) return Optional.empty(); - var users = userRepository.findAllById(userIds).stream() - .filter(u -> u.getTeam() != null) - .toList(); + var users = userRepository.findAllById(userIds).stream().filter(u -> u.getTeam() != null).toList(); if (users.isEmpty()) return Optional.empty(); TeamType first = users.get(0).getTeam(); @@ -165,24 +172,19 @@ public DaySummaryResponse summary(String date, TeamType teamForLeadOrNull) { LocalDate d = LocalDate.parse(date); Map day = getPresenceMap(d); - var roles = List.of(UserRole.CORE, UserRole.LEAD); - List baseUsers = (teamForLeadOrNull == null) - ? userRepository.findByUserRoleIn(roles) - : userRepository.findByTeamAndUserRoleIn(teamForLeadOrNull, roles); + var roles = List.of(UserRole.CORE, UserRole.LEAD, UserRole.ORGANIZER); + List baseUsers = (teamForLeadOrNull == null) ? userRepository.findByUserRoleIn(roles) : userRepository.findByTeamAndUserRoleIn(teamForLeadOrNull, roles); Map> byTeam = baseUsers.stream() .filter(u -> u.getTeam() != null) .collect(Collectors.groupingBy(User::getTeam, LinkedHashMap::new, Collectors.toList())); - var perTeam = byTeam.entrySet().stream() - .map(e -> { - TeamType team = e.getKey(); - List us = e.getValue(); - long p = us.stream().filter(u -> day.getOrDefault(u.getId(), false)).count(); - return new DaySummaryResponse.TeamSummary(team.name(), team.getLabel(), p, us.size()); - }) - .sorted(Comparator.comparing(DaySummaryResponse.TeamSummary::getTeamName)) - .toList(); + var perTeam = byTeam.entrySet().stream().map(e -> { + TeamType team = e.getKey(); + List us = e.getValue(); + long p = us.stream().filter(u -> day.getOrDefault(u.getId(), false)).count(); + return new DaySummaryResponse.TeamSummary(team.name(), team.getLabel(), p, us.size()); + }).sorted(Comparator.comparing(DaySummaryResponse.TeamSummary::getTeamName)).toList(); long present = perTeam.stream().mapToLong(DaySummaryResponse.TeamSummary::getPresent).sum(); long total = perTeam.stream().mapToLong(DaySummaryResponse.TeamSummary::getTotal).sum(); @@ -190,41 +192,130 @@ public DaySummaryResponse summary(String date, TeamType teamForLeadOrNull) { return new DaySummaryResponse(date, perTeam, present, total); } - /** 요약 CSV 생성 (UTF-8) */ + /** + * 요약 CSV 생성 (UTF-8) + */ @Transactional(readOnly = true) public String buildSummaryCsv(String date, TeamType teamOrNull) { DaySummaryResponse s = summary(date, teamOrNull); StringBuilder sb = new StringBuilder(); sb.append("date,team_id,team_name,present,total\n"); for (var t : s.getPerTeam()) { - sb.append(escape(date)).append(',') - .append(escape(t.getTeamId())).append(',') - .append(escape(t.getTeamName())).append(',') - .append(t.getPresent()).append(',') - .append(t.getTotal()).append('\n'); + sb.append(escape(date)) + .append(',') + .append(escape(t.getTeamId())) + .append(',') + .append(escape(t.getTeamName())) + .append(',') + .append(t.getPresent()) + .append(',') + .append(t.getTotal()) + .append('\n'); + } + sb.append(escape(date)) + .append(',') + .append("ALL") + .append(',') + .append("전체") + .append(',') + .append(s.getPresent()) + .append(',') + .append(s.getTotal()) + .append('\n'); + + return new String(sb.toString().getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + } + + @Transactional(readOnly = true) + public String buildFullMatrixCsv(TeamType teamOrNull) { + // 1) 날짜 목록(오름차순) + List dates = meetingRepository.findAll(Sort.by(Sort.Direction.ASC, "meetingDate")) + .stream() + .map(Meeting::getMeetingDate) + .toList(); + + // 날짜가 없으면 헤더만 + if (dates.isEmpty()) { + return "이름,출석률\n"; + } + + // 2) 대상 사용자: 기존 정책과 동일 (CORE/LEAD/ORGANIZER), 팀 필터 적용 + var roles = List.of(UserRole.CORE, UserRole.LEAD, UserRole.ORGANIZER); + List users = (teamOrNull == null) ? userRepository.findByUserRoleIn(roles) : userRepository.findByTeamAndUserRoleIn(teamOrNull, roles); + + // 팀 없는 사용자 제외 + 이름순 정렬 + users = users.stream().filter(u -> u.getTeam() != null).sorted(Comparator.comparing(User::getName)).toList(); + + // 사용자 없으면 헤더만 + if (users.isEmpty()) { + StringBuilder onlyHeader = new StringBuilder("이름"); + for (LocalDate d : dates) onlyHeader.append(',').append(d); + onlyHeader.append(",출석률\n"); + return new String(onlyHeader.toString().getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + } + + // 3) 날짜별 출석 맵 수집 (userId -> present) + List> presenceByDate = new ArrayList<>(dates.size()); + for (LocalDate d : dates) { + presenceByDate.add(getPresenceMap(d)); + } + + int totalSessions = dates.size(); + + // 4) CSV 빌드 + StringBuilder sb = new StringBuilder(); + // Header + sb.append("이름"); + for (LocalDate d : dates) { + sb.append(',').append(d); + } + sb.append(",출석률\n"); + + // Rows + for (User u : users) { + sb.append(escape(u.getName())); + Long uid = u.getId(); + + int attended = 0; + + // 날짜별 O/X 채우면서 출석 횟수 카운트 + for (Map day : presenceByDate) { + boolean present = Boolean.TRUE.equals(day.getOrDefault(uid, false)); + if (present) attended++; + sb.append(',').append(present ? 'O' : 'X'); + } + + // 출석률: 전체 회차 기준 (중간 합류 없음 가정) + double rate = attended * 100.0 / totalSessions; + + sb.append(',') + .append(attended) + .append('/') + .append(totalSessions) + .append(" (") + .append(String.format(java.util.Locale.US, "%.1f", rate)) + .append("%)") + .append('\n'); } - sb.append(escape(date)).append(',') - .append("ALL").append(',') - .append("전체").append(',') - .append(s.getPresent()).append(',') - .append(s.getTotal()).append('\n'); return new String(sb.toString().getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); } /* ===================== helpers ===================== */ - /** date로 meeting을 보장하고 meetingId 반환 */ + /** + * date로 meeting을 보장하고 meetingId 반환 + */ @Transactional protected Long ensureMeetingAndGetId(LocalDate date) { return meetingRepository.findByMeetingDate(date) .map(Meeting::getId) - .orElseGet(() -> meetingRepository.save( - Meeting.builder().meetingDate(date).build() - ).getId()); + .orElseGet(() -> meetingRepository.save(Meeting.builder().meetingDate(date).build()).getId()); } - /** 특정 날짜의 출석 맵(userId → present) */ + /** + * 특정 날짜의 출석 맵(userId → present) + */ @Transactional(readOnly = true) protected Map getPresenceMap(LocalDate date) { Map map = new HashMap<>(); @@ -240,11 +331,7 @@ protected Map getPresenceMap(LocalDate date) { return map; } - private static String escape(String s) { - if (s == null) return ""; - String needsQuote = ",\"\n"; - boolean mustQuote = s.chars().anyMatch(ch -> needsQuote.indexOf(ch) >= 0); - if (!mustQuote) return s; - return "\"" + s.replace("\"", "\"\"") + "\""; + public record UserIdValidationResult(List validIds, List invalidIds) { + } } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java b/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java index d68dc36..7efdd33 100644 --- a/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java +++ b/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java @@ -52,7 +52,7 @@ public ResponseEntity> create( description = "전체 목록 또는 이름 검색 결과를 반환합니다.", security = { @SecurityRequirement(name = "BearerAuth") } ) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('LEAD', 'ORGANIZER', 'ADMIN')") @GetMapping("/applicants") public ResponseEntity, PageMeta>> getApplicants( @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "홍길동") @@ -89,7 +89,7 @@ public ResponseEntity> getApplicant( @PathVariable Long id diff --git a/src/main/java/inha/gdgoc/domain/manito/controller/ManitoAdminController.java b/src/main/java/inha/gdgoc/domain/manito/controller/ManitoAdminController.java new file mode 100644 index 0000000..f123320 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/controller/ManitoAdminController.java @@ -0,0 +1,53 @@ +package inha.gdgoc.domain.manito.controller; + +import inha.gdgoc.domain.manito.dto.request.ManitoSessionCreateRequest; +import inha.gdgoc.domain.manito.dto.response.ManitoSessionResponse; +import inha.gdgoc.domain.manito.service.ManitoAdminService; +import inha.gdgoc.global.dto.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/admin/manito") +@RequiredArgsConstructor +public class ManitoAdminController { + + private final ManitoAdminService manitoAdminService; + + @GetMapping("/sessions") + public ResponseEntity, Void>> listSessions() { + List body = manitoAdminService.listSessions(); + return ResponseEntity.ok( + ApiResponse.ok("세션 목록 조회 성공", body) + ); + } + + @PostMapping("/sessions") + public ResponseEntity> createSession( + @RequestBody ManitoSessionCreateRequest request + ) { + ManitoSessionResponse body = manitoAdminService.createSession(request); + return ResponseEntity.ok( + ApiResponse.ok("세션 생성 성공", body) + ); + } + + @PostMapping(value = "/upload", consumes = "multipart/form-data", produces = "text/csv; charset=UTF-8") + public ResponseEntity uploadCsv(@RequestParam("sessionCode") String sessionCode, @RequestParam("file") MultipartFile file) { + manitoAdminService.importParticipantsCsv(sessionCode, file); + String csv = manitoAdminService.buildAssignmentsCsv(sessionCode); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"manito-" + sessionCode + ".csv\"") + .body(csv); + } + + @PostMapping(value = "/upload-encrypted", consumes = "multipart/form-data") + public ResponseEntity uploadEncrypted(@RequestParam("sessionCode") String sessionCode, @RequestParam("file") MultipartFile file) { + manitoAdminService.importEncryptedCsv(sessionCode, file); + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/controller/ManitoVerifyController.java b/src/main/java/inha/gdgoc/domain/manito/controller/ManitoVerifyController.java new file mode 100644 index 0000000..a7b9137 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/controller/ManitoVerifyController.java @@ -0,0 +1,34 @@ +package inha.gdgoc.domain.manito.controller; + +import inha.gdgoc.domain.manito.dto.request.ManitoVerifyRequest; +import inha.gdgoc.domain.manito.dto.response.ManitoVerifyResponse; +import inha.gdgoc.domain.manito.entity.ManitoAssignment; +import inha.gdgoc.domain.manito.service.ManitoUserService; +import inha.gdgoc.global.dto.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/manito") +@RequiredArgsConstructor +public class ManitoVerifyController { + + private final ManitoUserService manitoUserService; + + @PostMapping("/verify") + public ResponseEntity> verify(@Valid @RequestBody ManitoVerifyRequest request) { + ManitoAssignment assignment = manitoUserService.verifyAndGetAssignment(request.sessionCode(), request.studentId(), request.pin()); + + String cipher = assignment.getEncryptedManitto(); + String ownerName = assignment.getName(); + + ManitoVerifyResponse response = new ManitoVerifyResponse(cipher, ownerName); + + return ResponseEntity.ok(ApiResponse.ok("마니또 정보 조회 성공", response)); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/controller/message/ManitoMessage.java b/src/main/java/inha/gdgoc/domain/manito/controller/message/ManitoMessage.java new file mode 100644 index 0000000..299acbc --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/controller/message/ManitoMessage.java @@ -0,0 +1,5 @@ +package inha.gdgoc.domain.manito.controller.message; + +public class ManitoMessage { + +} diff --git a/src/main/java/inha/gdgoc/domain/manito/dto/request/ManitoSessionCreateRequest.java b/src/main/java/inha/gdgoc/domain/manito/dto/request/ManitoSessionCreateRequest.java new file mode 100644 index 0000000..88205d1 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/dto/request/ManitoSessionCreateRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.manito.dto.request; + +import jakarta.validation.constraints.NotBlank; + +/** + * 마니또 세션 생성 요청 + */ +public record ManitoSessionCreateRequest( + + @NotBlank(message = "세션 코드는 필수입니다.") + String code, + + @NotBlank(message = "세션 제목은 필수입니다.") + String title +) { } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/dto/request/ManitoVerifyRequest.java b/src/main/java/inha/gdgoc/domain/manito/dto/request/ManitoVerifyRequest.java new file mode 100644 index 0000000..a6398d0 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/dto/request/ManitoVerifyRequest.java @@ -0,0 +1,24 @@ +package inha.gdgoc.domain.manito.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 마니또 확인 요청 DTO + * - 로그인 없이 세션 코드 + 학번 + PIN으로 검증 + */ +public record ManitoVerifyRequest( + + @NotBlank(message = "세션 코드는 필수입니다.") + String sessionCode, + + @NotBlank(message = "학번은 필수입니다.") + String studentId, + + @NotBlank(message = "PIN은 필수입니다.") + @Size(min = 4, max = 4, message = "PIN은 4자리여야 합니다.") + @Pattern(regexp = "\\d{4}", message = "PIN은 숫자 4자리여야 합니다.") + String pin +) { +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/dto/response/ManitoSessionResponse.java b/src/main/java/inha/gdgoc/domain/manito/dto/response/ManitoSessionResponse.java new file mode 100644 index 0000000..703f851 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/dto/response/ManitoSessionResponse.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.manito.dto.response; + +import inha.gdgoc.domain.manito.entity.ManitoSession; + +import java.time.Instant; + +public record ManitoSessionResponse(Long id, String code, String title, Instant createdAt) { + + public static ManitoSessionResponse from(ManitoSession session) { + return new ManitoSessionResponse(session.getId(), session.getCode(), session.getTitle(), session.getCreatedAt()); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/dto/response/ManitoVerifyResponse.java b/src/main/java/inha/gdgoc/domain/manito/dto/response/ManitoVerifyResponse.java new file mode 100644 index 0000000..cb969fb --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/dto/response/ManitoVerifyResponse.java @@ -0,0 +1,11 @@ +package inha.gdgoc.domain.manito.dto.response; + +/** + * 마니또 검증 성공 시, 클라이언트에서 복호화할 암호문과 + * 요청자(=giver)의 이름을 내려주는 DTO + */ +public record ManitoVerifyResponse( + String encryptedManito, + String ownerName +) { +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/entity/ManitoAssignment.java b/src/main/java/inha/gdgoc/domain/manito/entity/ManitoAssignment.java new file mode 100644 index 0000000..dab011d --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/entity/ManitoAssignment.java @@ -0,0 +1,60 @@ +package inha.gdgoc.domain.manito.entity; + +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "manito_assignments", uniqueConstraints = {@UniqueConstraint(name = "uq_manito_assignment_per_student", columnNames = {"session_id", "student_id"})}, indexes = {@Index(name = "idx_manito_assignments_session", columnList = "session_id"), @Index(name = "idx_manito_assignments_student", columnList = "student_id")}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ManitoAssignment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "session_id", nullable = false) + private ManitoSession session; + + @Column(name = "student_id", nullable = false, length = 32) + private String studentId; + + @Column(name = "name", nullable = false, length = 64) + private String name; + + /** + * clientKey/hash로만 복호화 가능한 암호문 + * 처음 업로드 시에는 아직 없을 수 있으므로 nullable 허용 + */ + @Column(name = "encrypted_manitto", columnDefinition = "text") + private String encryptedManitto; + + @Column(name = "pin_hash", nullable = false, length = 255) + private String pinHash; + + @Builder + private ManitoAssignment(ManitoSession session, String studentId, String name, String encryptedManitto, String pinHash) { + this.session = session; + this.studentId = studentId; + this.name = name; + this.encryptedManitto = encryptedManitto; + this.pinHash = pinHash; + } + + public void changePinHash(String pinHash) { + this.pinHash = pinHash; + } + + public void changeEncryptedManitto(String encryptedManitto) { + this.encryptedManitto = encryptedManitto; + } + + public void changeName(String name) { + this.name = name; + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/entity/ManitoSession.java b/src/main/java/inha/gdgoc/domain/manito/entity/ManitoSession.java new file mode 100644 index 0000000..a5b3445 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/entity/ManitoSession.java @@ -0,0 +1,41 @@ +package inha.gdgoc.domain.manito.entity; + +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "manito_sessions", indexes = {@Index(name = "idx_manito_sessions_created_at", columnList = "created_at DESC")}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ManitoSession extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 세션 코드 (예: gmg-2025, manito-2025-fall) + */ + @Column(name = "code", nullable = false, unique = true, length = 64) + private String code; + + /** + * 사람이 읽기 좋은 제목 + */ + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Builder + private ManitoSession(String code, String title) { + this.code = code; + this.title = title; + } + + public void changeTitle(String title) { + this.title = title; + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/repository/ManitoAssignmentRepository.java b/src/main/java/inha/gdgoc/domain/manito/repository/ManitoAssignmentRepository.java new file mode 100644 index 0000000..fee639b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/repository/ManitoAssignmentRepository.java @@ -0,0 +1,41 @@ +package inha.gdgoc.domain.manito.repository; + +import inha.gdgoc.domain.manito.entity.ManitoAssignment; +import inha.gdgoc.domain.manito.entity.ManitoSession; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ManitoAssignmentRepository extends JpaRepository { + + /** + * 특정 세션 + 학번(studentId) + * - verify API에서 사용 + */ + Optional findBySessionAndStudentId(ManitoSession session, String studentId); + + /** + * 세션 코드 + 학번 단일 조회 + * - sessionRepo 없이 바로 조회 가능 + */ + Optional findBySession_CodeAndStudentId(String sessionCode, String studentId); + + /** + * 특정 세션 전체 조회 + * - admin 화면 (일괄 조회) + */ + List findBySession(ManitoSession session); + + /** + * 특정 세션 내 모든 assignment 삭제 + * - 세션 재생성, 재업로드 시 유용 + */ + Long deleteBySession(ManitoSession session); + + /** + * 특정 세션의 특정 studentId가 존재하는지 확인 + * - 중복 업로드 검증 + */ + boolean existsBySessionAndStudentId(ManitoSession session, String studentId); +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/repository/ManitoSessionRepository.java b/src/main/java/inha/gdgoc/domain/manito/repository/ManitoSessionRepository.java new file mode 100644 index 0000000..72263ff --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/repository/ManitoSessionRepository.java @@ -0,0 +1,19 @@ +package inha.gdgoc.domain.manito.repository; + +import inha.gdgoc.domain.manito.entity.ManitoSession; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ManitoSessionRepository extends JpaRepository { + + /** + * 세션 코드로 조회 (예: "gmg-2025") + */ + Optional findByCode(String code); + + /** + * 세션 코드 중복 체크용 + */ + boolean existsByCode(String code); +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/service/ManitoAdminService.java b/src/main/java/inha/gdgoc/domain/manito/service/ManitoAdminService.java new file mode 100644 index 0000000..475320a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/service/ManitoAdminService.java @@ -0,0 +1,299 @@ +package inha.gdgoc.domain.manito.service; + +import inha.gdgoc.domain.manito.dto.request.ManitoSessionCreateRequest; +import inha.gdgoc.domain.manito.dto.response.ManitoSessionResponse; +import inha.gdgoc.domain.manito.entity.ManitoAssignment; +import inha.gdgoc.domain.manito.entity.ManitoSession; +import inha.gdgoc.domain.manito.repository.ManitoAssignmentRepository; +import inha.gdgoc.domain.manito.repository.ManitoSessionRepository; +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ManitoAdminService { + + private final ManitoSessionRepository sessionRepository; + private final ManitoAssignmentRepository assignmentRepository; + private final PasswordEncoder passwordEncoder; + private final ManitoPinPolicy manitoPinPolicy; // ✅ PIN 정책 주입 + + /** + * 간단 CSV escape (콤마/따옴표/줄바꿈 포함 시 따옴표 감싸기) + */ + private static String escapeCsv(String s) { + if (s == null) return ""; + boolean needQuote = s.contains(",") || s.contains("\"") || s.contains("\n") || s.contains("\r"); + if (!needQuote) return s; + return '"' + s.replace("\"", "\"\"") + '"'; + } + + private static String cleanCsvField(String raw) { + if (raw == null) return ""; + String s = raw.trim(); + // 양 끝에 쌍따옴표가 있으면 제거 + if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\"")) { + s = s.substring(1, s.length() - 1); + // CSV 이스케이프 처리된 "" → " 로 복원 + s = s.replace("\"\"", "\""); + } + return s; + } + + private static List computeGroupSizes(int n) { + if (n < 5) { + throw new IllegalArgumentException("n must be >= 5"); + } + + // 처음엔 전체를 하나의 그룹으로 두고, + // 크기가 10 이상인 그룹은 [5, 나머지]로 계속 쪼갠다. + // 그러면 모든 그룹이 5~9명이 된다. + List groups = new ArrayList<>(); + groups.add(n); + + while (true) { + int idx = -1; + for (int i = 0; i < groups.size(); i++) { + if (groups.get(i) >= 10) { + idx = i; + break; + } + } + if (idx == -1) break; // 더 이상 쪼갤 그룹 없음 + + int size = groups.get(idx); + groups.remove(idx); + groups.add(idx, size - 5); + groups.add(idx, 5); + } + + return groups; + } + + /** + * CSV 헤더: studentId,name,pin + */ + @Transactional + public void importParticipantsCsv(String sessionCode, MultipartFile file) { + ManitoSession session = sessionRepository.findByCode(sessionCode) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND, "세션 코드를 찾을 수 없습니다: " + sessionCode)); + + try (var reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { + + String header = reader.readLine(); + if (header == null) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "비어 있는 CSV 파일입니다."); + } + + // 1) 구분자 추론 (탭 우선) + String delimiter = header.contains("\t") ? "\t" : ","; + + // 2) 헤더 컬럼 개수 확인 + String[] headerCols = header.split(delimiter, -1); + // - 4개 이상: [타임스탬프, 학번, 이름, PIN, ...] + // - 3개: [studentId, name, pin] 형식으로 이미 전처리된 경우 + int studentIdx; + int nameIdx; + int pinIdx; + + if (headerCols.length >= 4) { + // 구글폼 원본처럼 타임스탬프 포함된 케이스 + studentIdx = 1; + nameIdx = 2; + pinIdx = 3; + } else if (headerCols.length == 3) { + // 사전에 [studentId,name,pin] 으로 정리된 파일 + studentIdx = 0; + nameIdx = 1; + pinIdx = 2; + } else { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "지원하지 않는 CSV 헤더 형식입니다. (컬럼 수: " + headerCols.length + ")"); + } + + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + + String[] cols = line.split(delimiter, -1); + if (cols.length <= pinIdx) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "CSV 컬럼 수가 부족합니다: " + line); + } + + String studentId = cleanCsvField(cols[studentIdx]); + String name = cleanCsvField(cols[nameIdx]); + String pinRaw = cleanCsvField(cols[pinIdx]); + + String pinPlain = manitoPinPolicy.normalize(pinRaw); + + name = name.replace("`", "").trim(); + + if (studentId.isEmpty() || name.isEmpty() || pinPlain.isEmpty()) { + // 비어 있으면 스킵 (원하면 여기서 에러 던져도 됨) + continue; + } + + String pinHash = passwordEncoder.encode(pinPlain); + + var existingOpt = assignmentRepository.findBySessionAndStudentId(session, studentId); + + if (existingOpt.isPresent()) { + ManitoAssignment existing = existingOpt.get(); + existing.changeName(name); + existing.changePinHash(pinHash); + } else { + ManitoAssignment assignment = ManitoAssignment.builder() + .session(session) + .studentId(studentId) + .name(name) + .encryptedManitto(null) // 나중에 upload-encrypted 로 채움 + .pinHash(pinHash) + .build(); + assignmentRepository.save(assignment); + } + } + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException(GlobalErrorCode.INTERNAL_SERVER_ERROR, "CSV 업로드 처리 중 오류가 발생했습니다."); + } + } + + @Transactional + public String buildAssignmentsCsv(String sessionCode) { + ManitoSession session = sessionRepository.findByCode(sessionCode) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND, "세션 코드를 찾을 수 없습니다: " + sessionCode)); + + // 참가자 전체 조회 + var participants = assignmentRepository.findBySession(session) + .stream() + .sorted(Comparator.comparing(ManitoAssignment::getStudentId)) // 학번 기준 정렬(선택) + .toList(); + + int n = participants.size(); + if (n < 5) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "매칭을 생성하기 위해서는 최소 5명 이상의 참가자가 필요합니다."); + } + + // 랜덤성 확보: 참가자 순서를 섞어놓고, 그 위에서 서킷 분할 + List shuffled = new ArrayList<>(participants); + Collections.shuffle(shuffled); // 필요하면 SecureRandom 사용해도 됨 + + // 서킷 크기 리스트 계산 (각 서킷 최소 5명) + List groupSizes = computeGroupSizes(n); + + StringBuilder sb = new StringBuilder(); + // 매칭용 헤더 (암호화 전, 순수 매칭 정보) + sb.append("giverStudentId,giverName,receiverStudentId,receiverName\n"); + + int index = 0; + for (int size : groupSizes) { + List group = shuffled.subList(index, index + size); + index += size; + + // 하나의 서킷: group[i] -> group[(i+1) % size] + for (int i = 0; i < size; i++) { + ManitoAssignment giver = group.get(i); + ManitoAssignment receiver = group.get((i + 1) % size); + + sb.append(escapeCsv(giver.getStudentId())) + .append(',') + .append(escapeCsv(giver.getName())) + .append(',') + .append(escapeCsv(receiver.getStudentId())) + .append(',') + .append(escapeCsv(receiver.getName())) + .append('\n'); + } + } + + return new String(sb.toString().getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + } + + @Transactional + public void importEncryptedCsv(String sessionCode, MultipartFile file) { + ManitoSession session = sessionRepository.findByCode(sessionCode) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND, "세션 코드를 찾을 수 없습니다: " + sessionCode)); + + try (var reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { + + String header = reader.readLine(); + if (header == null) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "비어 있는 CSV 파일입니다."); + } + + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + + String[] cols = line.split(",", -1); + if (cols.length < 2) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "CSV 컬럼 수가 부족합니다: " + line); + } + + String studentId = cleanCsvField(cols[0]); + String encryptedManitto = cleanCsvField(cols[1]); + + log.info("[MANITO] upload-encrypted row: studentId='{}', enc='{}'", studentId, encryptedManitto); + + if (studentId.isEmpty() || encryptedManitto.isEmpty()) { + // 비어 있는 줄은 그냥 스킵 + continue; + } + + ManitoAssignment assignment = assignmentRepository.findBySessionAndStudentId(session, studentId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND, "해당 학번에 대한 참가자 정보가 없습니다: " + studentId)); + + assignment.changeEncryptedManitto(encryptedManitto); + + assignmentRepository.flush(); + + ManitoAssignment reloaded = assignmentRepository.findBySessionAndStudentId(session, studentId) + .orElseThrow(); + + log.info("[MANITO] after save: studentId='{}', enc='{}'", reloaded.getStudentId(), reloaded.getEncryptedManitto()); + } + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException(GlobalErrorCode.INTERNAL_SERVER_ERROR, "암호문 CSV 업로드 처리 중 오류가 발생했습니다."); + } + } + + @Transactional + public ManitoSessionResponse createSession(ManitoSessionCreateRequest req) { + String code = req.code().trim(); + + if (sessionRepository.existsByCode(code)) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "이미 존재하는 세션 코드입니다: " + code); + } + + ManitoSession session = ManitoSession.builder().code(code).title(req.title().trim()).build(); + + sessionRepository.save(session); + return ManitoSessionResponse.from(session); + } + + @Transactional + public List listSessions() { + return sessionRepository.findAll(Sort.by(Sort.Direction.DESC, "createdAt")) + .stream() + .map(ManitoSessionResponse::from) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/service/ManitoPinPolicy.java b/src/main/java/inha/gdgoc/domain/manito/service/ManitoPinPolicy.java new file mode 100644 index 0000000..1374c62 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/service/ManitoPinPolicy.java @@ -0,0 +1,29 @@ +package inha.gdgoc.domain.manito.service; + +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import org.springframework.stereotype.Component; + +@Component +public class ManitoPinPolicy { + public String normalize(String rawPin) { + if (rawPin == null) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "PIN 값이 비어 있습니다."); + } + + // 숫자만 추출 + String digits = rawPin.replaceAll("\\D", ""); + + if (digits.isEmpty()) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "PIN 값에는 적어도 1자리 이상의 숫자가 있어야 합니다."); + } + + // 4자리 zero-padding + try { + int asInt = Integer.parseInt(digits); + return String.format("%04d", asInt); + } catch (NumberFormatException e) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "PIN 형식이 올바르지 않습니다."); + } + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/manito/service/ManitoUserService.java b/src/main/java/inha/gdgoc/domain/manito/service/ManitoUserService.java new file mode 100644 index 0000000..907698a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/manito/service/ManitoUserService.java @@ -0,0 +1,48 @@ +package inha.gdgoc.domain.manito.service; + +import inha.gdgoc.domain.manito.entity.ManitoAssignment; +import inha.gdgoc.domain.manito.entity.ManitoSession; +import inha.gdgoc.domain.manito.repository.ManitoAssignmentRepository; +import inha.gdgoc.domain.manito.repository.ManitoSessionRepository; +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ManitoUserService { + + private final ManitoSessionRepository sessionRepository; + private final ManitoAssignmentRepository assignmentRepository; + private final PasswordEncoder passwordEncoder; + private final ManitoPinPolicy manitoPinPolicy; + + @Transactional(readOnly = true) + public ManitoAssignment verifyAndGetAssignment(String sessionCode, String studentId, String pinPlain) { + + ManitoSession session = sessionRepository.findByCode(sessionCode) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND, "세션 코드가 올바르지 않습니다.")); + + ManitoAssignment assignment = assignmentRepository.findBySessionAndStudentId(session, studentId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND, "해당 학번은 세션에 참여하지 않았습니다.")); + + String normalizedPin = manitoPinPolicy.normalize(pinPlain); + if (normalizedPin.isEmpty()) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "PIN 형식이 올바르지 않습니다."); + } + + if (!passwordEncoder.matches(normalizedPin, assignment.getPinHash())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "PIN이 일치하지 않습니다."); + } + + if (assignment.getEncryptedManitto() == null || assignment.getEncryptedManitto().isBlank()) { + throw new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND, "아직 마니또 암호문이 업로드되지 않았습니다. 관리자에게 문의하세요."); + } + return assignment; + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java index b2dc02f..9cd59b2 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java @@ -85,7 +85,7 @@ public ResponseEntity> duplicatedPho } @Operation(summary = "특정 멤버 가입 신청서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") @GetMapping("/recruit/members/{memberId}") public ResponseEntity> getSpecifiedMember( @PathVariable Long memberId @@ -100,7 +100,7 @@ public ResponseEntity> getSpecifiedMe description = "설정하려는 상태(NOT 현재 상태)를 body에 보내주세요. true=입금 완료, false=입금 미완료", security = { @SecurityRequirement(name = "BearerAuth") } ) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") @PatchMapping("/recruit/members/{memberId}/payment") public ResponseEntity> updatePayment( @PathVariable Long memberId, @@ -122,7 +122,7 @@ public ResponseEntity> updatePayment( description = "전체 목록 또는 이름 검색 결과를 반환합니다. 검색어(question)를 주면 이름 포함 검색, 없으면 전체 조회. sort랑 dir은 example 값 그대로 코딩하는 것 추천...", security = { @SecurityRequirement(name = "BearerAuth") } ) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") @GetMapping("/recruit/members") public ResponseEntity, PageMeta>> getMembers( @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "소연") diff --git a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java new file mode 100644 index 0000000..56135d9 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java @@ -0,0 +1,80 @@ +package inha.gdgoc.domain.user.controller; + +import inha.gdgoc.domain.user.dto.request.UpdateRoleRequest; +import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest; +import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.service.UserAdminService; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import inha.gdgoc.global.dto.response.ApiResponse; +import inha.gdgoc.global.dto.response.PageMeta; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/admin/users") +public class UserAdminController { + + private final UserAdminService userAdminService; + + // q(검색) + role/team(필터) + pageable + @Operation(summary = "사용자 요약 목록 조회", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") + @GetMapping + public ResponseEntity, 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 + ) { + Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC; + Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort)); + Page result = userAdminService.listUsers(q, pageable); + return ResponseEntity.ok(ApiResponse.ok("USER_SUMMARY_LIST_RETRIEVED", result, PageMeta.of(result))); + } + + @Operation(summary = "사용자 역할/팀 수정", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") + @PatchMapping("/{userId}/role-team") + public ResponseEntity> updateRoleTeam( + @AuthenticationPrincipal CustomUserDetails me, + @PathVariable Long userId, + @RequestBody UpdateUserRoleTeamRequest req + ) { + userAdminService.updateRoleAndTeam(me, userId, req); + return ResponseEntity.ok(ApiResponse.ok("USER_ROLE_TEAM_UPDATED")); + } + + @Operation(summary = "사용자 역할 수정", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") + @PatchMapping("/{userId}/role") + public ResponseEntity> updateUserRole( + @AuthenticationPrincipal CustomUserDetails me, + @PathVariable Long userId, + @RequestBody @Valid UpdateRoleRequest req + ) { + userAdminService.updateUserRoleWithRules(me, userId, req.role()); + return ResponseEntity.ok(ApiResponse.ok("USER_ROLE_UPDATED")); + } + + @Operation(summary = "사용자 삭제", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") + @DeleteMapping("/{userId}") + public ResponseEntity> deleteUser( + @AuthenticationPrincipal CustomUserDetails me, + @PathVariable Long userId + ) { + userAdminService.deleteUserWithRules(me, userId); + return ResponseEntity.ok(ApiResponse.ok("USER_DELETED")); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java new file mode 100644 index 0000000..201a45c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java @@ -0,0 +1,8 @@ +package inha.gdgoc.domain.user.dto.request; + +import inha.gdgoc.domain.user.enums.UserRole; +import jakarta.validation.constraints.NotNull; + +public record UpdateRoleRequest( + @NotNull UserRole role +) {} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java new file mode 100644 index 0000000..3b942e6 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java @@ -0,0 +1,9 @@ +package inha.gdgoc.domain.user.dto.request; + +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; + +public record UpdateUserRoleTeamRequest( + UserRole role, // null 이면 변경 안 함 + TeamType team // null 이면 변경 안 함 +) {} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java b/src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java new file mode 100644 index 0000000..0d9ff9f --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java @@ -0,0 +1,14 @@ +package inha.gdgoc.domain.user.dto.response; + +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; + +public record UserSummaryResponse( + Long id, + String name, + String major, + String studentId, + String email, + UserRole userRole, + TeamType team +) {} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/entity/User.java b/src/main/java/inha/gdgoc/domain/user/entity/User.java index f05c240..dcff2f1 100644 --- a/src/main/java/inha/gdgoc/domain/user/entity/User.java +++ b/src/main/java/inha/gdgoc/domain/user/entity/User.java @@ -131,4 +131,6 @@ public void updatePassword(String password) throws NoSuchAlgorithmException, Inv public boolean isGuest() { return this.userRole == UserRole.GUEST; } + public void changeRole(UserRole role) { this.userRole = role; } + public void changeTeam(TeamType team) { this.team = team; } } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java b/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java index f517096..c02719e 100644 --- a/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java +++ b/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java @@ -5,6 +5,7 @@ @Getter public enum TeamType { + HQ("HQ"), HR("HR"), PR_DESIGN("PR/DESIGN"), TECH("TECH"), @@ -18,6 +19,7 @@ public enum TeamType { public static TeamType from(String raw) { if (raw == null) return null; return switch (raw) { + case "HQ" -> HQ; case "HR" -> HR; case "TECH" -> TECH; case "BD" -> BD; diff --git a/src/main/java/inha/gdgoc/domain/user/enums/UserRole.java b/src/main/java/inha/gdgoc/domain/user/enums/UserRole.java index 081ff0b..11911c5 100644 --- a/src/main/java/inha/gdgoc/domain/user/enums/UserRole.java +++ b/src/main/java/inha/gdgoc/domain/user/enums/UserRole.java @@ -4,16 +4,33 @@ @Getter public enum UserRole { - GUEST("GUEST"), - MEMBER("MEMBER"), - CORE("CORE"), - LEAD("LEAD"), - ORGANIZER("ORGANIZER"), - ADMIN("ADMIN"); + GUEST("GUEST"), MEMBER("MEMBER"), CORE("CORE"), LEAD("LEAD"), ORGANIZER("ORGANIZER"), ADMIN("ADMIN"); private final String role; UserRole(String role) { this.role = role; } + + /** + * 나(me)가 required 이상 권한인지 + */ + public static boolean hasAtLeast(UserRole me, UserRole required) { + if (me == null || required == null) return false; + return me.rank() >= required.rank(); + } + + /** + * 역할 서열(낮음→높음). enum 순서 바뀌어도 여기만 수정하면 됨 + */ + public int rank() { + return switch (this) { + case GUEST -> 0; + case MEMBER -> 1; + case CORE -> 2; + case LEAD -> 3; + case ORGANIZER -> 4; + case ADMIN -> 5; + }; + } } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java index 664b3fd..3836883 100644 --- a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java +++ b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java @@ -1,9 +1,15 @@ package inha.gdgoc.domain.user.repository; +import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; @@ -29,4 +35,15 @@ public interface UserRepository extends JpaRepository, UserRepositor // 필요 시: 특정 팀 전체 멤버(역할 무관) List findByTeam(TeamType team); + + @Query(""" + select new inha.gdgoc.domain.user.dto.response.UserSummaryResponse( + u.id, u.name, u.major, u.studentId, u.email, u.userRole, u.team + ) + from User u + where (:q is null or :q = '' or u.name like concat('%', :q, '%')) + """) + Page findSummaries(@Param("q") String q, Pageable pageable); + + @NotNull Optional findById(@NotNull Long id); } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java b/src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java new file mode 100644 index 0000000..cca2cb0 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java @@ -0,0 +1,243 @@ +package inha.gdgoc.domain.user.service; + +import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest; +import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class UserAdminService { + + private final UserRepository userRepository; + + /* ======================= 목록 ======================= */ + + @Transactional(readOnly = true) + public Page listUsers(String q, Pageable pageable) { + Pageable fixed = rewriteSort(pageable); + return userRepository.findSummaries(q, fixed); + } + + private Pageable rewriteSort(Pageable pageable) { + Sort original = pageable.getSort(); + if (original.isUnsorted()) return pageable; + + Sort composed = Sort.unsorted(); + boolean hasUserRoleOrder = false; + boolean hasTeamOrder = false; + final String roleRankCase = + "(CASE " + + " WHEN u.userRole = 'GUEST' THEN 0 " + + " WHEN u.userRole = 'MEMBER' THEN 1 " + + " WHEN u.userRole = 'CORE' THEN 2 " + + " WHEN u.userRole = 'LEAD' THEN 3 " + + " WHEN u.userRole = 'ORGANIZER' THEN 4 " + + " WHEN u.userRole = 'ADMIN' THEN 5 " + + " ELSE -1 END)"; + for (Sort.Order o : original) { + String prop = o.getProperty(); + Sort.Direction dir = o.getDirection(); + + if ("userRole".equals(prop)) { + hasUserRoleOrder = true; + composed = composed.and(JpaSort.unsafe(dir, roleRankCase)); + } else if ("team".equals(prop)) { + hasTeamOrder = true; + composed = composed.and(Sort.by(new Sort.Order(dir, "team"))); + } else { + composed = composed.and(Sort.by(new Sort.Order(dir, prop))); + } + } + + if (hasUserRoleOrder) { + composed = composed.and(Sort.by("name").ascending()); + } + + if (hasTeamOrder) { + composed = composed.and(JpaSort.unsafe(Sort.Direction.DESC, roleRankCase)); + composed = composed.and(Sort.by("name").ascending()); + } + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), composed); + } + + /* ======================= 수정 ======================= */ + + @Transactional + public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, UpdateUserRoleTeamRequest req) { + User editorUser = getEditor(editor); + User target = userRepository.findById(targetUserId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + UserRole editorRole = editorUser.getUserRole(); + UserRole targetCurrentRole = target.getUserRole(); + + UserRole newRole = (req.role() != null ? req.role() : targetCurrentRole); + TeamType requestedTeam = (req.team() != null ? req.team() : target.getTeam()); + + // 팀 보유 가능한 역할만 팀 허용 (CORE, LEAD) + TeamType newTeam = isTeamAssignableRole(newRole) ? requestedTeam : null; + + // 공통: 에디터는 대상의 현재/신규 role보다 엄격히 높아야 함 + if (!(editorRole.rank() > targetCurrentRole.rank())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "동급/상위 사용자의 정보는 변경할 수 없습니다."); + } + if (!(editorRole.rank() > newRole.rank())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자신보다 크거나 같은 권한으로 변경할 수 없습니다."); + } + + switch (editorRole) { + case ADMIN -> { + if (editorUser.getId().equals(target.getId()) && newRole.rank() < UserRole.ADMIN.rank()) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신을 강등할 수 없습니다."); + } + } + case ORGANIZER -> { + if (targetCurrentRole == UserRole.ADMIN) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 수정할 수 없습니다."); + } + } + case LEAD -> { + if (editor.getTeam() == null) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD 토큰에 팀 정보가 없습니다."); + } + if (!(targetCurrentRole == UserRole.MEMBER || targetCurrentRole == UserRole.CORE)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 수정할 수 있습니다."); + } + if (!(newRole == UserRole.MEMBER || newRole == UserRole.CORE)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE로만 변경할 수 있습니다."); + } + + if (editor.getTeam() == TeamType.HR) { + // HR-LEAD: 본인 제외 타인지원 팀 변경 가능 + if (editorUser.getId().equals(target.getId())) { + if (req.team() != null && !Objects.equals(req.team(), target.getTeam())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-LEAD도 자기 자신의 팀은 변경할 수 없습니다."); + } + } + } else { + if (target.getTeam() != editor.getTeam()) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 수정할 수 없습니다."); + } + if (req.team() != null && !Objects.equals(req.team(), editor.getTeam())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 팀을 변경할 수 없습니다."); + } + } + } + default -> throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER); + } + + targetChange(target, newRole, newTeam); + } + + @Transactional + public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, UserRole newRole) { + var meRole = me.getRole(); + var meTeam = me.getTeam(); + + var target = userRepository.findById(targetUserId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + if (Objects.equals(me.getUserId(), targetUserId)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신의 역할은 변경할 수 없습니다."); + } + + UserRole current = target.getUserRole(); + + // HR-CORE 특례: GUEST -> MEMBER + boolean isHrCore = (meRole == UserRole.CORE) && (meTeam == TeamType.HR); + if (isHrCore) { + if (current == UserRole.GUEST && newRole == UserRole.MEMBER) { + target.changeRole(UserRole.MEMBER); + if (!isTeamAssignableRole(UserRole.MEMBER)) target.changeTeam(null); + userRepository.save(target); + return; + } + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-CORE는 GUEST→MEMBER 변경만 가능"); + } + + boolean higherThanCurrent = meRole.rank() > current.rank(); + boolean higherThanNew = meRole.rank() > newRole.rank(); + if (!higherThanCurrent || !higherThanNew) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "요청한 역할 변경 권한이 없습니다."); + } + + target.changeRole(newRole); + if (!isTeamAssignableRole(newRole)) target.changeTeam(null); + userRepository.save(target); + } + + @Transactional + public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) { + User editor = userRepository.findById(me.getUserId()) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER)); + User target = userRepository.findById(targetUserId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + if (Objects.equals(editor.getId(), target.getId())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신은 삭제할 수 없습니다."); + } + + UserRole editorRole = editor.getUserRole(); + TeamType editorTeam = editor.getTeam(); + UserRole targetRole = target.getUserRole(); + TeamType targetTeam = target.getTeam(); + + if (!(editorRole.rank() > targetRole.rank())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "동급/상급 사용자는 삭제할 수 없습니다."); + } + + switch (editorRole) { + case ADMIN -> {} + case ORGANIZER -> { + if (targetRole == UserRole.ADMIN) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 삭제할 수 없습니다."); + } + } + case LEAD -> { + if (!(targetRole == UserRole.MEMBER || targetRole == UserRole.CORE)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 삭제할 수 있습니다."); + } + if (editorTeam != TeamType.HR) { + if (editorTeam == null || targetTeam != editorTeam) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 삭제할 수 없습니다."); + } + } + } + default -> throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER); + } + + userRepository.delete(target); + } + + private void targetChange(User target, UserRole newRole, TeamType newTeam) { + target.changeRole(newRole); + if (!isTeamAssignableRole(newRole)) newTeam = null; + target.changeTeam(newTeam); + userRepository.save(target); + } + + private User getEditor(CustomUserDetails editor) { + return userRepository.findById(editor.getUserId()) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER)); + } + + private boolean isTeamAssignableRole(UserRole role) { + return role == UserRole.CORE || role == UserRole.LEAD; + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index c0263b3..124a9e8 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -1,19 +1,11 @@ package inha.gdgoc.global.config.jwt; -import static inha.gdgoc.global.exception.GlobalErrorCode.INVALID_JWT_REQUEST; - import inha.gdgoc.domain.auth.enums.LoginType; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.global.exception.BusinessException; import io.jsonwebtoken.*; -import java.time.Duration; -import java.util.Base64; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.Set; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -22,9 +14,15 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; +import java.time.Duration; +import java.util.*; + +import static inha.gdgoc.global.exception.GlobalErrorCode.INVALID_JWT_REQUEST; + @RequiredArgsConstructor @Service public class TokenProvider { + private final JwtProperties jwtProperties; // 자체 로그인용 토큰 생성 @@ -44,8 +42,7 @@ public String generateRefreshToken(User user, Duration expiredAt, LoginType logi return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user, loginType); } - public Claims validToken(String token) throws ExpiredJwtException, UnsupportedJwtException, - MalformedJwtException, SignatureException, IllegalArgumentException { + public Claims validToken(String token) throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException { return getClaims(token); } @@ -62,32 +59,31 @@ public Authentication getAuthentication(String token) { String roleStr = claims.get("role", String.class); if (roleStr == null) throw new BusinessException(INVALID_JWT_REQUEST); UserRole userRole = UserRole.valueOf(roleStr); - String roleName = "ROLE_" + userRole.name(); - Set authorities = - Collections.singleton(new SimpleGrantedAuthority(roleName)); - // team (선택) - 토큰에는 enum name(String)으로 저장됨. null/오타는 무시. + // 권한 세트 구성 + Set authorities = new HashSet<>(); + // 1) 역할 권한 + authorities.add(new SimpleGrantedAuthority("ROLE_" + userRole.name())); + + // 2) 팀 권한 (선택) TeamType team = null; String teamStr = claims.get("team", String.class); if (teamStr != null && !teamStr.isBlank()) { try { team = TeamType.valueOf(teamStr); + authorities.add(new SimpleGrantedAuthority("TEAM_" + team.name())); } catch (IllegalArgumentException ignored) { - // 구버전 토큰 또는 잘못된 값이면 null 유지 } } - CustomUserDetails userDetails = - new CustomUserDetails(userId, username, "", authorities, userRole, team); + CustomUserDetails userDetails = new CustomUserDetails(userId, username, "", authorities, userRole, team); return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); } private String makeToken(Date expiry, User user, LoginType loginType) { Date now = new Date(); - String issuer = (loginType == LoginType.SELF_SIGNUP) - ? jwtProperties.getSelfIssuer() - : jwtProperties.getGoogleIssuer(); + String issuer = (loginType == LoginType.SELF_SIGNUP) ? jwtProperties.getSelfIssuer() : jwtProperties.getGoogleIssuer(); // team: enum name 저장(예: "PR_DESIGN"), 없으면 null String teamEnumName = (user.getTeam() == null) ? null : user.getTeam().name(); @@ -102,8 +98,8 @@ private String makeToken(Date expiry, User user, LoginType loginType) { .claim("loginType", loginType.name()) .claim("role", user.getUserRole().name()) .claim("team", teamEnumName) - .signWith(SignatureAlgorithm.HS256, - Base64.getEncoder().encodeToString(jwtProperties.getSecretKey().getBytes())) + .signWith(SignatureAlgorithm.HS256, Base64.getEncoder() + .encodeToString(jwtProperties.getSecretKey().getBytes())) .compact(); } @@ -116,16 +112,12 @@ private Claims getClaims(String token) { @Getter public static class CustomUserDetails extends org.springframework.security.core.userdetails.User { + private final Long userId; private final UserRole role; private final TeamType team; - public CustomUserDetails(Long userId, - String username, - String password, - Collection authorities, - UserRole role, - TeamType team) { + public CustomUserDetails(Long userId, String username, String password, Collection authorities, UserRole role, TeamType team) { super(username, password, authorities); this.userId = userId; this.role = role; diff --git a/src/main/java/inha/gdgoc/global/exception/BusinessException.java b/src/main/java/inha/gdgoc/global/exception/BusinessException.java index e45c264..b700359 100644 --- a/src/main/java/inha/gdgoc/global/exception/BusinessException.java +++ b/src/main/java/inha/gdgoc/global/exception/BusinessException.java @@ -12,4 +12,8 @@ public BusinessException(ErrorCode errorCode) { this.errorCode = errorCode; } + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } } diff --git a/src/main/java/inha/gdgoc/global/exception/GlobalErrorCode.java b/src/main/java/inha/gdgoc/global/exception/GlobalErrorCode.java index 5cf5675..26e28ad 100644 --- a/src/main/java/inha/gdgoc/global/exception/GlobalErrorCode.java +++ b/src/main/java/inha/gdgoc/global/exception/GlobalErrorCode.java @@ -14,7 +14,7 @@ public enum GlobalErrorCode implements ErrorCode { // 403 FORBIDDEN INVALID_JWT_REQUEST(HttpStatus.FORBIDDEN, "잘못된 JWT 토큰입니다."), - FORBIDDEN_USER(HttpStatus.NOT_FOUND, "권한이 부족합니다."), + FORBIDDEN_USER(HttpStatus.FORBIDDEN, "권한이 부족합니다."), // 404 Not Found RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 리소스입니다."), diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index f459e92..3c07680 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -49,7 +49,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/apply/**", "/api/v1/check/**", "/api/v1/core-recruit", - "/api/v1/fileupload") + "/api/v1/fileupload", + "/api/v1/manito/verify") .permitAll() .anyRequest() .authenticated() diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index cdefe07..bd3381c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -50,7 +50,8 @@ spring: logging: level: org.hibernate.SQL: debug - org.hibernate.type: off + org.hibernate.orm.jdbc.bind: trace + org.hibernate.type: trace google: