diff --git a/build.gradle b/build.gradle index 8523b115..5d38b838 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter' //ElasticSearch implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' diff --git a/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java b/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java index 50ebe29f..296c49f4 100644 --- a/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java +++ b/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java @@ -16,16 +16,17 @@ @Tag(name = "05. Admin") @WebAdapter -@RequiredArgsConstructor @RequestMapping("/api/managements") +@RequiredArgsConstructor public class RegisterMemberController { + private final RegisterMemberUsecase registerMemberUsecase; @Operation(summary = "단일 회원 등록 API") @PostMapping("/members") @Secured("ROLE_ADMIN") public void registerMember(@AuthenticationPrincipal SecurityUserDetails userInfo, - @RequestBody @Valid RegisterMemberRequest request){ + @RequestBody @Valid RegisterMemberRequest request) { registerMemberUsecase.registerMember(userInfo.getUserId(), request); } } \ No newline at end of file diff --git a/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberCsvController.java b/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberCsvController.java new file mode 100644 index 00000000..0a9b229b --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberCsvController.java @@ -0,0 +1,35 @@ +package clap.server.adapter.inbound.web.admin; + +import clap.server.adapter.inbound.security.SecurityUserDetails; +import clap.server.application.port.inbound.admin.RegisterMemberUsecase; +import clap.server.common.annotation.architecture.WebAdapter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "05. Admin") +@WebAdapter +@RequestMapping("/api/managements") +public class RegisterMemberCsvController { + private final RegisterMemberUsecase registerMemberUsecase; + + public RegisterMemberCsvController(RegisterMemberUsecase registerMemberUsecase) { + this.registerMemberUsecase = registerMemberUsecase; + } + + @Operation(summary = "CSV 파일로 회원 등록 API") + @PostMapping("/members/upload") + @Secured("ROLE_ADMIN") + public ResponseEntity registerMembersFromCsv( + @AuthenticationPrincipal SecurityUserDetails userInfo, + @RequestParam("file") MultipartFile file) { + int addedCount = registerMemberUsecase.registerMembersFromCsv(userInfo.getUserId(), file); + return ResponseEntity.ok(addedCount + "명의 회원이 등록되었습니다."); + } +} diff --git a/src/main/java/clap/server/application/mapper/MemberMapper.java b/src/main/java/clap/server/application/mapper/MemberMapper.java index c8379761..df6a54c7 100644 --- a/src/main/java/clap/server/application/mapper/MemberMapper.java +++ b/src/main/java/clap/server/application/mapper/MemberMapper.java @@ -4,6 +4,7 @@ import clap.server.adapter.inbound.web.dto.member.MemberDetailInfoResponse; import clap.server.adapter.inbound.web.dto.member.MemberProfileResponse; import clap.server.domain.model.member.Member; +import clap.server.domain.model.member.MemberInfo; public class MemberMapper { private MemberMapper() { @@ -40,5 +41,19 @@ public static MemberDetailInfoResponse.NotificationSettingInfoResponse toNotific member.getEmailNotificationEnabled(), member.getKakaoworkNotificationEnabled() ); + + } + public static Member toMember(MemberInfo memberInfo) { + return Member.builder() + .memberInfo(memberInfo) + .agitNotificationEnabled(null) + .emailNotificationEnabled(null) + .kakaoworkNotificationEnabled(null) + .admin(null) + .imageUrl(null) + .status(null) + .password(null) + .build(); } + } \ No newline at end of file diff --git a/src/main/java/clap/server/application/port/inbound/admin/RegisterMemberUsecase.java b/src/main/java/clap/server/application/port/inbound/admin/RegisterMemberUsecase.java index 44855dab..08972e2e 100644 --- a/src/main/java/clap/server/application/port/inbound/admin/RegisterMemberUsecase.java +++ b/src/main/java/clap/server/application/port/inbound/admin/RegisterMemberUsecase.java @@ -1,7 +1,10 @@ package clap.server.application.port.inbound.admin; import clap.server.adapter.inbound.web.dto.admin.RegisterMemberRequest; +import org.springframework.web.multipart.MultipartFile; public interface RegisterMemberUsecase { void registerMember(Long adminId, RegisterMemberRequest request); + + int registerMembersFromCsv(Long adminId, MultipartFile file); } diff --git a/src/main/java/clap/server/application/service/admin/CsvParseService.java b/src/main/java/clap/server/application/service/admin/CsvParseService.java new file mode 100644 index 00000000..def504c0 --- /dev/null +++ b/src/main/java/clap/server/application/service/admin/CsvParseService.java @@ -0,0 +1,68 @@ +package clap.server.application.service.admin; + +import clap.server.application.port.outbound.member.LoadDepartmentPort; +import clap.server.domain.model.member.Department; +import clap.server.domain.model.member.Member; +import clap.server.domain.model.member.MemberInfo; +import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole; +import clap.server.exception.ApplicationException; +import clap.server.exception.code.DepartmentErrorCode; +import clap.server.exception.code.MemberErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; + +import static clap.server.application.mapper.MemberMapper.toMember; +import static clap.server.domain.model.member.MemberInfo.toMemberInfo; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class CsvParseService { + + private final LoadDepartmentPort loadDepartmentPort; + + public List parse(MultipartFile file) { + List members = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + String[] fields = line.split(","); + if (fields.length != 7) { + throw ApplicationException.from(MemberErrorCode.INVALID_CSV_FORMAT); + } + members.add(mapToMember(fields)); + } + } catch (IOException e) { + throw ApplicationException.from(MemberErrorCode.CSV_PARSING_ERROR); + } + return members; + } + + private Member mapToMember(String[] fields) { + // 부서 ID로 Department 객체 조회 + Long departmentId = Long.parseLong(fields[2].trim()); + Department department = loadDepartmentPort.findById(departmentId) + .orElseThrow(() -> new ApplicationException(DepartmentErrorCode.DEPARTMENT_NOT_FOUND)); + + MemberInfo memberInfo = toMemberInfo( + fields[0].trim(), // name + fields[4].trim(), // email + fields[1].trim(), // nickname + Boolean.parseBoolean(fields[6].trim()), // isReviewer + department, // department + MemberRole.valueOf(fields[5].trim()), // role + fields[3].trim() // departmentRole + ); + + return toMember(memberInfo); + } +} diff --git a/src/main/java/clap/server/application/service/admin/RegisterMemberService.java b/src/main/java/clap/server/application/service/admin/RegisterMemberService.java index 2985c564..115e9410 100644 --- a/src/main/java/clap/server/application/service/admin/RegisterMemberService.java +++ b/src/main/java/clap/server/application/service/admin/RegisterMemberService.java @@ -14,6 +14,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @ApplicationService @RequiredArgsConstructor @@ -22,18 +25,31 @@ class RegisterMemberService implements RegisterMemberUsecase { private final CommandMemberPort commandMemberPort; private final LoadDepartmentPort loadDepartmentPort; private final PasswordEncoder passwordEncoder; + private final CsvParseService csvParser; @Override @Transactional public void registerMember(Long adminId, RegisterMemberRequest request) { Member admin = memberService.findActiveMember(adminId); - Department department = loadDepartmentPort.findById(request.departmentId()).orElseThrow(()-> - new ApplicationException(DepartmentErrorCode.DEPARTMENT_NOT_FOUND)); + Department department = loadDepartmentPort.findById(request.departmentId()) + .orElseThrow(() -> new ApplicationException(DepartmentErrorCode.DEPARTMENT_NOT_FOUND)); - //TODO: 인프라팀만 담당자가 될 수 있도록 수정해야함 + // TODO: 인프라팀만 담당자가 될 수 있도록 수정해야함 MemberInfo memberInfo = MemberInfo.toMemberInfo(request.name(), request.email(), request.nickname(), request.isReviewer(), department, request.role(), request.departmentRole()); Member member = Member.createMember(admin, memberInfo); commandMemberPort.save(member); } + + @Override + @Transactional + public int registerMembersFromCsv(Long adminId, MultipartFile file) { + List members = csvParser.parse(file); + Member admin = memberService.findActiveMember(adminId); + members.forEach(member -> { + member.register(admin); + commandMemberPort.save(member); + }); + return members.size(); + } } diff --git a/src/main/java/clap/server/domain/model/member/Member.java b/src/main/java/clap/server/domain/model/member/Member.java index 6f68b919..25615234 100644 --- a/src/main/java/clap/server/domain/model/member/Member.java +++ b/src/main/java/clap/server/domain/model/member/Member.java @@ -136,4 +136,8 @@ public void verifyPassword(String encodedPassword) { throw new DomainException(MemberErrorCode.PASSWORD_VERIFY_FAILED); } } + + public void register(Member admin) { + this.admin = admin; // 관리자 설정 + } } diff --git a/src/main/java/clap/server/domain/model/member/MemberInfo.java b/src/main/java/clap/server/domain/model/member/MemberInfo.java index 9f4d3bd4..e6a9aece 100644 --- a/src/main/java/clap/server/domain/model/member/MemberInfo.java +++ b/src/main/java/clap/server/domain/model/member/MemberInfo.java @@ -65,7 +65,7 @@ public void updateName(String name) { public static void assertReviewerIsManager(boolean isReviewer, MemberRole role) { if (isReviewer) { if (role != MemberRole.ROLE_MANAGER) { - throw new DomainException(MemberErrorCode.MEMBER_REGISTER_FAILED); + throw new DomainException(MemberErrorCode.MEMBER_REGISTRATION_FAILED); } } } diff --git a/src/main/java/clap/server/exception/ExceptionAdvice.java b/src/main/java/clap/server/exception/ExceptionAdvice.java index ba8dc8f5..47535bea 100644 --- a/src/main/java/clap/server/exception/ExceptionAdvice.java +++ b/src/main/java/clap/server/exception/ExceptionAdvice.java @@ -3,6 +3,7 @@ import clap.server.exception.code.AuthErrorCode; import clap.server.exception.code.BaseErrorCode; import clap.server.exception.code.GlobalErrorCode; +import clap.server.exception.code.MemberErrorCode; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -48,7 +49,7 @@ public ResponseEntity handleMethodArgumentNotValid( return handleExceptionInternalArgs( e, HttpHeaders.EMPTY, - GlobalErrorCode.BAD_REQUEST, + GlobalErrorCode.BAD_REQUEST, // GlobalErrorCode 사용 request, errors ); @@ -57,11 +58,16 @@ public ResponseEntity handleMethodArgumentNotValid( @ExceptionHandler public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { String errorMessage = e.getConstraintViolations().stream() - .map(ConstraintViolation::getMessage) - .findFirst() - .orElseThrow(() -> new RuntimeException("ConstraintViolationException Error")); + .map(ConstraintViolation::getMessage) + .findFirst() + .orElseThrow(() -> new RuntimeException("ConstraintViolationException Error")); - return handleExceptionInternalConstraint(e, GlobalErrorCode.valueOf(errorMessage), HttpHeaders.EMPTY, request); + return handleExceptionInternalConstraint( + e, + GlobalErrorCode.valueOf(errorMessage), // GlobalErrorCode 사용 + HttpHeaders.EMPTY, + request + ); } @ExceptionHandler @@ -70,7 +76,7 @@ public ResponseEntity exception(Exception e, WebRequest request) { return handleExceptionInternalFalse( e, - GlobalErrorCode.INTERNAL_SERVER_ERROR, + GlobalErrorCode.INTERNAL_SERVER_ERROR, // GlobalErrorCode 사용 HttpHeaders.EMPTY, GlobalErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus(), request, @@ -78,12 +84,20 @@ public ResponseEntity exception(Exception e, WebRequest request) { ); } - @ExceptionHandler(value = { BaseException.class }) - public ResponseEntity onThrowException( - BaseException exception, - HttpServletRequest request) { + @ExceptionHandler(ApplicationException.class) + public ResponseEntity handleApplicationException(ApplicationException e, WebRequest request) { + // CSV 관련 에러 처리 유지 + if (e.getCode() == MemberErrorCode.CSV_PARSING_ERROR || e.getCode() == MemberErrorCode.INVALID_CSV_FORMAT) { + log.error("CSV Parsing Error: {}", e.getCode().getMessage()); + return buildErrorResponse(e.getCode()); + } + return buildErrorResponse(e.getCode()); + } + @ExceptionHandler(value = { BaseException.class }) + public ResponseEntity onThrowException(BaseException exception, HttpServletRequest request) { BaseErrorCode baseErrorCode = exception.getCode(); + log.error("BaseException occurred: {}", baseErrorCode.getMessage()); return handleExceptionInternal(exception, baseErrorCode, null, request); } @@ -170,4 +184,12 @@ public ResponseEntity handleAccessDeniedException(AccessDeniedException AuthErrorCode.FORBIDDEN.getMessage() ); } + + private ResponseEntity buildErrorResponse(BaseErrorCode errorCode) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(Map.of( + "code", errorCode.getCustomCode(), + "message", errorCode.getMessage() + )); + } } diff --git a/src/main/java/clap/server/exception/code/MemberErrorCode.java b/src/main/java/clap/server/exception/code/MemberErrorCode.java index f62ef2aa..72bc2e65 100644 --- a/src/main/java/clap/server/exception/code/MemberErrorCode.java +++ b/src/main/java/clap/server/exception/code/MemberErrorCode.java @@ -10,9 +10,11 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_001", "회원을 찾을 수 없습니다."), ACTIVE_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_002", "활성화 회원을 찾을 수 없습니다."), NOT_A_REVIEWER(HttpStatus.FORBIDDEN, "MEMBER_003", "리뷰어 권한이 없습니다."), - MEMBER_REGISTER_FAILED(HttpStatus.BAD_REQUEST, "MEMBER_004", "회원 등록에 실패하였습니다"), NOT_A_COMMENTER(HttpStatus.FORBIDDEN, "MEMBER_005", "댓글 권한이 없습니다."), - PASSWORD_VERIFY_FAILED(HttpStatus.BAD_REQUEST, "MEMBER_006", "비밀번호 검증에 실패하였습니다") + PASSWORD_VERIFY_FAILED(HttpStatus.BAD_REQUEST, "MEMBER_006", "비밀번호 검증에 실패하였습니다"), + INVALID_CSV_FORMAT(HttpStatus.BAD_REQUEST, "MEMBER_007", "CSV 파일 형식이 잘못되었습니다."), + CSV_PARSING_ERROR(HttpStatus.BAD_REQUEST, "MEMBER_008", "CSV 데이터 파싱 중 오류가 발생했습니다."), + MEMBER_REGISTRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MEMBER_009", "회원 등록 중 오류가 발생했습니다.") ; private final HttpStatus httpStatus;