diff --git a/backend/build.gradle b/backend/build.gradle index fa1c3665..3d01dc5c 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -95,6 +95,9 @@ dependencies { // Thymleaf implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // Apache POI (Excel) + implementation 'org.apache.poi:poi-ooxml:5.5.1' } jacoco { diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java index 2e733a27..a6a5ac15 100644 --- a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java @@ -4,10 +4,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.sejongisc.backend.admin.dto.AdminUserRequest; -import org.sejongisc.backend.user.dto.UserInfoResponse; // 기존 DTO 활용 +import org.sejongisc.backend.admin.dto.AdminUserResponse; +import org.sejongisc.backend.admin.dto.ExcelSyncResponse; +import org.sejongisc.backend.admin.service.AdminUserService; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.UserStatus; -import org.sejongisc.backend.user.service.UserService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -15,7 +16,6 @@ import org.springframework.web.multipart.MultipartFile; import java.util.List; -import java.util.Map; import java.util.UUID; @RestController @@ -24,55 +24,77 @@ @Tag(name = "관리자 API", description = "운영진 및 개발자용 회원 관리 API") public class AdminUserController { - private final UserService userService; + private final AdminUserService adminUserService; // --- [회장/운영진용] 회원 관리 API --- - @Operation(summary = "엑셀 명단 업로드 및 동기화", description = "엑셀 파일을 업로드하여 신규 회원을 등록하고, 기존 회원의 기수/직위를 갱신합니다.") + @Operation( + summary = "엑셀 명단 업로드 및 동기화", + description = """ + 엑셀 파일을 업로드하여 신규 회원을 등록하고, 기존 회원의 기수/직위를 갱신합니다. (회장/관리자용) + - .xlsx 형식만 업로드 가능합니다. \s + - 학번 기준으로 회원을 생성하거나 기존 정보를 갱신합니다. \s + - 신규 회원의 초기 비밀번호는 전화번호 숫자만(예: 01012345678)으로 설정됩니다. + """ + ) @PostMapping(value = "/upload-excel", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") // 회장, 개발자만 가능 - public ResponseEntity uploadMemberExcel(@RequestPart("file") MultipartFile file) { - // 엑셀 파싱 및 DB 동기화 결과 반환 - //ExcelSyncResultDto result = adminUserService.syncMembersFromExcel(file); - //return ResponseEntity.ok(result); - return null; + public ResponseEntity uploadMemberExcel(@RequestPart("file") MultipartFile file) { + return ResponseEntity.ok(adminUserService.syncUsersFromExcel(file)); } - @Operation(summary = "전체 회원 목록 조회", description = "모든 회원의 정보를 조회합니다. (회장/관리자용)") - @GetMapping("") - @PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'MANAGER')") - public ResponseEntity> getAllUsers(@RequestBody AdminUserRequest request) { - //return ResponseEntity.ok(userService.findAllUsers()); // TODO : 전체 조회, 기수별 조회, 이름 검색 등 기능 추가 (페이징은 추후 고려) - return null; + @Operation( + summary = "전체 회원 목록 조회", + description = """ + 모든 회원의 정보를 조회합니다. (시스템 관리자용) + - 키워드, 기수, 권한(Role), 활동 상태(UserStatus) 조건으로 필터 조회합니다. \s + - 키워드는 이름, 학번, 이메일 기준으로 검색됩니다. \s + - 조건 미입력 시 전체 조회됩니다. \s + - 정렬 기준: 기수 최신순 → 이름 오름차순 + """ + ) + @GetMapping + @PreAuthorize("hasAnyRole('SYSTEM_ADMIN')") + public ResponseEntity> getAllUsers(@ModelAttribute AdminUserRequest request) { + // TODO: 페이징 추후 고려 + return ResponseEntity.ok(adminUserService.findAllUsers(request)); } - @Operation(summary = "회원 활동 상태 변경", description = "ACTIVE, INACTIVE, GRADUATED 등으로 상태를 변경합니다.") + @Operation(summary = "회원 활동 상태 변경", description = "ACTIVE, INACTIVE, GRADUATED 등으로 상태를 변경합니다. (시스템 관리자용)") @PatchMapping("/{userId}/status") - @PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('SYSTEM_ADMIN')") public ResponseEntity updateUserStatus( @PathVariable UUID userId, @RequestParam UserStatus status) { - //userService.updateUserStatus(userId, status); - return ResponseEntity.ok(Map.of("message", "사용자 상태가 " + status + "(으)로 변경되었습니다.")); + adminUserService.updateUserStatus(userId, status); + return ResponseEntity.noContent().build(); } // --- [시스템 관리자용 or 회장용] 권한 및 계정 제어 API --- // TODO : 회장 권한 논의 필요 - @Operation(summary = "회원 권한 변경", description = "특정 유저의 Role(PRESIDENT, VICE_PRESIDENT, TEAM_LEADER)을 변경합니다.)") + @Operation(summary = "회원 권한 변경", description = "특정 유저의 Role(PRESIDENT, VICE_PRESIDENT, TEAM_LEADER)을 변경합니다. (시스템 관리자용)") @PatchMapping("/{userId}/role") @PreAuthorize("hasRole('SYSTEM_ADMIN')") public ResponseEntity updateUserRole( @PathVariable UUID userId, @RequestParam Role role) { - //userService.updateUserRole(userId, role); - return ResponseEntity.ok(Map.of("message", "사용자 권한이 " + role + "(으)로 변경되었습니다.")); + adminUserService.updateUserRole(userId, role); + return ResponseEntity.noContent().build(); } - @Operation(summary = "회원 강제 탈퇴", description = "시스템에서 유저를 완전히 삭제합니다. (시스템 관리자용)") + @Operation(summary = "선배(SENIOR) 등급 변경", description = "특정 유저를 선배(SENIOR) 등급으로 변경합니다. (회장/관리자용)") + @PatchMapping("/{userId}/senior") + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") + public ResponseEntity promoteToSenior(@PathVariable UUID userId) { + adminUserService.promoteToSenior(userId); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "회원 강제 탈퇴", description = "시스템에서 유저를 탈퇴 처리합니다. (시스템 관리자용)") @DeleteMapping("/{userId}") @PreAuthorize("hasRole('SYSTEM_ADMIN')") public ResponseEntity forceDeleteUser(@PathVariable UUID userId) { - //userService.deleteUserWithOauth(userId); - return ResponseEntity.ok(Map.of("message", "해당 사용자가 시스템에서 완전히 삭제되었습니다.")); + adminUserService.deleteUser(userId); + return ResponseEntity.noContent().build(); } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserRequest.java b/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserRequest.java index a393dc81..891242d8 100644 --- a/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserRequest.java @@ -1,5 +1,15 @@ package org.sejongisc.backend.admin.dto; -public class AdminUserRequest { +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.UserStatus; -} +/** + * 관리자 페이지의 사용자 필터링/검색 조회 요청 + */ +public record AdminUserRequest ( + String keyword, // 키워드 + Integer generation, // 기수 + Role role, // 권한 + UserStatus status // 상태 +) { +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserResponse.java b/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserResponse.java new file mode 100644 index 00000000..90f5dd8f --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserResponse.java @@ -0,0 +1,25 @@ +package org.sejongisc.backend.admin.dto; + +import lombok.Builder; +import org.sejongisc.backend.user.entity.*; + +import java.util.UUID; + +@Builder +public record AdminUserResponse( + UUID id, + String studentId, + String name, + String email, + String phoneNumber, + long point, // Account 엔티티의 balance 값 + Grade grade, + Role role, + UserStatus status, + Integer generation, + String college, + String department, + String teamName, + String positionName +) { +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/dto/ExcelSyncResponse.java b/backend/src/main/java/org/sejongisc/backend/admin/dto/ExcelSyncResponse.java new file mode 100644 index 00000000..9cab7474 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/dto/ExcelSyncResponse.java @@ -0,0 +1,10 @@ +package org.sejongisc.backend.admin.dto; + +/** + * 엑셀 동기화 결과 응답 + */ +public record ExcelSyncResponse( + int createdCount, + int updatedCount +) { +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/dto/UserExcelRow.java b/backend/src/main/java/org/sejongisc/backend/admin/dto/UserExcelRow.java new file mode 100644 index 00000000..e86c6701 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/dto/UserExcelRow.java @@ -0,0 +1,21 @@ +package org.sejongisc.backend.admin.dto; + +import lombok.Builder; + +/** + * 엑셀 파일의 한 행 데이터를 담는 Row + */ +@Builder +public record UserExcelRow( + String studentId, + String name, + String phone, + String teamName, + String generation, + String college, + String department, + String grade, + String position, + String gender +) { +} diff --git a/backend/src/main/java/org/sejongisc/backend/admin/repository/AdminUserRepository.java b/backend/src/main/java/org/sejongisc/backend/admin/repository/AdminUserRepository.java new file mode 100644 index 00000000..0b848c87 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/repository/AdminUserRepository.java @@ -0,0 +1,65 @@ +package org.sejongisc.backend.admin.repository; + +import org.sejongisc.backend.admin.dto.AdminUserResponse; +import org.sejongisc.backend.point.entity.AccountType; +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.User; +import org.sejongisc.backend.user.entity.UserStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.UUID; + +/** + * 관리자 화면용 User 조회 전용 레포지토리 + * - User 도메인의 기본 CRUD는 user 패키지의 UserRepository에서 담당 + * - 해당 클래스는 Admin 페이지에서만 사용되는 조회 쿼리와 Admin DTO로의 프로젝션 로직을 정의 + */ +public interface AdminUserRepository extends JpaRepository { + + @Query(""" + SELECT new org.sejongisc.backend.admin.dto.AdminUserResponse( + u.userId, + u.studentId, + u.name, + u.email, + u.phoneNumber, + COALESCE(a.balance, 0), + u.grade, + u.role, + u.status, + u.generation, + u.college, + u.department, + u.teamName, + u.positionName + ) + FROM User u + LEFT JOIN Account a + ON u.userId = a.ownerId + AND a.type = :accountType + WHERE + (:keyword IS NULL OR + u.name LIKE %:keyword% OR + u.email LIKE %:keyword% OR + (:numericKeyword IS NOT NULL AND ( + u.studentId LIKE %:numericKeyword% OR + u.phoneNumber LIKE %:numericKeyword% + )) + ) + AND (:generation IS NULL OR u.generation = :generation) + AND (:role IS NULL OR u.role = :role) + AND (:status IS NULL OR u.status = :status) + ORDER BY u.generation DESC, u.name ASC + """) + List findAllByAdminFilter( + @Param("keyword") String keyword, + @Param("numericKeyword") String numericKeyword, + @Param("generation") Integer generation, + @Param("role") Role role, + @Param("status") UserStatus status, + @Param("accountType") AccountType accountType + ); +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/service/AdminUserService.java b/backend/src/main/java/org/sejongisc/backend/admin/service/AdminUserService.java new file mode 100644 index 00000000..e52ce615 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/service/AdminUserService.java @@ -0,0 +1,171 @@ +package org.sejongisc.backend.admin.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.sejongisc.backend.admin.dto.AdminUserRequest; +import org.sejongisc.backend.admin.dto.AdminUserResponse; +import org.sejongisc.backend.admin.dto.ExcelSyncResponse; +import org.sejongisc.backend.admin.dto.UserExcelRow; +import org.sejongisc.backend.admin.repository.AdminUserRepository; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.point.entity.AccountType; +import org.sejongisc.backend.user.entity.*; +import org.sejongisc.backend.user.service.UserService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminUserService { + private final AdminUserRepository adminUserRepository; + private final AdminUserSyncService adminUserSyncService; + private final UserService userService; + + /** + * 엑셀 파일을 읽어 동기화 프로세스 시작 + */ + public ExcelSyncResponse syncUsersFromExcel(MultipartFile file) { + DataFormatter formatter = new DataFormatter(); + + // 엑셀 파일 검증 + validateFile(file); + List excelRows = new ArrayList<>(); + + try (InputStream is = file.getInputStream(); Workbook workbook = new XSSFWorkbook(is)) { + Sheet sheet = workbook.getSheetAt(0); + + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) continue; + + // 학번 없으면 빈 행으로 간주 + String studentId = getCellValue(row, 4, formatter); + if (studentId.isEmpty()) continue; + + // 필수값 검증 및 UserExcelRow 리스트에 추가 + excelRows.add(buildExcelRow(row, studentId, i, formatter)); + } + + // 추가할 내용이 없는 빈 파일의 경우 예외 + if (excelRows.isEmpty()) { + throw new CustomException(ErrorCode.EMPTY_FILE); + } + log.info("엑셀 파일 파싱 완료: 파일명={}, 총 건수={}", file.getOriginalFilename(), excelRows.size()); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("엑셀 동기화 중 오류 발생: ", e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + return adminUserSyncService.syncMemberData(excelRows); + } + + /** + * 관리자 필터 조건(키워드, 기수, 권한, 상태)에 따른 사용자 목록 조회 + */ + @Transactional(readOnly = true) + public List findAllUsers(AdminUserRequest request) { + String keyword = request.keyword(); + String numericKeyword = null; + if (keyword != null && !keyword.isEmpty()) { + // 학번 및 전화번호 검색을 위해 숫자만 남긴 키워드 + numericKeyword = keyword.replaceAll("[^0-9]", ""); + if (numericKeyword.isEmpty()) numericKeyword = null; + } + + return adminUserRepository.findAllByAdminFilter( + keyword, numericKeyword, request.generation(), request.role(), request.status(), AccountType.USER + ); + } + + /** + * 특정 사용자의 활동 상태 변경 + */ + @Transactional + public void updateUserStatus(UUID userId, UserStatus status) { + userService.updateUserStatus(userId, status); + } + + /** + * 특정 사용자의 시스템 권한(Role) 변경 + */ + @Transactional + public void updateUserRole(UUID userId, Role role) { + userService.updateUserRole(userId, role); + } + + /** + * 특정 사용자를 선배(SENIOR) 등급으로 변경 + */ + @Transactional + public void promoteToSenior(UUID userId) { + userService.promoteToSenior(userId); + } + + /** + * 사용자 계정 삭제 (soft delete) + */ + @Transactional + public void deleteUser(UUID userId) { + userService.deleteUserSoftDelete(userId); + log.info("관리자에 의한 강제 탈퇴 완료: userId={}", userId); + } + + /** + * 엑셀 파일의 셀을 문자열로 변환 + */ + private String getCellValue(Row row, int cellIndex, DataFormatter formatter) { + Cell cell = row.getCell(cellIndex); + if (cell == null) return ""; + + return formatter.formatCellValue(cell).trim(); + } + + /** + * 엑셀 파일 검증 + */ + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) throw new CustomException(ErrorCode.EMPTY_FILE); + + String fileName = file.getOriginalFilename(); + if (fileName == null || !fileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) throw new CustomException(ErrorCode.INVALID_FILE_FORMAT); + } + + /** + * 엑셀의 특정 행을 읽어 UserExcelRow 생성 + */ + private UserExcelRow buildExcelRow(Row row, String studentId, int rowIndex, DataFormatter formatter) { + String name = getCellValue(row, 3, formatter); + String phone = getCellValue(row, 5, formatter); + String team = getCellValue(row, 1, formatter); + String grade = getCellValue(row, 8, formatter); + + // 학번은 있지만 필수 데이터가 누락된 경우 + if (name.isEmpty() || phone.isEmpty() || team.isEmpty() || grade.isEmpty()) { + log.error("엑셀 데이터 누락: 행 번호={}, 학번={}", rowIndex + 1, studentId); + throw new CustomException(ErrorCode.INVALID_EXCEL_STRUCTURE); + } + + return UserExcelRow.builder() + .studentId(studentId) + .name(name) + .phone(phone.replaceAll("[^0-9]", "")) + .teamName(team) + .generation(getCellValue(row, 2, formatter)) + .college(getCellValue(row, 6, formatter)) + .department(getCellValue(row, 7, formatter)) + .grade(grade) + .position(getCellValue(row, 9, formatter)) + .gender(getCellValue(row, 10, formatter)) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/service/AdminUserSyncService.java b/backend/src/main/java/org/sejongisc/backend/admin/service/AdminUserSyncService.java new file mode 100644 index 00000000..a3e1fd8c --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/service/AdminUserSyncService.java @@ -0,0 +1,111 @@ +package org.sejongisc.backend.admin.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.admin.dto.ExcelSyncResponse; +import org.sejongisc.backend.admin.dto.UserExcelRow; +import org.sejongisc.backend.common.annotation.OptimisticRetry; +import org.sejongisc.backend.point.dto.AccountEntry; +import org.sejongisc.backend.point.entity.Account; +import org.sejongisc.backend.point.entity.AccountName; +import org.sejongisc.backend.point.entity.TransactionReason; +import org.sejongisc.backend.point.service.AccountService; +import org.sejongisc.backend.point.service.PointLedgerService; +import org.sejongisc.backend.user.entity.*; +import org.sejongisc.backend.user.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminUserSyncService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final AccountService accountService; + private final PointLedgerService pointLedgerService; + + /** + * 엑셀로부터 추출된 사용자 데이터를 DB와 동기화 + */ + @Transactional + @OptimisticRetry + public ExcelSyncResponse syncMemberData(List excelRows) { + int createdCount = 0; + int updatedCount = 0; + + // 기존 활동 인원 일괄 비활성화 (SYSTEM_ADMIN 제외) + userRepository.findAllByStatus(UserStatus.ACTIVE) + .stream() + .filter(user -> user.getRole() != Role.SYSTEM_ADMIN) + .forEach(user -> user.setStatus(UserStatus.INACTIVE)); + + for (UserExcelRow rowData : excelRows) { + Optional existingUser = userRepository.findByStudentId(rowData.studentId()); + boolean isNew = existingUser.isEmpty(); + + // 기존 사용자 조회, 없으면 신규 사용자 생성 + User user = existingUser.orElseGet(() -> User.builder() + .studentId(rowData.studentId()) + .passwordHash(passwordEncoder.encode(rowData.phone())) + .build()); + + // 엑셀 데이터 매핑 및 ACTIVE 상태 복구 + updateUserFromRow(user, rowData); + User savedUser = userRepository.save(user); + + if (isNew) { + createdCount++; + // 계정 생성 및 가입 보상 포인트 지급 + Account userAccount = accountService.createUserAccount(savedUser.getUserId()); + pointLedgerService.processTransaction( + TransactionReason.SIGNUP_REWARD, + savedUser.getUserId(), + AccountEntry.credit(accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE), 100L), + AccountEntry.debit(userAccount, 100L) + ); + log.info("신규 사용자 자동 가입 및 포인트 지급 완료: {}", user.getStudentId()); + } else { + updatedCount++; + } + } + + log.info("엑셀 사용자 데이터 동기화 완료: 신규 등록={}, 갱신={}", createdCount, updatedCount); + return new ExcelSyncResponse(createdCount, updatedCount); + } + + /** + * 엑셀 행 데이터를 유저 엔티티 필드에 매핑 + */ + private void updateUserFromRow(User user, UserExcelRow rowData) { + Integer generation = parseGeneration(rowData.generation()); + Grade grade = Grade.fromString(rowData.grade()); + Role role = Role.fromPosition(rowData.position()); + Gender gender = Gender.fromString(rowData.gender()); + + user.applyExcelData( + rowData.name(), + rowData.phone(), + rowData.teamName(), + generation, + rowData.college(), + rowData.department(), + grade, + rowData.position(), + role, + gender + ); + } + + /** + * 기수 문자열에서 숫자만 추출하여 파싱 + */ + private int parseGeneration(String genStr) { + String clean = genStr.replaceAll("[^0-9]", ""); + return clean.isEmpty() ? 0 : Integer.parseInt(clean); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java index 3950c2ad..a09f7e0c 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java @@ -13,7 +13,6 @@ import org.sejongisc.backend.common.auth.dto.AuthRequest; import org.sejongisc.backend.common.auth.dto.AuthResponse; import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.user.util.PasswordPolicyValidator; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -34,10 +33,18 @@ public class AuthService { public AuthResponse login(AuthRequest request) { User user = userRepository.findByStudentId(request.getStudentId()) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(request.getPassword()); - if (user.getPasswordHash() == null || !passwordEncoder.matches(trimmedPassword, user.getPasswordHash())) { + + // 탈퇴 회원 로그인 차단 + if (!user.canLogin()) { + throw new CustomException(ErrorCode.USER_WITHDRAWN); + } + + // 비밀번호 일치 확인 + if (user.getPasswordHash() == null || + !passwordEncoder.matches(request.getPassword().trim(), user.getPasswordHash())) { throw new CustomException(ErrorCode.UNAUTHORIZED); } + String accessToken = jwtProvider.createToken(user.getUserId(), user.getRole(), user.getEmail()); String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index fc668015..6dab44dd 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -89,6 +89,13 @@ public enum ErrorCode { DUPLICATE_PHONE(HttpStatus.CONFLICT, "이미 사용 중인 전화번호입니다."), DUPLICATE_USER(HttpStatus.CONFLICT, "이미 가입된 사용자입니다."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."), + USER_WITHDRAWN(HttpStatus.FORBIDDEN, "탈퇴한 회원은 로그인할 수 없습니다."), + + // EXCEL + + INVALID_FILE_FORMAT(HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다. .xlsx 파일을 업로드해주세요."), + INVALID_EXCEL_STRUCTURE(HttpStatus.UNPROCESSABLE_ENTITY, "엑셀 양식이 일치하지 않습니다. 필수 컬럼을 확인해주세요."), + EMPTY_FILE(HttpStatus.BAD_REQUEST, "업로드된 파일이 비어있습니다."), // BETTING diff --git a/backend/src/main/java/org/sejongisc/backend/point/entity/Account.java b/backend/src/main/java/org/sejongisc/backend/point/entity/Account.java index 5cd43703..f33e92b7 100644 --- a/backend/src/main/java/org/sejongisc/backend/point/entity/Account.java +++ b/backend/src/main/java/org/sejongisc/backend/point/entity/Account.java @@ -16,6 +16,12 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Table( + name = "account", + uniqueConstraints = { + @UniqueConstraint(name = "uk_account_owner_type", columnNames = {"owner_id", "type"}) + } +) public class Account extends BasePostgresEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/Gender.java b/backend/src/main/java/org/sejongisc/backend/user/entity/Gender.java index 5c1da2ee..50bf80e8 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/Gender.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/Gender.java @@ -2,5 +2,16 @@ public enum Gender { MALE, - FEMALE + FEMALE; + + public static Gender fromString(String genderStr) { + if (genderStr == null || genderStr.isBlank()) { + return null; + } + + if (genderStr.contains("남")) return MALE; + if (genderStr.contains("여")) return FEMALE; + + return null; + } } diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/Grade.java b/backend/src/main/java/org/sejongisc/backend/user/entity/Grade.java new file mode 100644 index 00000000..d405b49b --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/Grade.java @@ -0,0 +1,18 @@ +package org.sejongisc.backend.user.entity; + +public enum Grade { + NEW_MEMBER, // 신입부원 + ASSOCIATE_MEMBER, // 준회원 + REGULAR_MEMBER, // 정회원 + SENIOR; // 선배/OB + + public static Grade fromString(String gradeStr) { + if (gradeStr == null) return NEW_MEMBER; + + if (gradeStr.contains("정회원")) return REGULAR_MEMBER; + if (gradeStr.contains("준회원")) return ASSOCIATE_MEMBER; + if (gradeStr.contains("선배") || gradeStr.contains("OB")) return SENIOR; + + return NEW_MEMBER; + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/Role.java b/backend/src/main/java/org/sejongisc/backend/user/entity/Role.java index 590b5a20..7aad5982 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/Role.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/Role.java @@ -3,11 +3,31 @@ // 일반 회원가입 : 회장 승인이 있어야만 설정 가능 // 엑셀 회원가입 : 회장 승인 없이 설정 가능 (user.isManagerPosition 으로 판단) public enum Role { - SYSTEM_ADMIN, // 시스템 관리자 - PRESIDENT, // 회장 - VICE_PRESIDENT, // 부회장 - TEAM_LEADER, // 팀장 - TEAM_MEMBER, // 부원 - PENDING_MEMBER; // 대기회원 (회장이 승인 전 상태) - // 추가 가능 : SENIOR (선배/OB): 게시물 열람 위주 (포인트 활동 등은 제한 가능) + SYSTEM_ADMIN, // 시스템 관리자 + PRESIDENT, // 회장 + VICE_PRESIDENT, // 부회장 + TEAM_LEADER, // 팀장 + TEAM_MEMBER, // 부원 + PENDING_MEMBER; // 대기회원 (회장이 승인 전 상태) + // 추가 가능 : SENIOR (선배/OB): 게시물 열람 위주 (포인트 활동 등은 제한 가능) + + public static Role fromPosition(String position) { + if (position == null || position.isBlank()) { + return TEAM_MEMBER; + } + + if (position.contains("회장") && !position.contains("부회장")) { + return PRESIDENT; + } + + if (position.contains("부회장") || position.contains("부대표자")) { + return VICE_PRESIDENT; + } + + if (position.contains("팀장")) { + return TEAM_LEADER; + } + + return TEAM_MEMBER; + } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java index 3f1feb00..ff1ba46d 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java @@ -49,8 +49,10 @@ public class User extends BasePostgresEntity{ @Enumerated(EnumType.STRING) private Gender gender; // 성별 - @Column(name = "is_new_member", nullable = false) - private boolean isNewMember; // 신규 여부 (포인트나 이벤트 대상자 선정용) + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private Grade grade = Grade.NEW_MEMBER; // 신입/준/정회원 @Enumerated(EnumType.STRING) @Column(nullable = false) @@ -100,13 +102,16 @@ public void prePersist() { this.point = 0; } } - public void updatePoint(int amount) { - this.point += amount; + + // 로그인 가능 여부 + public boolean canLogin() { + return this.status != UserStatus.OUT; } + public static User createUserWithSignupAndPending(SignupRequest request, String encodedPw) { return User.builder() - .role(Role.TEAM_MEMBER) // TODO : 운영진 승인 로직 추가 후 PENDING_MEMBER로 변경 필요 + .role(Role.PENDING_MEMBER) .studentId(request.getStudentId()) .name(request.getName()) .passwordHash(encodedPw) @@ -117,9 +122,35 @@ public static User createUserWithSignupAndPending(SignupRequest request, String .department(request.getDepartment()) // 학과 .generation(request.getGeneration()) // .teamName(request.getTeamName()) // 소속 팀명 - .isNewMember(true) // 신규 가입자 + .grade(Grade.NEW_MEMBER) // 신규 가입자 .point(0) .status(UserStatus.ACTIVE) // 기본 활동 상태 .build(); } + + public void applyExcelData( + String name, + String phone, + String teamName, + Integer generation, + String college, + String department, + Grade grade, + String position, + Role role, + Gender gender + ) { + this.name = name; + this.phoneNumber = phone; + this.teamName = teamName; + this.generation = generation; + this.college = college; + this.department = department; + this.status = UserStatus.ACTIVE; + this.grade = grade; + this.positionName = position; + this.role = role; + this.gender = gender; + } + } diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/UserStatus.java b/backend/src/main/java/org/sejongisc/backend/user/entity/UserStatus.java index 65a8d51c..4882ea16 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/UserStatus.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/UserStatus.java @@ -10,8 +10,7 @@ public enum UserStatus { ACTIVE("활동 중"), INACTIVE("활동 중지"), GRADUATED("졸업생"), - OUT("탈퇴"), - PENDING("승인 대기"); + OUT("탈퇴"); private final String description; // 한글 명칭 } diff --git a/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java b/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java index 56e366a6..0966f14b 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java @@ -2,6 +2,7 @@ import org.sejongisc.backend.user.entity.User; +import org.sejongisc.backend.user.entity.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -20,8 +21,11 @@ public interface UserRepository extends JpaRepository { @Query( "SELECT u FROM User u " + "LEFT JOIN Account a ON u.userId = a.ownerId " + - "WHERE a.accountId IS NULL") + "WHERE a.accountId IS NULL" + ) List findAllUsersMissingAccount(); Optional findByStudentId(String studentId); + + List findAllByStatus(UserStatus status); } diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index dcdeaf3e..a6ff3827 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -18,6 +18,8 @@ import org.sejongisc.backend.point.service.AccountService; import org.sejongisc.backend.point.service.PointLedgerService; import org.sejongisc.backend.user.dto.UserUpdateRequest; +import org.sejongisc.backend.user.entity.Grade; +import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.user.entity.UserStatus; import org.sejongisc.backend.user.repository.UserRepository; @@ -140,12 +142,43 @@ public void resetPasswordByToken(String resetToken, String newPassword) { refreshTokenService.deleteByUserId(user.getUserId()); } + @Transactional(readOnly = true) public List findAllUsersMissingAccount() { return userRepository.findAllUsersMissingAccount(); } + @Transactional + public void updateUserStatus(UUID userId, UserStatus status) { + User user = findUser(userId); + user.setStatus(status); + log.info("사용자 상태 변경 완료: userId={}", userId); + } + + @Transactional + public void updateUserRole(UUID userId, Role role) { + User user = findUser(userId); + user.setRole(role); + log.info("사용자 권한 변경 완료: userId={}", userId); + } + + @Transactional + public void promoteToSenior(UUID userId) { + User user = findUser(userId); + + // grade 및 status 변경 + user.setGrade(Grade.SENIOR); + user.setStatus(UserStatus.GRADUATED); + + log.info("선배 등급 전환 완료: userId={}, 학번={}", userId, user.getStudentId()); + } + // --- 내부 헬퍼 메서드 --- + private User findUser(UUID userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + private String validateNotBlank(String value, String fieldName) { if (value == null || value.trim().isEmpty()) { throw new CustomException(ErrorCode.INVALID_INPUT);