diff --git a/build.gradle b/build.gradle index a0a581b..4d9db4d 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,8 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + implementation 'software.amazon.awssdk:s3:2.28.23' } tasks.named('test') { diff --git a/src/main/java/dmu/dasom/api/ApiApplication.java b/src/main/java/dmu/dasom/api/ApiApplication.java index 721665e..ffc869d 100644 --- a/src/main/java/dmu/dasom/api/ApiApplication.java +++ b/src/main/java/dmu/dasom/api/ApiApplication.java @@ -3,11 +3,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; @EnableJpaAuditing @SpringBootApplication +@EnableAsync public class ApiApplication { - public static void main(String[] args) { SpringApplication.run(ApiApplication.class, args); } diff --git a/src/main/java/dmu/dasom/api/domain/activity/controller/ActivityController.java b/src/main/java/dmu/dasom/api/domain/activity/controller/ActivityController.java new file mode 100644 index 0000000..c099323 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/controller/ActivityController.java @@ -0,0 +1,59 @@ +package dmu.dasom.api.domain.activity.controller; + +import dmu.dasom.api.domain.activity.dto.ActivityResponseDto; +import dmu.dasom.api.domain.activity.service.ActivityService; +import dmu.dasom.api.domain.common.exception.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/activities") +@RequiredArgsConstructor +@Tag(name = "Activity API", description = "활동 연혁 조회 API") +public class ActivityController { + + private final ActivityService activityService; + + @Operation(summary = "활동 연혁 전체 조회", description = "모든 활동 연혁을 연도별, 섹션별로 그룹화하여 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "활동 연혁 전체 조회 성공"), + @ApiResponse(responseCode = "405", description = "허용되지 않은 요청 방식", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "허용되지 않은 메서드", + value = "{ \"code\": \"C007\", \"message\": \"허용되지 않은 요청 방식입니다.\" }" + ) + } + )), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "서버 문제 발생", + value = "{ \"code\": \"C009\", \"message\": \"서버에 문제가 발생하였습니다.\" }" + ) + } + )) + }) + @GetMapping + public ResponseEntity> getActivities() { + return ResponseEntity.ok(activityService.getActivities()); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/controller/AdminActivityController.java b/src/main/java/dmu/dasom/api/domain/activity/controller/AdminActivityController.java new file mode 100644 index 0000000..ce34be2 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/controller/AdminActivityController.java @@ -0,0 +1,72 @@ +package dmu.dasom.api.domain.activity.controller; + +import dmu.dasom.api.domain.activity.dto.ActivityRequestDto; +import dmu.dasom.api.domain.activity.service.ActivityService; +import dmu.dasom.api.domain.common.exception.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/activities") +@RequiredArgsConstructor +@Tag(name = "ADMIN - Activity API", description = "어드민 활동 연혁 관리 API") +public class AdminActivityController { + + private final ActivityService activityService; + + @Operation(summary = "활동 연혁 생성") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "활동 연혁 생성 성공"), + @ApiResponse(responseCode = "400", description = "요청 값 유효성 검사 실패", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "유효성 검사 실패", value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }"))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "인증 실패", value = "{ \"code\": \"C001\", \"message\": \"인증되지 않은 사용자입니다.\" }"))), + @ApiResponse(responseCode = "403", description = "접근 권한 없음 (ADMIN이 아님)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "권한 없음", value = "{ \"code\": \"C002\", \"message\": \"접근 권한이 없습니다.\" }"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "서버 문제 발생", value = "{ \"code\": \"C009\", \"message\": \"서버에 문제가 발생하였습니다.\" }"))) + }) + @PostMapping + public ResponseEntity createActivity(@Valid @RequestBody ActivityRequestDto requestDto) { + activityService.createActivity(requestDto); + return ResponseEntity.status(201).build(); + } + + @Operation(summary = "활동 연혁 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "활동 연혁 수정 성공"), + @ApiResponse(responseCode = "400", description = "요청 값 유효성 검사 실패", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "유효성 검사 실패", value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }"))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "인증 실패", value = "{ \"code\": \"C001\", \"message\": \"인증되지 않은 사용자입니다.\" }"))), + @ApiResponse(responseCode = "403", description = "접근 권한 없음 (ADMIN이 아님)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "권한 없음", value = "{ \"code\": \"C002\", \"message\": \"접근 권한이 없습니다.\" }"))), + @ApiResponse(responseCode = "404", description = "수정할 리소스를 찾을 수 없음", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "조회 결과 없음", value = "{ \"code\": \"C010\", \"message\": \"해당 리소스를 찾을 수 없습니다.\" }"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "서버 문제 발생", value = "{ \"code\": \"C009\", \"message\": \"서버에 문제가 발생하였습니다.\" }"))) + }) + @PutMapping("/{activityId}") + public ResponseEntity updateActivity( + @PathVariable @Min(1) Long activityId, + @Valid @RequestBody ActivityRequestDto requestDto + ) { + activityService.updateActivity(activityId, requestDto); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "활동 연혁 삭제") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "활동 연혁 삭제 성공"), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "인증 실패", value = "{ \"code\": \"C001\", \"message\": \"인증되지 않은 사용자입니다.\" }"))), + @ApiResponse(responseCode = "403", description = "접근 권한 없음 (ADMIN이 아님)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "권한 없음", value = "{ \"code\": \"C002\", \"message\": \"접근 권한이 없습니다.\" }"))), + @ApiResponse(responseCode = "404", description = "삭제할 리소스를 찾을 수 없음", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "조회 결과 없음", value = "{ \"code\": \"C010\", \"message\": \"해당 리소스를 찾을 수 없습니다.\" }"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "서버 문제 발생", value = "{ \"code\": \"C009\", \"message\": \"서버에 문제가 발생하였습니다.\" }"))) + }) + @DeleteMapping("/{activityId}") + public ResponseEntity deleteActivity(@PathVariable Long activityId) { + activityService.deleteActivity(activityId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityItemDto.java b/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityItemDto.java new file mode 100644 index 0000000..af39df2 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityItemDto.java @@ -0,0 +1,37 @@ +package dmu.dasom.api.domain.activity.dto; + +import dmu.dasom.api.domain.activity.entity.Activity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; +import java.time.format.DateTimeFormatter; + +@Getter +@Builder +@Schema(name = "ActivityItemDto", description = "개별 활동 목록") +public class ActivityItemDto { + + @Schema(description = "활동 고유 ID", example = "1") + private final Long id; + + @Schema(description = "활동 날짜", example = "05.10") + private final String monthDay; // 날짜 필드 추가 + + @Schema(description = "활동 제목", example = "컴퓨터 공학부 경진대회") + private final String title; + + @Schema(description = "수상 내역", example = "최우수상") + private final String award; + + public static ActivityItemDto of(Activity activity) { + String formattedMonthDay = activity.getActivityDate() + .format(DateTimeFormatter.ofPattern("MM.dd")); + + return ActivityItemDto.builder() + .id(activity.getId()) + .monthDay(formattedMonthDay) + .title(activity.getTitle()) + .award(activity.getAward()) + .build(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityRequestDto.java b/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityRequestDto.java new file mode 100644 index 0000000..75e8a69 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityRequestDto.java @@ -0,0 +1,35 @@ +package dmu.dasom.api.domain.activity.dto; + +import dmu.dasom.api.domain.activity.entity.Activity; +import dmu.dasom.api.domain.activity.entity.Section; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder +@Schema(name = "ActivityHistoryRequestDto", description = "활동 연혁 생성/수정 요청 DTO") +public class ActivityRequestDto { + + @NotNull(message = "활동 날짜는 필수입니다.") + @Schema(description = "활동 날짜", example = "2024-08-21") + private LocalDate activityDate; + + @Schema(description = "활동 섹션", example = "교내 경진대회", maxLength = 50) + private String section; + + @NotBlank(message = "활동 제목은 필수입니다.") + @Size(max = 50, message = "제목은 50자 이내로 입력해주세요.") + @Schema(description = "활동 제목", example = "컴퓨터 공학부 경진대회", maxLength = 50) + private String title; + + @Size(max = 50, message = "수상 내역은 50자 이내로 입력해주세요.") + @Schema(description = "수상 내역 (선택 사항)", example = "최우수상", maxLength = 50) + private String award; + +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityResponseDto.java b/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityResponseDto.java new file mode 100644 index 0000000..8da36ef --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/dto/ActivityResponseDto.java @@ -0,0 +1,60 @@ +package dmu.dasom.api.domain.activity.dto; + +import dmu.dasom.api.domain.activity.entity.Activity; +import dmu.dasom.api.domain.activity.entity.Section; +import lombok.Builder; +import lombok.Getter; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Getter +@Builder +public class ActivityResponseDto { + + private final int year; + private final List sections; + + public static List of(List activities) { + return activities.stream() + .collect(Collectors.groupingBy(activity -> activity.getActivityDate().getYear())) + .entrySet().stream() + .sorted(Map.Entry.comparingByKey()) // 1. 연도 오름차순 정렬 + .map(entryByYear -> { + List sectionDtos = groupAndSortSections(entryByYear.getValue()); + return ActivityResponseDto.builder() + .year(entryByYear.getKey()) + .sections(sectionDtos) + .build(); + }) + .collect(Collectors.toList()); + } + + private static List groupAndSortSections(List activitiesForYear) { + return activitiesForYear.stream() + .collect(Collectors.groupingBy(Activity::getSection)) + .entrySet().stream() + .map(entryBySection -> { + Section section = entryBySection.getKey(); + List activityDtos = mapAndSortActivities(entryBySection.getValue()); + return SectionItemDto.builder() + .id(section.getId()) + .section(section.getName()) + .activities(activityDtos) + .build(); + }) + // 2. 섹션 ID 오름차순 정렬 + .sorted(Comparator.comparing(SectionItemDto::getId)) + .collect(Collectors.toList()); + } + + private static List mapAndSortActivities(List activitiesForSection) { + return activitiesForSection.stream() + // 3. 활동 날짜 오름차순 정렬 + .sorted(Comparator.comparing(Activity::getActivityDate)) + .map(ActivityItemDto::of) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/dto/SectionItemDto.java b/src/main/java/dmu/dasom/api/domain/activity/dto/SectionItemDto.java new file mode 100644 index 0000000..80bd73f --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/dto/SectionItemDto.java @@ -0,0 +1,31 @@ +package dmu.dasom.api.domain.activity.dto; + +import dmu.dasom.api.domain.activity.entity.Section; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@Schema(name = "SectionItemDto", description = "섹션별 활동 목록") +public class SectionItemDto { + + @Schema(description = "활동 섹션 고유 ID", example = "1") + private final Long id; + + @Schema(description = "활동 섹션", example = "교내 경진대회") + private final String section; + + @Schema(description = "활동 목록") + private final List activities; + + public static SectionItemDto of(Section section, List activities) { + return SectionItemDto.builder() + .id(section.getId()) + .section(section.getName()) + .activities(activities) + .build(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/entity/Activity.java b/src/main/java/dmu/dasom/api/domain/activity/entity/Activity.java new file mode 100644 index 0000000..0d9db06 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/entity/Activity.java @@ -0,0 +1,47 @@ +package dmu.dasom.api.domain.activity.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDate; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "activities") +public class Activity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "section_id", nullable = false) + private Section section; + + @Column(nullable = false) + private LocalDate activityDate; + + @Column(length = 50, nullable = false) + private String title; + + @Column(length = 50) + private String award; + + public static Activity create(Section section, LocalDate activityDate, String title, String award) { + return Activity.builder() + .section(section) + .activityDate(activityDate) + .title(title) + .award(award) + .build(); + } + + public void update(Section section, LocalDate activityDate, String title, String award) { + this.section = section; + this.activityDate = activityDate; + this.title = title; + this.award = award; + } +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/entity/Section.java b/src/main/java/dmu/dasom/api/domain/activity/entity/Section.java new file mode 100644 index 0000000..e0cf84c --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/entity/Section.java @@ -0,0 +1,33 @@ +package dmu.dasom.api.domain.activity.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "sections") +public class Section { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 50, nullable = false, unique = true) + private String name; + + @OneToMany(mappedBy = "section", cascade = CascadeType.ALL, orphanRemoval = true) + private List activities = new ArrayList<>(); + + public static Section create(String name) { + return Section.builder().name(name).build(); + } + + public void update(String name) { + this.name = name; + } +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/repository/ActivityRepository.java b/src/main/java/dmu/dasom/api/domain/activity/repository/ActivityRepository.java new file mode 100644 index 0000000..7786245 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/repository/ActivityRepository.java @@ -0,0 +1,8 @@ +package dmu.dasom.api.domain.activity.repository; + +import dmu.dasom.api.domain.activity.entity.Activity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/src/main/java/dmu/dasom/api/domain/activity/repository/SectionRepository.java b/src/main/java/dmu/dasom/api/domain/activity/repository/SectionRepository.java new file mode 100644 index 0000000..c47a4f1 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/repository/SectionRepository.java @@ -0,0 +1,12 @@ +package dmu.dasom.api.domain.activity.repository; + +import dmu.dasom.api.domain.activity.entity.Section; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SectionRepository extends JpaRepository { + Optional
findByName(String name); +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/service/ActivityService.java b/src/main/java/dmu/dasom/api/domain/activity/service/ActivityService.java new file mode 100644 index 0000000..9b06a6c --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/service/ActivityService.java @@ -0,0 +1,13 @@ +package dmu.dasom.api.domain.activity.service; + +import dmu.dasom.api.domain.activity.dto.ActivityRequestDto; +import dmu.dasom.api.domain.activity.dto.ActivityResponseDto; + +import java.util.List; + +public interface ActivityService { + List getActivities(); + void createActivity(ActivityRequestDto requestDto); + void updateActivity(Long activityId, ActivityRequestDto requestDto); + void deleteActivity(Long activityId); +} diff --git a/src/main/java/dmu/dasom/api/domain/activity/service/ActivityServiceImpl.java b/src/main/java/dmu/dasom/api/domain/activity/service/ActivityServiceImpl.java new file mode 100644 index 0000000..821765e --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/activity/service/ActivityServiceImpl.java @@ -0,0 +1,73 @@ +package dmu.dasom.api.domain.activity.service; + +import dmu.dasom.api.domain.activity.dto.ActivityRequestDto; +import dmu.dasom.api.domain.activity.dto.ActivityResponseDto; +import dmu.dasom.api.domain.activity.entity.Activity; +import dmu.dasom.api.domain.activity.entity.Section; +import dmu.dasom.api.domain.activity.repository.ActivityRepository; +import dmu.dasom.api.domain.activity.repository.SectionRepository; +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class ActivityServiceImpl implements ActivityService { + + private final ActivityRepository activityRepository; + private final SectionRepository sectionRepository; + + @Override + @Transactional(readOnly = true) + public List getActivities() { + return ActivityResponseDto.of(activityRepository.findAll()); + } + + @Override + public void createActivity(ActivityRequestDto requestDto) { + // DTO에서 받은 String(섹션 이름)으로 Section 엔티티를 찾거나 생성 + Section section = findOrCreateSection(requestDto.getSection()); + + activityRepository.save(Activity.create( + section, + requestDto.getActivityDate(), + requestDto.getTitle(), + requestDto.getAward() + )); + } + + @Override + public void updateActivity(Long activityId, ActivityRequestDto requestDto) { + Activity activity = activityRepository.findById(activityId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + // DTO에서 받은 String(섹션 이름)으로 Section 엔티티를 찾거나 생성 + Section section = findOrCreateSection(requestDto.getSection()); + + activity.update( + section, + requestDto.getActivityDate(), + requestDto.getTitle(), + requestDto.getAward() + ); + } + + @Override + public void deleteActivity(Long activityId) { + if (!activityRepository.existsById(activityId)) { + throw new CustomException(ErrorCode.NOT_FOUND); + } + activityRepository.deleteById(activityId); + } + + // 파라미터 타입을 Section에서 String으로 변경 + private Section findOrCreateSection(String sectionName) { + return sectionRepository.findByName(sectionName) + .orElseGet(() -> sectionRepository.save(Section.create(sectionName))); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java b/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java index a0a9226..97d1320 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java @@ -25,4 +25,5 @@ public interface ApplicantRepository extends JpaRepository { Optional findByStudentNoAndContactEndsWith(@Param("studentNo") String studentNo, @Param("contactLastDigits") String contactLastDigits); + Optional findByStudentNoAndEmail(String studentNo, String email); } diff --git a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java index 62b1b02..8dbfcc4 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java @@ -4,7 +4,7 @@ import dmu.dasom.api.domain.applicant.dto.ApplicantDetailsResponseDto; import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto; -import dmu.dasom.api.domain.email.enums.MailType; +import dmu.dasom.api.domain.google.enums.MailType; import dmu.dasom.api.domain.recruit.dto.ResultCheckRequestDto; import dmu.dasom.api.domain.recruit.dto.ResultCheckResponseDto; import dmu.dasom.api.global.dto.PageResponse; diff --git a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java index b0e274b..94433cf 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java @@ -9,8 +9,8 @@ import dmu.dasom.api.domain.applicant.repository.ApplicantRepository; import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; -import dmu.dasom.api.domain.email.enums.MailType; -import dmu.dasom.api.domain.email.service.EmailService; +import dmu.dasom.api.domain.google.enums.MailType; +import dmu.dasom.api.domain.google.service.EmailService; import dmu.dasom.api.domain.google.service.GoogleApiService; import dmu.dasom.api.domain.recruit.dto.ResultCheckRequestDto; import dmu.dasom.api.domain.recruit.dto.ResultCheckResponseDto; @@ -132,11 +132,7 @@ public void sendEmailsToApplicants(MailType mailType) { } for (Applicant applicant : applicants) { - try { - emailService.sendEmail(applicant.getEmail(), applicant.getName(), mailType); - } catch (MessagingException e) { - System.err.println("Failed to send email to: " + applicant.getEmail()); - } + emailService.sendEmail(applicant.getEmail(), applicant.getName(), mailType); } } diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/CustomErrorController.java b/src/main/java/dmu/dasom/api/domain/common/exception/CustomErrorController.java index dee586f..62d73af 100644 --- a/src/main/java/dmu/dasom/api/domain/common/exception/CustomErrorController.java +++ b/src/main/java/dmu/dasom/api/domain/common/exception/CustomErrorController.java @@ -1,5 +1,6 @@ package dmu.dasom.api.domain.common.exception; +import io.swagger.v3.oas.annotations.Hidden; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.http.HttpServletRequest; import org.springframework.boot.web.servlet.error.ErrorController; @@ -8,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Hidden @RestController public class CustomErrorController implements ErrorController { diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java index 8910414..16759dd 100644 --- a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java +++ b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java @@ -34,9 +34,14 @@ public enum ErrorCode { SLOT_FULL(400, "C025", "해당 슬롯이 가득 찼습니다."), RESERVATION_NOT_FOUND(400, "C026", "예약을 찾을 수 없습니다."), SLOT_NOT_ACTIVE(400, "C027", "해당 슬롯이 비활성화 되었습니다."), - FILE_ENCODE_FAIL(400, "C028", "파일 인코딩에 실패하였습니다."), + FILE_UPLOAD_FAIL(400, "C028", "파일 업로드에 실패하였습니다."), RECRUITMENT_NOT_ACTIVE(400, "C029", "모집 기간이 아닙니다."), - NOT_FOUND_PARTICIPANT(400, "C030", "참가자를 찾을 수 없습니다.") + NOT_FOUND_PARTICIPANT(400, "C030", "참가자를 찾을 수 없습니다."), + EXECUTIVE_NOT_FOUND(400, "C031", "임원진을 찾을 수 없습니다."), + GENERATION_NOT_FOUND(400, "C032", "저장된 기수를 찾을 수 없습니다."), + INVALID_GENERATION_FORMAT(400, "C033", "유효하지 않은 기수 형식입니다. (예: '1기')"), + VERIFICATION_CODE_NOT_VALID(400, "C034", "인증 코드가 유효하지 않습니다."), + SLOT_UNAVAILABLE(400, "C035", "해당 슬롯을 예약할 수 없습니다.") ; private final int status; diff --git a/src/main/java/dmu/dasom/api/domain/email/enums/MailType.java b/src/main/java/dmu/dasom/api/domain/email/enums/MailType.java deleted file mode 100644 index 8a1940c..0000000 --- a/src/main/java/dmu/dasom/api/domain/email/enums/MailType.java +++ /dev/null @@ -1,6 +0,0 @@ -package dmu.dasom.api.domain.email.enums; - -public enum MailType { - DOCUMENT_RESULT, // 서류 합격 - FINAL_RESULT // 최종 합격 -} diff --git a/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java b/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java deleted file mode 100644 index 2dc9acf..0000000 --- a/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java +++ /dev/null @@ -1,80 +0,0 @@ -package dmu.dasom.api.domain.email.service; - -import dmu.dasom.api.domain.common.exception.CustomException; -import dmu.dasom.api.domain.common.exception.ErrorCode; -import dmu.dasom.api.domain.email.enums.MailType; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Service; -import org.thymeleaf.TemplateEngine; -import org.thymeleaf.context.Context; - -@RequiredArgsConstructor -@Service -public class EmailService { - - private final TemplateEngine templateEngine; - private final JavaMailSender javaMailSender; - - @Value("${spring.mail.username}") - private String from; - - public void sendEmail(String to, String name, MailType mailType) throws MessagingException { - if (mailType == null){ - throw new CustomException(ErrorCode.MAIL_TYPE_NOT_VALID); - } - - // 메일 제목 및 템플릿 설정 - String subject; - String emailContent; - String buttonUrl = "https://dmu-dasom.or.kr/recruit/result"; - String buttonText; - - switch (mailType) { - case DOCUMENT_RESULT -> { - subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 서류 결과 안내"; - emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,
" + - "내부 서류 평가 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.
" + - "서류 전형 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다."; - buttonText = "서류 결과 확인하기"; - } - case FINAL_RESULT -> { - subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 최종 면접 결과 안내"; - emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,
" + - "최종 면접 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.
" + - "최종 면접 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다."; - buttonText = "최종 결과 확인하기"; - } - default -> throw new IllegalStateException("Unexpected value: " + mailType); - } - - // HTML 템플릿에 전달할 데이터 설정 - Context context = new Context(); - context.setVariable("name", name); // 지원자 이름 전달 - context.setVariable("emailContent", emailContent); // 이메일 내용 전달 - context.setVariable("buttonUrl", buttonUrl); // 버튼 링크 전달 - context.setVariable("buttonText", buttonText); - - // HTML 템플릿 처리 - String htmlBody = templateEngine.process("email-template", context); - - // 이메일 생성 및 전송 - MimeMessage message = javaMailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); - - helper.setTo(to); - helper.setSubject(subject); - helper.setText(htmlBody, true); - helper.setFrom((from != null && !from.isEmpty()) ? from : "dasomdmu@gmail.com"); - - // Content-Type을 명시적으로 설정 - message.setContent(htmlBody, "text/html; charset=utf-8"); - - javaMailSender.send(message); - } - -} diff --git a/src/main/java/dmu/dasom/api/domain/executive/controller/ExecutiveController.java b/src/main/java/dmu/dasom/api/domain/executive/controller/ExecutiveController.java new file mode 100644 index 0000000..ec62b72 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/controller/ExecutiveController.java @@ -0,0 +1,60 @@ +package dmu.dasom.api.domain.executive.controller; + +import dmu.dasom.api.domain.executive.dto.*; +import dmu.dasom.api.domain.executive.service.ExecutiveServiceImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "EXECUTIVE API", description = "임원진 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/executives") +public class ExecutiveController { + + private final ExecutiveServiceImpl executiveService; + + @Operation(summary = "임원진 조회") + @GetMapping("/{id}") + public ResponseEntity getExecutiveById(@PathVariable @Min(1) Long id) { + return ResponseEntity.ok(executiveService.getExecutiveById(id)); + } + + @Operation(summary = "임원진 전체 조회") + @GetMapping + public ResponseEntity> getAllExecutives() { + return ResponseEntity.ok(executiveService.getAllExecutives()); + } + + @Operation(summary = "임원진 생성") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping + public ResponseEntity createExecutive(@Valid @RequestBody ExecutiveRequestDto requestDto) { + return ResponseEntity.status(201).body(executiveService.createExecutive(requestDto)); + } + + @Operation(summary = "임원진 삭제") + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{id}") + // Void 사용 이유? + // DELETE 요청 같이 성공/실패만 확인하면 되는 경우 사용 + public ResponseEntity deleteExecutive(@PathVariable @Min(1) Long id) { + executiveService.deleteExective(id); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "임원진 수정") + @PreAuthorize("hasRole('ADMIN')") + @PutMapping("/{id}") + public ResponseEntity updateExecutive(@PathVariable @Min(1) Long id, + @Valid @RequestBody ExecutiveUpdateRequestDto requestDto) { + return ResponseEntity.ok(executiveService.updateExecutive(id, requestDto)); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveCreationResponseDto.java b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveCreationResponseDto.java new file mode 100644 index 0000000..72affb2 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveCreationResponseDto.java @@ -0,0 +1,19 @@ +package dmu.dasom.api.domain.executive.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "ExecutiveCreationResponseDto", description = "회장단 생성 응답 DTO") +public class ExecutiveCreationResponseDto { + + @NotNull + @Schema(description = "회장단 ID", example = "1") + private Long id; + +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveListResponseDto.java b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveListResponseDto.java new file mode 100644 index 0000000..c50b4dd --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveListResponseDto.java @@ -0,0 +1,37 @@ +package dmu.dasom.api.domain.executive.dto; + +import dmu.dasom.api.domain.executive.enums.Team; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(name = "ExecutiveListResponseDto", description = "임원진 목록 응답 Dto") +public class ExecutiveListResponseDto { + + @Schema(description = "임원진 ID", example = "1") + private Long id; + + @Schema(description = "임원진 이름", example = "김다솜") + private String name; + + @Schema(description = "임원진 직책", example = "회장") + private String position; + + @Schema(description = "수정할 임원진 역할", example = "동아리 운영 총괄", nullable = true) + private String role; + + @Schema(description = "임원진 깃허브 이름", example = "DASOM") + private String github_username; + + @Schema(description = "소속 팀", example = "president, tech, academic, pr, management") + private Team team; + + private Integer sortOrder; + +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveRequestDto.java b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveRequestDto.java new file mode 100644 index 0000000..8d87f00 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveRequestDto.java @@ -0,0 +1,51 @@ +package dmu.dasom.api.domain.executive.dto; + +import dmu.dasom.api.domain.executive.entity.ExecutiveEntity; +import dmu.dasom.api.domain.executive.enums.Team; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "ExecutiveRequestDto", description = "임원진 요청 DTO") +public class ExecutiveRequestDto { + + private Long id; + + @NotBlank(message = "임원진 이름은 필수 입력 사항입니다.") + @Size(max = 50, message = "임원진 이름은 최대 50자입니다.") + @Schema(description = "임원진 이름", example = "김다솜") + private String name; + + @NotBlank(message = "임원진 직책은 필수 입력 사항입니다.") + @Schema(description = "임원진 직책", example = "회장") + private String position; + + @NotBlank(message = "임원진 역할은 필수 입력 사항입니다.") + @Schema(description = "임원진 역할", example = "동아리 운영 총괄") + private String role; + + @Schema(description = "임원진 깃허브 이름", example = "DASOM") + private String github_username; + + @Schema(description = "소속 팀", example = "president, tech, academic, pr, management") + private Team team; + + private Integer sortOrder; + + public ExecutiveEntity toEntity() { + return ExecutiveEntity.builder() + .name(this.name) + .position(this.position) + .role(this.role) + .githubUsername(this.github_username) + .team(this.team) + .sortOrder(this.sortOrder) + .build(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveResponseDto.java b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveResponseDto.java new file mode 100644 index 0000000..0e96fb6 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveResponseDto.java @@ -0,0 +1,36 @@ +package dmu.dasom.api.domain.executive.dto; + +import dmu.dasom.api.domain.executive.enums.Team; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(name = "ExecutiveResponseDto", description = "임원진 응답 DTO") +public class ExecutiveResponseDto { + + @Schema(description = "임원진 ID", example = "1") + private Long id; + + @Schema(description = "임원진 이름", example = "김다솜") + private String name; + + @Schema(description = "임원진 직책", example = "회장") + private String position; + + @Schema(description = "수정할 임원진 역할", example = "동아리 운영 총괄") + private String role; + + @Schema(description = "임원진 깃허브 이름", example = "DASOM") + private String github_username; + + @Schema(description = "소속 팀", example = "president, tech, academic, pr, management") + private Team team; + + private Integer sortOrder; +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveUpdateRequestDto.java b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveUpdateRequestDto.java new file mode 100644 index 0000000..286dcdb --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/dto/ExecutiveUpdateRequestDto.java @@ -0,0 +1,34 @@ +package dmu.dasom.api.domain.executive.dto; + +import dmu.dasom.api.domain.executive.enums.Team; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "ExecutiveUpdateRequestDto", description = "임원진 멤버 수정 요청 DTO") +public class ExecutiveUpdateRequestDto { + + @Size(max = 50, message = "임원진 이름은 최대 50자입니다.") + @Schema(description = "수정할 임원진 이름", example = "김다솜", nullable = true) + private String name; + + @Schema(description = "수정할 임원진 직책", example = "회장", nullable = true) + private String position; + + @Schema(description = "수정할 임원진 역할", example = "동아리 운영 총괄", nullable = true) + private String role; + + @Schema(description = "임원진 깃허브 이름", example = "DASOM") + private String github_username; + + @Schema(description = "소속 팀", example = "president, tech, academic, pr, management") + private Team team; + + private Integer sortOrder; + +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/entity/ExecutiveEntity.java b/src/main/java/dmu/dasom/api/domain/executive/entity/ExecutiveEntity.java new file mode 100644 index 0000000..45b1a22 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/entity/ExecutiveEntity.java @@ -0,0 +1,84 @@ +package dmu.dasom.api.domain.executive.entity; + +import dmu.dasom.api.domain.common.BaseEntity; // BaseEntity 상속 받음 +import dmu.dasom.api.domain.executive.dto.ExecutiveListResponseDto; +import dmu.dasom.api.domain.executive.dto.ExecutiveResponseDto; +import dmu.dasom.api.domain.executive.dto.ExecutiveUpdateRequestDto; +import dmu.dasom.api.domain.executive.enums.Team; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; // JPA 어노테이션 패키지 ( DB 매핑 관련 ) +import lombok.*; // 보일러플레이트 코드 자동 생성 라이브러리 +import org.checkerframework.checker.units.qual.C; + +@Getter +@Entity +@Table(name = "executive") +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "조직도 엔티티") +public class ExecutiveEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 이름 + @Column(nullable = false, length = 50) + private String name; + + // 직책 + @Column(nullable=false, length = 50) + private String position; + + // 역할 + @Column(nullable = false, length = 50) + private String role; + + // 깃허브 이름 + @Column(name = "github_username") + private String githubUsername; + + // 소속팀 (president/tech/academic/pr/management) + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Team team; + + @Column(name = "sort_order", nullable = false) + @Builder.Default + private Integer sortOrder = 9999; + + // 엔티티 업데이트 메소드 + public void update(ExecutiveUpdateRequestDto dto) { + if (dto.getName() != null) this.name = dto.getName(); + if (dto.getPosition() != null) this.position = dto.getPosition(); + if (dto.getRole() != null) this.role = dto.getRole(); + if (dto.getGithub_username() != null) this.githubUsername = dto.getGithub_username(); + if (dto.getTeam() != null) this.team = dto.getTeam(); + if (dto.getSortOrder() != null) this.sortOrder = dto.getSortOrder(); + } + + // 엔티티 -> DTO 변환 책임 + public ExecutiveResponseDto toResponseDto() { + return ExecutiveResponseDto.builder() + .id(this.id) + .name(this.name) + .position(this.position) + .role(this.role) + .github_username(this.githubUsername) + .team(this.team) + .build(); + } + + // 임원진 전체 목록 조회 + public ExecutiveListResponseDto toListResponseDto() { + return ExecutiveListResponseDto.builder() + .id(this.id) + .name(this.name) + .position(this.position) + .role(this.role) + .github_username(this.githubUsername) + .team(this.team) + .build(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/enums/Role.java b/src/main/java/dmu/dasom/api/domain/executive/enums/Role.java new file mode 100644 index 0000000..e736734 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/enums/Role.java @@ -0,0 +1,24 @@ +package dmu.dasom.api.domain.executive.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +// enum + 문자열 매핑 +@AllArgsConstructor +@Getter +public enum Role { + + ROLE_PRESIDENT("PRESIDENT"), // 회장 + ROLE_VICE_PRESIDENT("VICE_PRESIDENT"), // 부회장 + ROLE_TECHNICAL_MANAGER("TECHNICAL_MANAGER"), // 기술팀장 + ROLE_ACADEMIC_MANAGER("ACADEMIC_MANAGER"), // 학술팀장 + ROLE_ACADEMIC_SENIOR("ACADEMIC_SENIOR"), // 학술차장 + ROLE_PUBLIC_RELATIONS_MANAGER("PUBLIC_RELATIONS_MANAGER"), // 홍보팀장 + ROLE_CLERK("CLERK"), // 서기 + ROLE_MANAGER("MANAGER"), // 총무 + ROLE_SUB_MANAGER("SUB_MANAGER"), // 부총무 + ; + + private String name; + +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/enums/Team.java b/src/main/java/dmu/dasom/api/domain/executive/enums/Team.java new file mode 100644 index 0000000..2921b1f --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/enums/Team.java @@ -0,0 +1,17 @@ +package dmu.dasom.api.domain.executive.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum Team { + PRESIDENT("president"), + TECH("tech"), + ACADEMIC("academic"), + MANAGEMENT("management"), + PR("pr") + ; + + private String name; +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/repository/ExecutiveRepository.java b/src/main/java/dmu/dasom/api/domain/executive/repository/ExecutiveRepository.java new file mode 100644 index 0000000..461f3fa --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/repository/ExecutiveRepository.java @@ -0,0 +1,12 @@ +package dmu.dasom.api.domain.executive.repository; + +import dmu.dasom.api.domain.executive.entity.ExecutiveEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ExecutiveRepository extends JpaRepository { + + // 회장단 레포지토리 + +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/service/ExecutiveService.java b/src/main/java/dmu/dasom/api/domain/executive/service/ExecutiveService.java new file mode 100644 index 0000000..8c3d483 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/service/ExecutiveService.java @@ -0,0 +1,18 @@ +package dmu.dasom.api.domain.executive.service; + +import dmu.dasom.api.domain.executive.dto.*; + +import java.util.List; + +public interface ExecutiveService { + + ExecutiveResponseDto getExecutiveById(Long id); + + List getAllExecutives(); + + ExecutiveCreationResponseDto createExecutive(ExecutiveRequestDto requestDto); + + void deleteExective(Long id); + + ExecutiveResponseDto updateExecutive(Long id, ExecutiveUpdateRequestDto requestDto); +} diff --git a/src/main/java/dmu/dasom/api/domain/executive/service/ExecutiveServiceImpl.java b/src/main/java/dmu/dasom/api/domain/executive/service/ExecutiveServiceImpl.java new file mode 100644 index 0000000..42563d6 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/executive/service/ExecutiveServiceImpl.java @@ -0,0 +1,72 @@ +package dmu.dasom.api.domain.executive.service; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.executive.dto.*; +import dmu.dasom.api.domain.executive.entity.ExecutiveEntity; +import dmu.dasom.api.domain.executive.repository.ExecutiveRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // 기본값 : 읽기 전용 +public class ExecutiveServiceImpl implements ExecutiveService { + + private final ExecutiveRepository executiveRepository; + + // 임원진 멤버 조회 + // 이름, 직책, 깃허브 주소 검색 + public ExecutiveResponseDto getExecutiveById(Long id) { + ExecutiveEntity executive = executiveRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.EXECUTIVE_NOT_FOUND)); + + return executive.toResponseDto(); + } + + // 임원진 전체 조회 + // 이름, 직책, 깃허브 주소 출력 + public List getAllExecutives() { + // 전체 조회 시 정렬 + // 기준 sortOrder -> 직책 -> 이름 + Sort sort = Sort.by(Sort.Direction.ASC, "sortOrder") + .and(Sort.by(Sort.Direction.ASC, "position")) + .and(Sort.by(Sort.Direction.DESC, "name")); + + List executives = executiveRepository.findAll(sort); + + return executives.stream() + .map(executiveEntity -> executiveEntity.toListResponseDto()) + .toList(); + } + + // 임원진 멤버 생성 + public ExecutiveCreationResponseDto createExecutive(ExecutiveRequestDto requestDto) { + return new ExecutiveCreationResponseDto(executiveRepository.save(requestDto.toEntity()).getId()); + } + + // 임원진 멤버 삭제 + @Transactional + public void deleteExective(Long id) { + ExecutiveEntity executive = executiveRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.EXECUTIVE_NOT_FOUND)); + + executiveRepository.delete(executive); + } + + // 임원진 멤버 수정 + @Transactional + public ExecutiveResponseDto updateExecutive(Long id, ExecutiveUpdateRequestDto requestDto) { + ExecutiveEntity executive = executiveRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.EXECUTIVE_NOT_FOUND)); + + executive.update(requestDto); + + return executive.toResponseDto(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/google/controller/GoogleController.java b/src/main/java/dmu/dasom/api/domain/google/controller/GoogleController.java deleted file mode 100644 index 984ed66..0000000 --- a/src/main/java/dmu/dasom/api/domain/google/controller/GoogleController.java +++ /dev/null @@ -1,38 +0,0 @@ -package dmu.dasom.api.domain.google.controller; - -import dmu.dasom.api.domain.google.service.GoogleApiService; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; -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.bind.annotation.RestController; - -import java.util.Collections; -import java.util.List; - -@RestController -@RequestMapping("/google") -@RequiredArgsConstructor -public class GoogleController { - - private final GoogleApiService googleApiService; - @Value("${google.spreadsheet.id}") - private String spreadsheetId; - - private static final String RANGE = "A1"; - - @PostMapping("/write") - public ResponseEntity writeToSheet(@RequestParam String word){ - try{ - List> values = List.of(Collections.singletonList(word)); - - googleApiService.writeToSheet(spreadsheetId, RANGE, values); - return ResponseEntity.ok("Data written successfully to the spreadsheet" + word); - } catch (Exception e){ - e.printStackTrace(); - return ResponseEntity.internalServerError().body("Failed to write data to the spreadsheet" + e.getMessage()); - } - } -} diff --git a/src/main/java/dmu/dasom/api/domain/google/entity/EmailLog.java b/src/main/java/dmu/dasom/api/domain/google/entity/EmailLog.java new file mode 100644 index 0000000..6cb13b9 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/google/entity/EmailLog.java @@ -0,0 +1,40 @@ +package dmu.dasom.api.domain.google.entity; + +import dmu.dasom.api.domain.google.enums.MailSendStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class EmailLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String recipientEmail; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MailSendStatus status; + + private String errorMessage; + + @CreationTimestamp + private LocalDateTime sentAt; + + public static EmailLog of(String recipientEmail, MailSendStatus status, String errorMessage) { + return EmailLog.builder() + .recipientEmail(recipientEmail) + .status(status) + .errorMessage(errorMessage) + .build(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/google/enums/MailSendStatus.java b/src/main/java/dmu/dasom/api/domain/google/enums/MailSendStatus.java new file mode 100644 index 0000000..c52974d --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/google/enums/MailSendStatus.java @@ -0,0 +1,6 @@ +package dmu.dasom.api.domain.google.enums; + +public enum MailSendStatus { + SUCCESS, + FAILURE +} diff --git a/src/main/java/dmu/dasom/api/domain/google/enums/MailTemplate.java b/src/main/java/dmu/dasom/api/domain/google/enums/MailTemplate.java new file mode 100644 index 0000000..435c664 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/google/enums/MailTemplate.java @@ -0,0 +1,26 @@ +package dmu.dasom.api.domain.google.enums; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum MailTemplate { + DOCUMENT_RESULT(MailType.DOCUMENT_RESULT, "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 서류 결과 안내", "document-result-email"), + FINAL_RESULT(MailType.FINAL_RESULT, "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 최종 면접 결과 안내", "final-result-email"); + + private final MailType mailType; + private final String subject; + private final String templateName; + + public static MailTemplate getMailType(MailType mailType) { + return Arrays.stream(values()) + .filter(template -> template.getMailType() == mailType) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.MAIL_TYPE_NOT_VALID)); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/google/enums/MailType.java b/src/main/java/dmu/dasom/api/domain/google/enums/MailType.java new file mode 100644 index 0000000..3805dc4 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/google/enums/MailType.java @@ -0,0 +1,7 @@ +package dmu.dasom.api.domain.google.enums; + +public enum MailType { + DOCUMENT_RESULT, // 서류 합격 + FINAL_RESULT, // 최종 합격 + VERIFICATION // 이메일 인증 +} diff --git a/src/main/java/dmu/dasom/api/domain/google/repository/EmailLogRepository.java b/src/main/java/dmu/dasom/api/domain/google/repository/EmailLogRepository.java new file mode 100644 index 0000000..ab4d027 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/google/repository/EmailLogRepository.java @@ -0,0 +1,7 @@ +package dmu.dasom.api.domain.google.repository; + +import dmu.dasom.api.domain.google.entity.EmailLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EmailLogRepository extends JpaRepository { +} diff --git a/src/main/java/dmu/dasom/api/domain/google/service/EmailLogService.java b/src/main/java/dmu/dasom/api/domain/google/service/EmailLogService.java new file mode 100644 index 0000000..aa28313 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/google/service/EmailLogService.java @@ -0,0 +1,21 @@ +package dmu.dasom.api.domain.google.service; + +import dmu.dasom.api.domain.google.entity.EmailLog; +import dmu.dasom.api.domain.google.enums.MailSendStatus; +import dmu.dasom.api.domain.google.repository.EmailLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailLogService { + + private final EmailLogRepository emailLogRepository; + + @Async + public void logEmailSending(String recipientEmail, MailSendStatus status, String errorMessage) { + EmailLog emailLog = EmailLog.of(recipientEmail, status, errorMessage); + emailLogRepository.save(emailLog); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/google/service/EmailService.java b/src/main/java/dmu/dasom/api/domain/google/service/EmailService.java new file mode 100644 index 0000000..40d50a2 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/google/service/EmailService.java @@ -0,0 +1,108 @@ +package dmu.dasom.api.domain.google.service; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.google.enums.MailSendStatus; +import dmu.dasom.api.domain.google.enums.MailTemplate; +import dmu.dasom.api.domain.google.enums.MailType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Slf4j +@RequiredArgsConstructor +@Service +public class EmailService { + + private final TemplateEngine templateEngine; + private final JavaMailSender javaMailSender; + private final EmailLogService emailLogService; + + @Value("${spring.mail.username}") + private String from; + + @Async + public void sendEmail(String to, String name, MailType mailType) { + MailSendStatus mailSendStatus = MailSendStatus.SUCCESS; + String errorMessage = null; + try { + if (mailType == null) { + throw new CustomException(ErrorCode.MAIL_TYPE_NOT_VALID); + } + + // 메일 템플릿 조회 + MailTemplate mailTemplate = MailTemplate.getMailType(mailType); + String buttonUrl = "https://dmu-dasom.or.kr/recruit/result"; + + // HTML 템플릿에 전달할 데이터 설정 + Context context = new Context(); + context.setVariable("name", name); + context.setVariable("buttonUrl", buttonUrl); + + // HTML 템플릿 처리 + String htmlBody = templateEngine.process(mailTemplate.getTemplateName(), context); + + // 이메일 생성 및 전송 + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setTo(to); + helper.setSubject(mailTemplate.getSubject()); + helper.setText(htmlBody, true); + helper.setFrom((from != null && !from.isEmpty()) ? from : "dasomdmu@gmail.com"); + + message.setContent(htmlBody, "text/html; charset=utf-8"); + + javaMailSender.send(message); + log.info("Email sent successfully {}", to); + } catch (MessagingException e) { + log.error("Failed to send email to {}: {}", to, e.getMessage()); + mailSendStatus = MailSendStatus.FAILURE; + errorMessage = e.getMessage(); + } catch (CustomException e) { + log.error("Email sending error for {}: {}", to, e.getMessage()); + mailSendStatus = MailSendStatus.FAILURE; + errorMessage = e.getMessage(); + } + emailLogService.logEmailSending(to, mailSendStatus, errorMessage); + } + + /* + * 면접 예약 변경을 위한 인증코드 발송 + * - VerificationCodeManager에서 생성된 코드를 이메일로 전송 + * - verify-num-email.html 템플릿을 이용해 코드와 버튼 링크 포함 + */ + public void sendVerificationEmail(String to, String name, String code) throws MessagingException { + String subject = "DASOM 면접 시간 변경을 위한 이메일 인증 코드 안내"; + + // 인증 코드만 템플릿으로 전달 + String emailContent = "인증 코드: " + code + ""; + + Context context = new Context(); + context.setVariable("name", name); + context.setVariable("emailContent", emailContent); + context.setVariable("buttonUrl", "https://dmu-dasom.or.kr"); + context.setVariable("buttonText", "인증 완료"); + + String htmlBody = templateEngine.process("verify-num-email", context); + + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlBody, true); + helper.setFrom((from != null && !from.isEmpty()) ? from : "dasomdmu@gmail.com"); + + javaMailSender.send(message); + } + +} diff --git a/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java b/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java index 9217622..fff27b3 100644 --- a/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java +++ b/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java @@ -64,21 +64,6 @@ private Sheets getSheetsService() throws IOException, GeneralSecurityException { return sheetsService; } - public void writeToSheet(String spreadsheetId, String range, List> values) { - try { - Sheets service = getSheetsService(); - ValueRange body = new ValueRange().setValues(values); - service.spreadsheets() - .values() - .update(spreadsheetId, range, body) - .setValueInputOption("USER_ENTERED") - .execute(); - } catch (IOException | GeneralSecurityException e) { - log.error("시트에 데이터를 쓰는 데 실패했습니다."); - throw new CustomException(ErrorCode.WRITE_FAIL); - } - } - public void appendToSheet(List applicants) { processSheetsUpdate(applicants, true); } diff --git a/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java b/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java new file mode 100644 index 0000000..0612399 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java @@ -0,0 +1,79 @@ +package dmu.dasom.api.domain.interview.controller; + +import dmu.dasom.api.domain.applicant.entity.Applicant; +import dmu.dasom.api.domain.applicant.repository.ApplicantRepository; +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.google.service.EmailService; + +import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto; +import dmu.dasom.api.domain.interview.dto.VerificationCodeRequestDto; +import dmu.dasom.api.domain.interview.service.InterviewService; +import dmu.dasom.api.global.util.VerificationCodeManager; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.mail.MessagingException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import dmu.dasom.api.domain.interview.dto.InterviewReservationApplicantResponseDto; + +@Tag(name = "Interview", description = "면접 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/interview") +public class InterviewController { + + private final InterviewService interviewService; + private final ApplicantRepository applicantRepository; + private final VerificationCodeManager verificationCodeManager; + private final EmailService emailService; // 이메일 발송 서비스 (Google 기반) + + /* + * 면접 예약 수정을 위한 인증 코드 발송 + * - 지원자의 학번을 입력받아 해당 지원자를 조회 + * - VerificationCodeManager를 통해 인증 코드 생성 및 Redis 저장 + * - EmailService를 이용해 지원자 이메일로 인증 코드 발송 + */ + @Operation(summary = "면접 예약 수정을 위한 인증 코드 발송", description = "지원자의 학번을 받아 이메일로 인증 코드를 발송합니다.") + @PostMapping("/send-verification") + public ResponseEntity sendVerificationCode(@Valid @RequestBody VerificationCodeRequestDto request) throws MessagingException { + // 학번으로 지원자 조회 (없으면 예외 발생) + Applicant applicant = applicantRepository.findByStudentNo(request.getStudentNo()) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); + + // 인증 코드 생성 후 Redis에 저장 + String code = verificationCodeManager.generateAndStoreCode(applicant.getStudentNo()); + + // 이메일 발송 (받는 사람 이메일, 이름, 코드 전달) + emailService.sendVerificationEmail(applicant.getEmail(), applicant.getName(), code); + + return ResponseEntity.ok().build(); + } + + /* + * 면접 예약 수정 + * - 사용자가 받은 인증 코드를 검증한 후 + * - InterviewService를 통해 예약 날짜/시간 수정 처리 + */ + @Operation(summary = "면접 예약 수정", description = "이메일로 발송된 인증 코드를 통해 인증 후, 면접 날짜 및 시간을 수정합니다.") + @PutMapping("/reservation/modify") + public ResponseEntity modifyInterviewReservation(@Valid @RequestBody InterviewReservationModifyRequestDto request) { + interviewService.modifyInterviewReservation(request); + return ResponseEntity.ok().build(); + } + + /* + * 모든 면접 지원자 조회 + * - InterviewService를 통해 모든 지원자 + 예약 정보 반환 + */ + @Operation(summary = "모든 면접 지원자 목록 조회", description = "모든 면접 지원자의 상세 정보와 예약 정보를 조회합니다.") + @GetMapping("/applicants") + public ResponseEntity> getAllInterviewApplicants() { + List applicants = interviewService.getAllInterviewApplicants(); + return ResponseEntity.ok(applicants); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java new file mode 100644 index 0000000..5658fcb --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java @@ -0,0 +1,37 @@ +package dmu.dasom.api.domain.interview.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(name = "InterviewReservationModifyRequestDto", description = "면접 예약 수정 요청 DTO") +public class InterviewReservationModifyRequestDto { + + @NotNull(message = "학번은 필수 값입니다.") + @Pattern(regexp = "^[0-9]{8}$", message = "학번은 8자리 숫자로 구성되어야 합니다.") + @Size(min = 8, max = 8) + @Schema(description = "지원자 학번", example = "20250001") + private String studentNo; + + @NotNull(message = "이메일은 필수 값입니다.") + @Email(message = "유효한 이메일 주소를 입력해주세요.") + @Size(max = 64) + @Schema(description = "지원자 이메일", example = "test@example.com") + private String email; + + @NotNull(message = "새로운 슬롯 ID는 필수 값입니다.") + @Schema(description = "새롭게 예약할 면접 슬롯의 ID", example = "2") + private Long newSlotId; + + @NotNull(message = "인증 코드는 필수 값입니다.") + @Schema(description = "이메일로 발송된 6자리 인증 코드", example = "123456") + private String verificationCode; +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/VerificationCodeRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/VerificationCodeRequestDto.java new file mode 100644 index 0000000..a43d463 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/VerificationCodeRequestDto.java @@ -0,0 +1,17 @@ +package dmu.dasom.api.domain.interview.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class VerificationCodeRequestDto { + + @NotNull(message = "학번은 필수 값입니다.") + @Pattern(regexp = "^[0-9]{8}$", message = "학번은 8자리 숫자로 구성되어야 합니다.") + @Size(min = 8, max = 8) + @Schema(description = "지원자 학번", example = "20250001") + private String studentNo; +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java index 682c47f..33ceb73 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java +++ b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java @@ -36,4 +36,8 @@ public class InterviewReservation { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; // 생성 + + public void setSlot(InterviewSlot slot) { + this.slot = slot; + } } diff --git a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewReservationRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewReservationRepository.java index 4b7e402..a221c15 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewReservationRepository.java +++ b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewReservationRepository.java @@ -1,10 +1,15 @@ package dmu.dasom.api.domain.interview.repository; import dmu.dasom.api.domain.interview.entity.InterviewReservation; +import dmu.dasom.api.domain.applicant.entity.Applicant; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface InterviewReservationRepository extends JpaRepository { boolean existsByReservationCode(String reservationCode); + Optional findByReservationCode(String reservationCode); + Optional findByApplicant(Applicant applicant); } diff --git a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java index 67d005c..b13c265 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java +++ b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java @@ -4,6 +4,7 @@ import dmu.dasom.api.domain.interview.enums.InterviewStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; // ★ import 추가 import org.springframework.stereotype.Repository; import java.util.Collection; @@ -12,13 +13,12 @@ @Repository public interface InterviewSlotRepository extends JpaRepository { // 현재 인원이 최대 인원보다 작은 슬롯 조회 - // 현재 예약된 인원이 최대 지원자 수보다 적은 슬롯 조회 @Query("SELECT s FROM InterviewSlot s WHERE s.currentCandidates < s.maxCandidates") Collection findAllByCurrentCandidatesLessThanMaxCandidates(); // 상태에 따른 슬롯 조회 @Query("SELECT s FROM InterviewSlot s WHERE s.interviewStatus = :status AND s.currentCandidates < s.maxCandidates") - List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(InterviewStatus interviewStatus); + List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(@Param("status") InterviewStatus interviewStatus); // 슬롯이 하나라도 존재하는지 확인 @Query("SELECT COUNT(s) > 0 FROM InterviewSlot s") diff --git a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java index f58f8ce..e332477 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java @@ -2,6 +2,7 @@ import dmu.dasom.api.domain.interview.dto.InterviewReservationApplicantResponseDto; import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto; import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; import java.time.LocalDate; @@ -27,4 +28,7 @@ public interface InterviewService { List getAllInterviewApplicants(); + // 면접 예약 수정 + void modifyInterviewReservation(InterviewReservationModifyRequestDto request); + } diff --git a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java index 6a94895..67d40cd 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java @@ -6,6 +6,7 @@ import dmu.dasom.api.domain.common.exception.ErrorCode; import dmu.dasom.api.domain.interview.dto.InterviewReservationApplicantResponseDto; import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto; import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; import dmu.dasom.api.domain.interview.entity.InterviewReservation; import dmu.dasom.api.domain.interview.entity.InterviewSlot; @@ -13,6 +14,7 @@ import dmu.dasom.api.domain.interview.repository.InterviewReservationRepository; import dmu.dasom.api.domain.interview.repository.InterviewSlotRepository; import dmu.dasom.api.domain.recruit.service.RecruitServiceImpl; +import dmu.dasom.api.global.util.VerificationCodeManager; import jakarta.persistence.EntityListeners; import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -35,6 +37,7 @@ public class InterviewServiceImpl implements InterviewService{ private final InterviewReservationRepository interviewReservationRepository; private final ApplicantRepository applicantRepository; private final RecruitServiceImpl recruitService; + private final VerificationCodeManager verificationCodeManager; @Override @Transactional @@ -172,4 +175,47 @@ public List getAllInterviewApplicants( .collect(Collectors.toList()); } + @Override + @Transactional + public void modifyInterviewReservation(InterviewReservationModifyRequestDto request) { + // 0. 인증 코드 검증 + verificationCodeManager.verifyCode(request.getStudentNo(), request.getVerificationCode()); + + // 1. 지원자 학번과 이메일로 지원자 조회 및 검증 + Applicant applicant = applicantRepository.findByStudentNoAndEmail(request.getStudentNo(), request.getEmail()) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); + + // 2. 해당 지원자의 기존 면접 예약 조회 + InterviewReservation existingReservation = interviewReservationRepository.findByApplicant(applicant) + .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); + + // 3. 새로운 면접 슬롯 조회 및 검증 + InterviewSlot newSlot = interviewSlotRepository.findById(request.getNewSlotId()) + .orElseThrow(() -> new CustomException(ErrorCode.SLOT_NOT_FOUND)); + + // 4. 새로운 슬롯이 현재 예약된 슬롯과 동일한지 확인 (불필요한 업데이트 방지) + if (existingReservation.getSlot().getId().equals(newSlot.getId())) { + return; // 동일한 슬롯으로 변경 요청 시 아무것도 하지 않음 + } + + // 5. 새로운 슬롯의 가용성 확인 + if (newSlot.getCurrentCandidates() >= newSlot.getMaxCandidates() || newSlot.getInterviewStatus() != InterviewStatus.ACTIVE) { + throw new CustomException(ErrorCode.SLOT_UNAVAILABLE); + } + + // 6. 기존 슬롯의 예약 인원 감소 + InterviewSlot oldSlot = existingReservation.getSlot(); + oldSlot.decrementCurrentCandidates(); + interviewSlotRepository.save(oldSlot); // 변경된 oldSlot 저장 + + // 7. 예약 정보 업데이트 (새로운 슬롯으로 변경) + existingReservation.setSlot(newSlot); + + // 8. 새로운 슬롯의 예약 인원 증가 + newSlot.incrementCurrentCandidates(); + interviewSlotRepository.save(newSlot); // 변경된 newSlot 저장 + + // 9. 업데이트된 예약 정보 저장 + interviewReservationRepository.save(existingReservation); + } } diff --git a/src/main/java/dmu/dasom/api/domain/member/controller/MemberController.java b/src/main/java/dmu/dasom/api/domain/member/controller/MemberController.java index ee4fcc9..c8fbc73 100644 --- a/src/main/java/dmu/dasom/api/domain/member/controller/MemberController.java +++ b/src/main/java/dmu/dasom/api/domain/member/controller/MemberController.java @@ -1,6 +1,7 @@ package dmu.dasom.api.domain.member.controller; import dmu.dasom.api.domain.common.exception.ErrorResponse; +import dmu.dasom.api.domain.member.dto.LoginRequestDto; import dmu.dasom.api.domain.member.dto.SignupRequestDto; import dmu.dasom.api.domain.member.service.MemberService; import dmu.dasom.api.global.auth.dto.TokenBox; @@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; @@ -21,6 +23,7 @@ @RestController @RequestMapping("/api") @RequiredArgsConstructor +@Tag(name = "Member API", description = "회원 관리 API") public class MemberController { private final MemberService memberService; @@ -47,6 +50,68 @@ public ResponseEntity signUp(@Valid @RequestBody final SignupRequestDto re return ResponseEntity.ok().build(); } + // 일반 사용자 로그인 + @Operation(summary = "일반 사용자 로그인") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공 (Header로 토큰 반환)"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "회원 없음", + value = "{ \"code\": \"C003\", \"message\": \"해당 회원을 찾을 수 없습니다.\" }" + ), + @ExampleObject( + name = "로그인 실패", + value = "{ \"code\": \"C005\", \"message\": \"로그인에 실패하였습니다.\" }" + ), + @ExampleObject( + name = "권한 없음", + value = "{ \"code\": \"C001\", \"message\": \"인증되지 않은 사용자입니다.\" }")}))}) + @PostMapping("/auth/user-login") + public ResponseEntity userLogin(@Valid @RequestBody final LoginRequestDto request) { + final TokenBox tokenBox = memberService.userLogin(request); + final HttpHeaders headers = new HttpHeaders(); + headers.add("Access-Token", tokenBox.getAccessToken()); + headers.add("Refresh-Token", tokenBox.getRefreshToken()); + headers.add("Authority", tokenBox.getAuthority()); + + return ResponseEntity.ok().headers(headers).build(); + } + + // 관리자 로그인 + @Operation(summary = "관리자 로그인") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공 (Header로 토큰 반환)"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "회원 없음", + value = "{ \"code\": \"C003\", \"message\": \"해당 회원을 찾을 수 없습니다.\" }" + ), + @ExampleObject( + name = "로그인 실패", + value = "{ \"code\": \"C005\", \"message\": \"로그인에 실패하였습니다.\" }" + ), + @ExampleObject( + name = "권한 없음", + value = "{ \"code\": \"C001\", \"message\": \"인증되지 않은 사용자입니다.\" }")}))}) + @PostMapping("/auth/admin-login") + public ResponseEntity adminLogin(@Valid @RequestBody final LoginRequestDto request) { + final TokenBox tokenBox = memberService.adminLogin(request); + final HttpHeaders headers = new HttpHeaders(); + headers.add("Access-Token", tokenBox.getAccessToken()); + headers.add("Refresh-Token", tokenBox.getRefreshToken()); + headers.add("Authority", tokenBox.getAuthority()); + + return ResponseEntity.ok().headers(headers).build(); + } + @Operation(summary = "토큰 갱신") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "토큰 갱신 성공 (Header로 토큰 반환)"), diff --git a/src/main/java/dmu/dasom/api/domain/member/dto/LoginRequestDto.java b/src/main/java/dmu/dasom/api/domain/member/dto/LoginRequestDto.java new file mode 100644 index 0000000..790a0cc --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/member/dto/LoginRequestDto.java @@ -0,0 +1,23 @@ +package dmu.dasom.api.domain.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +@Schema(name = "LoginRequestDto", description = "로그인 요청 DTO") +public class LoginRequestDto { + + @NotNull(message = "이메일은 필수 값입니다.") + @Email(message = "유효한 이메일 주소를 입력해주세요.") + @Size(max = 64) + @Schema(description = "이메일 주소", example = "test@example.com") + private String email; + + @NotNull(message = "비밀번호는 필수 값입니다.") + @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.") + @Schema(description = "비밀번호", example = "password123!") + private String password; +} diff --git a/src/main/java/dmu/dasom/api/domain/member/dto/SignupRequestDto.java b/src/main/java/dmu/dasom/api/domain/member/dto/SignupRequestDto.java index 62b395e..94c1994 100644 --- a/src/main/java/dmu/dasom/api/domain/member/dto/SignupRequestDto.java +++ b/src/main/java/dmu/dasom/api/domain/member/dto/SignupRequestDto.java @@ -22,10 +22,15 @@ public class SignupRequestDto { @Schema(description = "비밀번호", example = "password", minLength = 8, maxLength = 128) private String password; - public Member toEntity(final String password) { + @Length(max = 4) + @Schema(description = "기수 (선택)", example = "34기", nullable = true) + private String generation; + + public Member toEntity(final String password, final String generation) { return Member.builder() .email(this.email) .password(password) + .generation(generation) .build(); } } diff --git a/src/main/java/dmu/dasom/api/domain/member/entity/Member.java b/src/main/java/dmu/dasom/api/domain/member/entity/Member.java index b2c6c9a..aacaa48 100644 --- a/src/main/java/dmu/dasom/api/domain/member/entity/Member.java +++ b/src/main/java/dmu/dasom/api/domain/member/entity/Member.java @@ -33,4 +33,7 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private Role role = Role.ROLE_MEMBER; -} + // 기수 정보를 저장할 필드 추가 + @Column(name = "generation", length = 4, nullable = false) + private String generation; +} \ No newline at end of file diff --git a/src/main/java/dmu/dasom/api/domain/member/service/MemberService.java b/src/main/java/dmu/dasom/api/domain/member/service/MemberService.java index 719816d..db867f3 100644 --- a/src/main/java/dmu/dasom/api/domain/member/service/MemberService.java +++ b/src/main/java/dmu/dasom/api/domain/member/service/MemberService.java @@ -1,5 +1,6 @@ package dmu.dasom.api.domain.member.service; +import dmu.dasom.api.domain.member.dto.LoginRequestDto; import dmu.dasom.api.domain.member.dto.SignupRequestDto; import dmu.dasom.api.domain.member.entity.Member; import dmu.dasom.api.global.auth.dto.TokenBox; @@ -13,6 +14,12 @@ public interface MemberService { void signUp(final SignupRequestDto request); + TokenBox login(final LoginRequestDto request); + + TokenBox userLogin(final LoginRequestDto request); + + TokenBox adminLogin(final LoginRequestDto request); + TokenBox tokenRotation(final UserDetailsImpl userDetails); } diff --git a/src/main/java/dmu/dasom/api/domain/member/service/MemberServiceImpl.java b/src/main/java/dmu/dasom/api/domain/member/service/MemberServiceImpl.java index eef3d68..32acb28 100644 --- a/src/main/java/dmu/dasom/api/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/member/service/MemberServiceImpl.java @@ -1,13 +1,18 @@ package dmu.dasom.api.domain.member.service; +import dmu.dasom.api.domain.member.dto.LoginRequestDto; import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.recruit.service.RecruitService; import dmu.dasom.api.domain.member.dto.SignupRequestDto; import dmu.dasom.api.domain.member.entity.Member; +import dmu.dasom.api.domain.member.enums.Role; import dmu.dasom.api.domain.member.repository.MemberRepository; import dmu.dasom.api.global.auth.dto.TokenBox; import dmu.dasom.api.global.auth.jwt.JwtUtil; import dmu.dasom.api.global.auth.userdetails.UserDetailsImpl; +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -24,6 +29,7 @@ public class MemberServiceImpl implements MemberService { private final BCryptPasswordEncoder encoder; private final MemberRepository memberRepository; + private final RecruitService recruitService; private final JwtUtil jwtUtil; // 이메일로 사용자 조회 @@ -45,9 +51,50 @@ public void signUp(final SignupRequestDto request) { // 이미 가입된 이메일인지 확인 if (checkByEmail(request.getEmail())) throw new CustomException(ErrorCode.SIGNUP_FAILED); + //기수는 선택적으로 가져오며, 없을 경우 신입 부붠 처리하여, 모집일정의 기수 사용 + String generation = (request.getGeneration() != null && !request.getGeneration().isEmpty()) + ? request.getGeneration() + : recruitService.getCurrentGeneration(); - // 비밀번호 암호화 후 저장 - memberRepository.save(request.toEntity(encoder.encode(request.getPassword()))); + // 비밀번호 암호화 후 저장, 기수도 같이 기입 + memberRepository.save(request.toEntity(encoder.encode(request.getPassword()), generation)); + } + + // 로그인 (기존 로직을 private 헬퍼 메소드로 변경) + private TokenBox authenticateAndGenerateToken(final String email, final String password, final Role expectedRole) { + // 1. 이메일로 사용자 조회 + final Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + // 2. 비밀번호 일치 여부 확인 + if (!encoder.matches(password, member.getPassword())) { + throw new CustomException(ErrorCode.LOGIN_FAILED); + } + + // 3. 역할 확인 + if (expectedRole != null && member.getRole() != expectedRole) { + throw new CustomException(ErrorCode.UNAUTHORIZED); // 또는 더 구체적인 에러 코드 + } + + // 4. JWT 토큰 생성 및 반환 + return jwtUtil.generateTokenBox(member.getEmail(), member.getRole().getName()); + } + + @Override + public TokenBox login(final LoginRequestDto request) { + // 이 메소드는 더 이상 사용되지 않거나, userLogin/adminLogin으로 대체됩니다. + // 여기서는 임시로 일반 로그인으로 처리합니다. + return authenticateAndGenerateToken(request.getEmail(), request.getPassword(), null); + } + + @Override + public TokenBox userLogin(final LoginRequestDto request) { + return authenticateAndGenerateToken(request.getEmail(), request.getPassword(), Role.ROLE_MEMBER); + } + + @Override + public TokenBox adminLogin(final LoginRequestDto request) { + return authenticateAndGenerateToken(request.getEmail(), request.getPassword(), Role.ROLE_ADMIN); } // 토큰 갱신 diff --git a/src/main/java/dmu/dasom/api/domain/news/controller/NewsController.java b/src/main/java/dmu/dasom/api/domain/news/controller/NewsController.java index f3658fc..592dcd7 100644 --- a/src/main/java/dmu/dasom/api/domain/news/controller/NewsController.java +++ b/src/main/java/dmu/dasom/api/domain/news/controller/NewsController.java @@ -12,7 +12,7 @@ import jakarta.validation.Valid; import java.util.List; -@Tag(name = "NEWS API", description = "뉴스 API") +@Tag(name = "News API", description = "뉴스 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/news") @@ -32,24 +32,4 @@ public ResponseEntity getNewsById(@PathVariable @Min(1) Long id return ResponseEntity.ok(newsService.getNewsById(id)); } - @Operation(summary = "뉴스 등록") - @PostMapping - public ResponseEntity createNews(@Valid @RequestBody NewsRequestDto requestDto) { - return ResponseEntity.status(201).body(newsService.createNews(requestDto)); - } - - @Operation(summary = "뉴스 수정") - @PutMapping("/{id}") - public ResponseEntity updateNews(@PathVariable @Min(1) Long id, - @Valid @RequestBody NewsUpdateRequestDto requestDto) { - return ResponseEntity.ok(newsService.updateNews(id, requestDto)); - } - - @Operation(summary = "뉴스 삭제") - @DeleteMapping("/{id}") - public ResponseEntity deleteNews(@PathVariable Long id) { - newsService.deleteNews(id); - return ResponseEntity.ok().build(); - } - } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java index fc4bbeb..008d349 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java @@ -15,18 +15,18 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.LocalDate; -import java.time.LocalTime; import java.util.List; @RestController @RequestMapping("/api/recruit") @RequiredArgsConstructor +@Tag(name = "Recruit API", description = "부원 모집 API") public class RecruitController { private final ApplicantService applicantService; @@ -36,25 +36,21 @@ public class RecruitController { // 지원하기 @Operation(summary = "부원 지원하기") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "지원 성공"), - @ApiResponse(responseCode = "400", description = "실패 케이스", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = { - @ExampleObject( - name = "학번 중복", - value = "{ \"code\": \"C013\", \"message\": \"이미 등록된 학번입니다.\" }"), - @ExampleObject( - name = "모집 기간 아님", - value = "{ \"code\": \"C029\", \"message\": \"모집 기간이 아닙니다.\" }") - })) + @ApiResponse(responseCode = "200", description = "지원 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "학번 중복", value = "{ \"code\": \"C013\", \"message\": \"이미 등록된 학번입니다.\" }"), + @ExampleObject(name = "모집 기간 아님", value = "{ \"code\": \"C029\", \"message\": \"모집 기간이 아닙니다.\" }"), + @ExampleObject(name = "잘못된 요청 값", value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }") + })) }) @PostMapping("/apply") public ResponseEntity apply(@Valid @RequestBody final ApplicantCreateRequestDto request) { applicantService.apply(request); - return ResponseEntity.ok() - .build(); + return ResponseEntity.ok().build(); } // 모집 일정 조회 @@ -65,44 +61,67 @@ public ResponseEntity> getRecruitSchedule() { return ResponseEntity.ok(recruitService.getRecruitSchedule()); } + // 모집 일정 수정 + @Operation(summary = "모집 일정 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "모집 일정 수정 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "잘못된 날짜 형식", value = "{ \"code\": \"C016\", \"message\": \"날짜 형식이 올바르지 않습니다.\" }"), + @ExampleObject(name = "잘못된 시간 형식", value = "{ \"code\": \"C017\", \"message\": \"시간 형식이 올바르지 않습니다.\" }"), + @ExampleObject(name = "잘못된 요청 값", value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }") + })) + }) + @PutMapping("/schedule") + public ResponseEntity modifyRecruitSchedule(@RequestBody dmu.dasom.api.domain.recruit.dto.RecruitScheduleModifyRequestDto request) { + recruitService.modifyRecruitSchedule(request); + return ResponseEntity.ok().build(); + } + + // 모집 일정 초기화 (테스트용) + @Operation(summary = "TEMP: 모집 일정 초기화") + @GetMapping("/init-schedule") + public ResponseEntity initSchedule() { + recruitService.initRecruitSchedule(); + return ResponseEntity.ok("Recruit schedule initialized successfully."); + } + // 합격 결과 확인 @Operation(summary = "합격 결과 확인") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "합격 결과 확인 성공"), - @ApiResponse(responseCode = "400", description = "실패 케이스", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = { - @ExampleObject( - name = "학번 조회 실패 혹은 전화번호 뒷자리 일치하지 않음", - value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }") - })) + @ApiResponse(responseCode = "200", description = "합격 결과 확인 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "지원자 없음", value = "{ \"code\": \"C022\", \"message\": \"지원자를 찾을 수 없습니다.\" }"), + @ExampleObject(name = "잘못된 요청 값", value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }") + })) }) @GetMapping("/result") public ResponseEntity checkResult(@ModelAttribute final ResultCheckRequestDto request) { return ResponseEntity.ok(applicantService.checkResult(request)); } - // 면접 일정 생성 - @Operation(summary = "면접 일정 생성", description = "새로운 면접 일정을 생성합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "면접 일정 생성 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") - }) - @PostMapping("/interview/schedule") - public ResponseEntity> createInterviewSlots(@Valid @RequestBody InterviewSlotCreateRequestDto request) { - - List slots = - interviewService.createInterviewSlots(request.getStartDate(), request.getEndDate(), request.getStartTime(), request.getEndTime()); - return ResponseEntity.ok(slots); - } - // 면접 예약 @Operation(summary = "면접 예약", description = "지원자가 특정 면접 슬롯을 예약합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "면접 예약 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "지원자 없음", value = "{ \"code\": \"C022\", \"message\": \"지원자를 찾을 수 없습니다.\" }"), + @ExampleObject(name = "슬롯 없음", value = "{ \"code\": \"C021\", \"message\": \"슬롯을 찾을 수 없습니다.\" }"), + @ExampleObject(name = "이미 예약됨", value = "{ \"code\": \"C023\", \"message\": \"이미 예약된 지원자입니다.\" }"), + @ExampleObject(name = "슬롯 가득 참", value = "{ \"code\": \"C025\", \"message\": \"해당 슬롯이 가득 찼습니다.\" }"), + @ExampleObject(name = "슬롯 불가", value = "{ \"code\": \"C33\", \"message\": \"해당 슬롯을 예약할 수 없습니다.\" }") + })) }) @PostMapping("/interview/reserve") public ResponseEntity reserveInterviewSlot(@Valid @RequestBody InterviewReservationRequestDto request) { @@ -127,11 +146,4 @@ public ResponseEntity> getAllInterviewSlots() { List allSlots = interviewService.getAllInterviewSlots(); return ResponseEntity.ok(allSlots); } - - @GetMapping("/interview/applicants") - public ResponseEntity> getAllInterviewApplicants() { - List applicants = interviewService.getAllInterviewApplicants(); - return ResponseEntity.ok(applicants); - } - } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/entity/Recruit.java b/src/main/java/dmu/dasom/api/domain/recruit/entity/Recruit.java index 6d39414..29366ab 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/entity/Recruit.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/entity/Recruit.java @@ -37,7 +37,19 @@ public void updateTime(final LocalTime time) { this.value = time.format(TIME_FORMATTER); } + + // 기수 업데이트 + public void updateGeneration(final String generation) { + this.value = generation; // ex. "34기" + } + public RecruitConfigResponseDto toResponse() { + if(this.key == ConfigKey.GENERATION){ + return RecruitConfigResponseDto.builder() + .key(key) + .value(value) + .build(); + } LocalDateTime dateTime = LocalDateTime.parse(this.value, DATE_TIME_FORMATTER); return RecruitConfigResponseDto.builder() .key(key) diff --git a/src/main/java/dmu/dasom/api/domain/recruit/enums/ConfigKey.java b/src/main/java/dmu/dasom/api/domain/recruit/enums/ConfigKey.java index 5bc3a4e..a224918 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/enums/ConfigKey.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/enums/ConfigKey.java @@ -18,6 +18,7 @@ public enum ConfigKey { INTERVIEW_TIME_END, // 면접 종료 시간 // 2차 합격 발표일 (시간 포함) - INTERVIEW_PASS_ANNOUNCEMENT // 2차 합격 발표일 + INTERVIEW_PASS_ANNOUNCEMENT, // 2차 합격 발표일 + GENERATION //현재 모집중인 기수 정보 } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java index ac272dd..1ee30d7 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java @@ -13,7 +13,13 @@ public interface RecruitService { List getRecruitSchedule(); - void modifyRecruitSchedule(final RecruitScheduleModifyRequestDto requestDto); + void modifyRecruitSchedule(RecruitScheduleModifyRequestDto request); + + void initRecruitSchedule(); + + void modifyGeneration(String newGeneration); + + String getCurrentGeneration(); String generateReservationCode(String studentNo, String contactLastDigits); diff --git a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java index 7dbd6ce..4667aa4 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java @@ -25,13 +25,13 @@ public class RecruitServiceImpl implements RecruitService { private final RecruitRepository recruitRepository; - // 모집 일정 설정 조회 + // 모집 설정 조회 @Override public List getRecruitSchedule() { return findAll().stream() - .map(config -> config.getKey() == ConfigKey.INTERVIEW_TIME_START || config.getKey() == ConfigKey.INTERVIEW_TIME_END - ? config.toTimeResponse() : config.toResponse()) - .toList(); + .map(config -> config.getKey() == ConfigKey.INTERVIEW_TIME_START || config.getKey() == ConfigKey.INTERVIEW_TIME_END + ? config.toTimeResponse() : config.toResponse()) + .toList(); } // 모집 일정 설정 수정 @@ -50,6 +50,20 @@ public void modifyRecruitSchedule(final RecruitScheduleModifyRequestDto request) config.updateDateTime(dateTime); } + + //기수 수정 + @Override + @Transactional + public void modifyGeneration(String newGeneration) { + final Recruit config = findByKey(ConfigKey.GENERATION); + config.updateGeneration(newGeneration); + } + // 기수 조회 + @Override + public String getCurrentGeneration() { + Recruit generationConfig = findByKey(ConfigKey.GENERATION); + return generationConfig.getValue(); + } // 모집 기간 여부 확인 @Override public boolean isRecruitmentActive() { @@ -74,6 +88,30 @@ public LocalDateTime getResultAnnouncementSchedule(ResultCheckType type) { return parseDateTimeFormat(recruit.getValue()); } + /* + * 모집 일정 초기화 + * - DB에 Recruit 데이터가 존재하지 않을 경우 기본 값으로 초기화 + * - 각 ConfigKey에 대해 Recruit 엔티티를 생성하여 저장 + * - 기본 값은 "2025-01-01T00:00:00"으로 설정됨 + */ + @Override + @Transactional + public void initRecruitSchedule() { + // 이미 데이터가 존재하면 초기화하지 않음 + if (recruitRepository.count() > 0) { + return; + } + + // 모든 ConfigKey를 순회하며 기본 Recruit 데이터 생성 + for (ConfigKey key : ConfigKey.values()) { + Recruit recruit = Recruit.builder() + .key(key) + .value("2025-01-01T00:00:00") // 초기 기본 값 + .build(); + recruitRepository.save(recruit); + } + } + // DB에 저장된 모든 Recruit 객체를 찾아 반환 private List findAll() { return recruitRepository.findAll(); @@ -82,8 +120,7 @@ private List findAll() { // DB에서 key에 해당하는 Recruit 객체를 찾아 반환 private Recruit findByKey(final ConfigKey key) { return recruitRepository.findByKey(key) - .orElseThrow(() -> new CustomException(ErrorCode.ARGUMENT_NOT_VALID)); - + .orElseThrow(() -> new CustomException(ErrorCode.ARGUMENT_NOT_VALID)); } // 시간 형식 변환 및 검증 @@ -103,5 +140,4 @@ private LocalDateTime parseDateTimeFormat(String value) { throw new CustomException(ErrorCode.INVALID_DATETIME_FORMAT); } } - } diff --git a/src/main/java/dmu/dasom/api/global/R2/config/R2Config.java b/src/main/java/dmu/dasom/api/global/R2/config/R2Config.java new file mode 100644 index 0000000..293bf8f --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/R2/config/R2Config.java @@ -0,0 +1,35 @@ +package dmu.dasom.api.global.R2.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import java.net.URI; + +@Configuration +public class R2Config { + + @Value("${cloudflare.r2.endpoint}") + private String endPoint; + + @Value("${cloudflare.r2.access-key-id}") + private String accessKeyId; + + @Value("${cloudflare.r2.secret-access-key}") + private String secretAccessKey; + + @Bean + public S3Client s3Client() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey); + + return S3Client.builder() + .region(Region.of("auto")) // R2 does not require a specific region + .endpointOverride(URI.create(endPoint)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } +} diff --git a/src/main/java/dmu/dasom/api/global/R2/service/R2Service.java b/src/main/java/dmu/dasom/api/global/R2/service/R2Service.java new file mode 100644 index 0000000..6747845 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/R2/service/R2Service.java @@ -0,0 +1,56 @@ +package dmu.dasom.api.global.R2.service; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.UUID; + +@Service +public class R2Service { + + private final S3Client s3Client; + + @Value("${cloudflare.r2.bucket}") + private String bucket; + + @Value("${cloudflare.r2.public-url}") + private String publicUrl; + + public R2Service(S3Client s3Client) { + this.s3Client = s3Client; + } + + public String uploadFile(MultipartFile file) { + String key = UUID.randomUUID() + "_" + file.getOriginalFilename(); + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(request, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + } catch (IOException e) { + throw new CustomException(ErrorCode.FILE_UPLOAD_FAIL); + } + return publicUrl + "/" + key; + } + + public void deleteFile(String fileUrl) { + String key = fileUrl.replace(publicUrl + "/", ""); + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + s3Client.deleteObject(request); + } +} diff --git a/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java b/src/main/java/dmu/dasom/api/global/admin/controller/AdminApplicantController.java similarity index 76% rename from src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java rename to src/main/java/dmu/dasom/api/global/admin/controller/AdminApplicantController.java index 659d570..0abad72 100644 --- a/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java +++ b/src/main/java/dmu/dasom/api/global/admin/controller/AdminApplicantController.java @@ -4,9 +4,7 @@ import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto; import dmu.dasom.api.domain.applicant.service.ApplicantService; -import dmu.dasom.api.domain.recruit.dto.RecruitScheduleModifyRequestDto; -import dmu.dasom.api.domain.recruit.service.RecruitService; -import dmu.dasom.api.domain.email.enums.MailType; +import dmu.dasom.api.domain.google.enums.MailType; import dmu.dasom.api.global.dto.PageResponse; import io.swagger.v3.oas.annotations.Operation; @@ -16,6 +14,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -24,12 +23,12 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/admin") +@RequestMapping("/api/admin/applicants") @RequiredArgsConstructor -public class AdminController { +@Tag(name = "ADMIN - Applicant API", description = "어드민 지원자 관리 API") +public class AdminApplicantController { private final ApplicantService applicantService; - private final RecruitService recruitService; // 지원자 조회 @Operation(summary = "지원자 전체 조회") @@ -48,7 +47,7 @@ public class AdminController { ) ) }) - @GetMapping("/applicants") + @GetMapping public ResponseEntity> getApplicants( @RequestParam(value = "page", defaultValue = "0") @Min(0) final int page ) { @@ -72,7 +71,7 @@ public ResponseEntity> getApplicants( ) ) }) - @GetMapping("/applicants/{id}") + @GetMapping("/{id}") public ResponseEntity getApplicant(@PathVariable("id") @Min(0) final Long id) { return ResponseEntity.ok(applicantService.getApplicant(id)); } @@ -94,7 +93,7 @@ public ResponseEntity getApplicant(@PathVariable("i ) ) }) - @PatchMapping("/applicants/{id}/status") + @PatchMapping("/{id}/status") public ResponseEntity updateApplicantStatus( @PathVariable("id") @Min(0) final Long id, @Valid @RequestBody final ApplicantStatusUpdateRequestDto request @@ -102,33 +101,6 @@ public ResponseEntity updateApplicantStatus( return ResponseEntity.ok(applicantService.updateApplicantStatus(id, request)); } - // 모집 일정 수정 - @Operation(summary = "모집 일정 수정") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "모집 일정 수정 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = { - @ExampleObject( - name = "날짜 형식 오류", - value = "{ \"code\": \"C016\", \"message\": \"날짜 형식이 올바르지 않습니다.\" }" - ), - @ExampleObject( - name = "시간 형식 오류", - value = "{ \"code\": \"C017\", \"message\": \"시간 형식이 올바르지 않습니다.\" }" - ) - } - ) - ) - }) - @PatchMapping("/recruit/schedule") - public ResponseEntity modifyRecruitSchedule(@Valid @RequestBody final RecruitScheduleModifyRequestDto request) { - recruitService.modifyRecruitSchedule(request); - return ResponseEntity.ok().build(); - } - // 메일 전송 @Operation( summary = "메일 전송", @@ -149,7 +121,7 @@ public ResponseEntity modifyRecruitSchedule(@Valid @RequestBody final Recr ) ) }) - @PostMapping("/applicants/send-email") + @PostMapping("/send-email") public ResponseEntity sendEmailsToApplicants( @RequestParam @Parameter(description = "메일 발송 타입", examples = { diff --git a/src/main/java/dmu/dasom/api/global/file/controller/FileController.java b/src/main/java/dmu/dasom/api/global/admin/controller/AdminFileController.java similarity index 82% rename from src/main/java/dmu/dasom/api/global/file/controller/FileController.java rename to src/main/java/dmu/dasom/api/global/admin/controller/AdminFileController.java index 8fe65bc..9d3b6b2 100644 --- a/src/main/java/dmu/dasom/api/global/file/controller/FileController.java +++ b/src/main/java/dmu/dasom/api/global/admin/controller/AdminFileController.java @@ -1,4 +1,4 @@ -package dmu.dasom.api.global.file.controller; +package dmu.dasom.api.global.admin.controller; import dmu.dasom.api.domain.common.exception.ErrorResponse; import dmu.dasom.api.global.file.dto.FileResponseDto; @@ -9,8 +9,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -18,9 +20,10 @@ import java.util.List; @RestController -@RequestMapping("/api/files") +@RequestMapping("/api/admin/files") @RequiredArgsConstructor -public class FileController { +@Tag(name = "ADMIN - File API", description = "어드민 파일 관리 API") +public class AdminFileController { private final FileService fileService; @@ -38,14 +41,14 @@ public class FileController { )) }) @PostMapping(value = "/upload", consumes = {"multipart/form-data"}) - public ResponseEntity uploadFiles( + public ResponseEntity> uploadFiles( @RequestParam("files") List files, @RequestParam("fileType") FileType fileType, @RequestParam("targetId") @Min(1) Long targetId ) { - fileService.uploadFiles(files, fileType, targetId); - return ResponseEntity.ok() - .build(); + List fileResponseDtos = fileService.uploadFiles(files, fileType, targetId); + return ResponseEntity.status(HttpStatus.CREATED) + .body(fileResponseDtos); } @ApiResponses(value = { diff --git a/src/main/java/dmu/dasom/api/global/admin/controller/AdminNewsController.java b/src/main/java/dmu/dasom/api/global/admin/controller/AdminNewsController.java new file mode 100644 index 0000000..8b6b7c2 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/admin/controller/AdminNewsController.java @@ -0,0 +1,85 @@ +package dmu.dasom.api.global.admin.controller; + +import dmu.dasom.api.domain.common.exception.ErrorResponse; +import dmu.dasom.api.domain.news.dto.NewsCreationResponseDto; +import dmu.dasom.api.domain.news.dto.NewsRequestDto; +import dmu.dasom.api.domain.news.dto.NewsResponseDto; +import dmu.dasom.api.domain.news.dto.NewsUpdateRequestDto; +import dmu.dasom.api.domain.news.service.NewsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/news") +@RequiredArgsConstructor +@Tag(name = "ADMIN - News API", description = "어드민 소식 관리 API") +public class AdminNewsController { + + private final NewsService newsService; + + @Operation(summary = "뉴스 등록") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "뉴스 등록 성공") + }) + @PostMapping + public ResponseEntity createNews(@Valid @RequestBody NewsRequestDto requestDto) { + return ResponseEntity.status(201) + .body(newsService.createNews(requestDto)); + } + + @Operation(summary = "뉴스 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "뉴스 수정 성공"), + @ApiResponse(responseCode = "404", description = "조회 결과 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "조회 결과 없음", + value = "{ \"code\": \"C010\", \"message\": \"해당 리소스를 찾을 수 없습니다.\" }" + ) + } + )) + }) + @PutMapping("/{id}") + public ResponseEntity updateNews( + @PathVariable @Min(1) Long id, + @Valid @RequestBody NewsUpdateRequestDto requestDto + ) { + return ResponseEntity.ok(newsService.updateNews(id, requestDto)); + } + + @Operation(summary = "뉴스 삭제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "뉴스 삭제 성공"), + @ApiResponse(responseCode = "404", description = "조회 결과 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "조회 결과 없음", + value = "{ \"code\": \"C010\", \"message\": \"해당 리소스를 찾을 수 없습니다.\" }" + ) + } + )) + }) + @DeleteMapping("/{id}") + public ResponseEntity deleteNews(@PathVariable Long id) { + newsService.deleteNews(id); + return ResponseEntity.ok() + .build(); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/admin/controller/AdminRecruitController.java b/src/main/java/dmu/dasom/api/global/admin/controller/AdminRecruitController.java new file mode 100644 index 0000000..a2ceb7f --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/admin/controller/AdminRecruitController.java @@ -0,0 +1,101 @@ +package dmu.dasom.api.global.admin.controller; + +import dmu.dasom.api.domain.interview.dto.InterviewReservationApplicantResponseDto; +import dmu.dasom.api.domain.interview.dto.InterviewSlotCreateRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; +import dmu.dasom.api.domain.interview.service.InterviewService; +import dmu.dasom.api.domain.recruit.dto.RecruitScheduleModifyRequestDto; +import dmu.dasom.api.domain.recruit.service.RecruitService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/recruit") +@RequiredArgsConstructor +@Tag(name = "ADMIN - Recruit API", description = "어드민 모집 관리 API") +public class AdminRecruitController { + + private final RecruitService recruitService; + private final InterviewService interviewService; + + @Operation(summary = "모집 일정 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "모집 일정 수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "날짜 형식 오류", + value = "{ \"code\": \"C016\", \"message\": \"날짜 형식이 올바르지 않습니다.\" }" + ), + @ExampleObject( + name = "시간 형식 오류", + value = "{ \"code\": \"C017\", \"message\": \"시간 형식이 올바르지 않습니다.\" }" + ) + } + ) + ) + }) + @PatchMapping("/schedule") + public ResponseEntity modifyRecruitSchedule(@Valid @RequestBody final RecruitScheduleModifyRequestDto request) { + recruitService.modifyRecruitSchedule(request); + return ResponseEntity.ok() + .build(); + } + + @Operation(summary = "모집 기수 변경") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "모집 기수 수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청") + }) + @PatchMapping("/generation") + public ResponseEntity modifyGeneration(@Valid @RequestBody String request) { + recruitService.modifyGeneration(request); + return ResponseEntity.ok() + .build(); + } + + + @Operation(summary = "면접 일정 생성", description = "새로운 면접 일정을 생성합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "면접 일정 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") + }) + @PostMapping("/interview/schedule") + public ResponseEntity> createInterviewSlots(@Valid @RequestBody InterviewSlotCreateRequestDto request) { + + List slots = + interviewService.createInterviewSlots( + request.getStartDate(), + request.getEndDate(), + request.getStartTime(), + request.getEndTime() + ); + return ResponseEntity.ok(slots); + } + + @Operation(summary = "면접 예약자 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "면접 예약자 조회 성공") + }) + @GetMapping("/interview/applicants") + public ResponseEntity> getAllInterviewApplicants() { + List applicants = interviewService.getAllInterviewApplicants(); + return ResponseEntity.ok(applicants); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/config/SecurityConfig.java b/src/main/java/dmu/dasom/api/global/auth/config/SecurityConfig.java index 3f3e2e6..6e4b41e 100644 --- a/src/main/java/dmu/dasom/api/global/auth/config/SecurityConfig.java +++ b/src/main/java/dmu/dasom/api/global/auth/config/SecurityConfig.java @@ -12,6 +12,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -26,6 +27,7 @@ @Configuration @EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) // 메소드 보안 활성화 (@PreAuthorize 사용) @RequiredArgsConstructor public class SecurityConfig { @@ -71,7 +73,7 @@ public SecurityFilterChain filterChain(final HttpSecurity http, final Authentica .requestMatchers("/api/auth/logout", "/api/auth/rotation").authenticated() .anyRequest().permitAll()) .addFilterBefore(jwtFilter, CustomAuthenticationFilter.class) - .addFilterAt(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(new CustomLogoutFilter(jwtUtil), JwtFilter.class) .exceptionHandling(handler -> handler .accessDeniedHandler(accessDeniedHandler) diff --git a/src/main/java/dmu/dasom/api/global/file/dto/FileResponseDto.java b/src/main/java/dmu/dasom/api/global/file/dto/FileResponseDto.java index eee46ff..1f48aa6 100644 --- a/src/main/java/dmu/dasom/api/global/file/dto/FileResponseDto.java +++ b/src/main/java/dmu/dasom/api/global/file/dto/FileResponseDto.java @@ -15,12 +15,11 @@ public class FileResponseDto { @NotNull private Long id; - @Schema(description = "파일 형식", example = "image/png") + @Schema(description = "파일 URL", example = "url") @NotNull - private String fileFormat; + private String encodedData; // r2에 저장된 파일의 URL - @Schema(description = "인코딩 된 파일", example = "base64encoded") + @Schema(description = "파일 형식", example = "image/png") @NotNull - private String encodedData; // Base64 인코딩 데이터 - + private String fileFormat; } diff --git a/src/main/java/dmu/dasom/api/global/file/entity/FileEntity.java b/src/main/java/dmu/dasom/api/global/file/entity/FileEntity.java index 2aea9a9..62b923e 100644 --- a/src/main/java/dmu/dasom/api/global/file/entity/FileEntity.java +++ b/src/main/java/dmu/dasom/api/global/file/entity/FileEntity.java @@ -21,8 +21,8 @@ public class FileEntity { private String originalName; @Lob - @Column(name = "ENCODED_DATA", nullable = false, columnDefinition = "CLOB") - private String encodedData; + @Column(name = "FILE_URL", nullable = false, columnDefinition = "CLOB") + private String fileUrl; @Column(name = "FILE_FORMAT", nullable = false) private String fileFormat; @@ -41,7 +41,7 @@ public FileResponseDto toResponseDto() { return FileResponseDto.builder() .id(id) .fileFormat(fileFormat) - .encodedData(encodedData) + .encodedData(fileUrl) .build(); } diff --git a/src/main/java/dmu/dasom/api/global/file/service/FileService.java b/src/main/java/dmu/dasom/api/global/file/service/FileService.java index 6837b38..67e8212 100644 --- a/src/main/java/dmu/dasom/api/global/file/service/FileService.java +++ b/src/main/java/dmu/dasom/api/global/file/service/FileService.java @@ -3,6 +3,7 @@ import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; import dmu.dasom.api.domain.news.entity.NewsEntity; +import dmu.dasom.api.global.R2.service.R2Service; import dmu.dasom.api.global.file.dto.FileResponseDto; import dmu.dasom.api.global.file.entity.FileEntity; import dmu.dasom.api.global.file.enums.FileType; @@ -10,10 +11,9 @@ import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.util.Base64; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -23,21 +23,27 @@ public class FileService { private final FileRepository fileRepository; + private final R2Service r2Service; // 파일 업로드 - public void uploadFiles(List files, FileType fileType, Long targetId) { + @Transactional + public List uploadFiles(List files, FileType fileType, Long targetId) { List filesToEntity = files.stream() - .map(file -> FileEntity.builder() - .originalName(file.getOriginalFilename()) - .encodedData(encode(file)) - .fileFormat(file.getContentType()) - .fileSize(file.getSize()) - .fileType(fileType) - .targetId(targetId) - .build()) - .toList(); - - fileRepository.saveAllAndFlush(filesToEntity); + .map(file -> { + String fileUrl = r2Service.uploadFile(file); + return FileEntity.builder() + .originalName(file.getOriginalFilename()) + .fileUrl(fileUrl) + .fileFormat(file.getContentType()) + .fileSize(file.getSize()) + .fileType(fileType) + .targetId(targetId) + .build(); + }).toList(); + List savedFiles = fileRepository.saveAll(filesToEntity); + return savedFiles.stream() + .map(FileEntity::toResponseDto) + .collect(Collectors.toList()); } // 단일 파일 조회 @@ -56,13 +62,17 @@ public List getFilesByTypeAndTargetId(FileType fileType, Long t .toList(); } + @Transactional public void deleteFilesByTypeAndTargetId(FileType fileType, Long targetId) { List files = findByFileTypeAndTargetId(fileType, targetId); - if (ObjectUtils.isNotEmpty(files)) - fileRepository.deleteAll(files); + if (ObjectUtils.isNotEmpty(files)) { + files.forEach(file -> r2Service.deleteFile(file.getFileUrl())); + } + fileRepository.deleteAll(files); } + @Transactional public void deleteFilesById(NewsEntity news, List fileIds) { List files = fileRepository.findAllById(fileIds); @@ -92,15 +102,4 @@ public Map getFirstFileByTypeAndTargetIds(FileType fileTy private List findByFileTypeAndTargetId(FileType fileType, Long targetId) { return fileRepository.findByFileTypeAndTargetId(fileType, targetId); } - - private String encode(MultipartFile file) { - try { - byte[] bytes = file.getBytes(); - return Base64.getEncoder() - .encodeToString(bytes); - } catch (IOException e) { - throw new CustomException(ErrorCode.FILE_ENCODE_FAIL); - } - } - } \ No newline at end of file diff --git a/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java b/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java new file mode 100644 index 0000000..333da44 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java @@ -0,0 +1,47 @@ +package dmu.dasom.api.global.util; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class VerificationCodeManager { + + private static final long EXPIRATION_TIME_SECONDS = 180; // 인증 코드 유효 시간 (3분) + private final StringRedisTemplate redisTemplate; + + // RedisTemplate 주입 + public VerificationCodeManager(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** + * 인증 코드 생성 및 Redis에 저장 + * @param key - 사용자 식별값 (예: 이메일 주소) + * @return 생성된 6자리 인증 코드 + */ + public String generateAndStoreCode(String key) { + String code = String.valueOf((int)(Math.random() * 900000) + 100000); // 6자리 난수 생성 + // Redis에 key=코드 저장, TTL=3분 + redisTemplate.opsForValue().set(key, code, EXPIRATION_TIME_SECONDS, TimeUnit.SECONDS); + return code; + } + + /** + * Redis에서 인증 코드 검증 + * @param key - 사용자 식별값 (예: 이메일 주소) + * @param code - 사용자가 입력한 인증 코드 + * @throws CustomException - 코드가 없거나 일치하지 않을 때 발생 + */ + public void verifyCode(String key, String code) { + String storedCode = redisTemplate.opsForValue().get(key); // Redis에서 코드 조회 + if (storedCode == null || !storedCode.equals(code)) { + throw new CustomException(ErrorCode.VERIFICATION_CODE_NOT_VALID); + } + // 검증 성공 시 코드 삭제 (재사용 방지) + redisTemplate.delete(key); + } +} diff --git a/src/main/resources/application-credentials.yml b/src/main/resources/application-credentials.yml index be47469..e71af2f 100644 --- a/src/main/resources/application-credentials.yml +++ b/src/main/resources/application-credentials.yml @@ -35,4 +35,11 @@ google: file: path: ${GOOGLE_CREDENTIALS_PATH} spreadsheet: - id: ${GOOGLE_SPREADSHEET_ID} \ No newline at end of file + id: ${GOOGLE_SPREADSHEET_ID} +cloudflare: + r2: + endpoint: ${CLOUDFLARE_R2_ENDPOINT} + bucket: ${CLOUDFLARE_R2_BUCKET} + access-key-id: ${CLOUDFLARE_R2_ACCESS_KEY_ID} + secret-access-key: ${CLOUDFLARE_R2_SECRET_ACCESS_KEY} + public-url: ${CLOUDFLARE_R2_PUBLIC_URL} \ No newline at end of file diff --git a/src/main/resources/templates/document-result-email.html b/src/main/resources/templates/document-result-email.html new file mode 100644 index 0000000..457f3d0 --- /dev/null +++ b/src/main/resources/templates/document-result-email.html @@ -0,0 +1,122 @@ + + + + + + + + 이메일 안내 + + +
+
+
+
+ +
+ 로고 +
+
+
+
+ +
+ DASOM +
+
+
+
+ 컴퓨터공학부 전공동아리 다솜입니다. +
+ +
+ 먼저 다솜 35기에 많은 관심을 두고 지원해 주셔서 감사드리며,
+ 내부 서류 평가 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.
+ 서류 전형 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다. +
+ + + +
+ 또한, 문의 사항은 본 메일에 회신 또는 아래 번호로 편하게 연락 부탁드립니다.
+ 010-6361-3481 +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/email-template.html b/src/main/resources/templates/final-result-email.html similarity index 89% rename from src/main/resources/templates/email-template.html rename to src/main/resources/templates/final-result-email.html index 89465a3..98f6eeb 100644 --- a/src/main/resources/templates/email-template.html +++ b/src/main/resources/templates/final-result-email.html @@ -78,8 +78,10 @@ line-height: 2.5; text-align: right; margin-bottom: 40px; - color: white !important;" - th:utext="${emailContent}"> + color: white !important;"> + 먼저 다솜 35기에 많은 관심을 두고 지원해 주셔서 감사드리며,
+ 최종 면접 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.
+ 최종 면접 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다.
- - + 최종 결과 확인하기 + + + + + + 이메일 인증 안내 + + +
+ +
+
+ 로고 +
+
+ +
DASOM
+ +

+ +

+ 컴퓨터공학부 전공동아리 다솜입니다. +

+ +

+ 요청하신 인증 코드는 다음과 같습니다.
+
+ 해당 코드를 3분 내에 입력하여 인증을 완료해 주세요. +

+ +
+ + + +
+ +

+ 또한, 문의 사항은 본 메일에 회신 또는 아래 번호로 편하게 연락 부탁드립니다.
+ 010-6361-3481 +

+
+ + diff --git a/src/test/java/dmu/dasom/api/domain/activity/ActivityServiceTest.java b/src/test/java/dmu/dasom/api/domain/activity/ActivityServiceTest.java new file mode 100644 index 0000000..e0b6788 --- /dev/null +++ b/src/test/java/dmu/dasom/api/domain/activity/ActivityServiceTest.java @@ -0,0 +1,177 @@ +package dmu.dasom.api.domain.activity; + +import dmu.dasom.api.domain.activity.dto.ActivityRequestDto; +import dmu.dasom.api.domain.activity.dto.ActivityResponseDto; +import dmu.dasom.api.domain.activity.entity.Activity; +import dmu.dasom.api.domain.activity.entity.Section; +import dmu.dasom.api.domain.activity.repository.ActivityRepository; +import dmu.dasom.api.domain.activity.repository.SectionRepository; +import dmu.dasom.api.domain.activity.service.ActivityServiceImpl; +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ActivityServiceTest { + + @InjectMocks + private ActivityServiceImpl activityService; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private SectionRepository sectionRepository; + + @Test + @DisplayName("활동 전체 조회 성공") + void getActivities() { + // given + Section section1 = createSection(1L, "교내 경진대회"); + Section section2 = createSection(2L, "외부 경진대회"); + Activity activity1 = createActivity(10L, "캡스톤 디자인", section1, LocalDate.of(2024, 5, 10)); + Activity activity2 = createActivity(11L, "구글 해커톤", section2, LocalDate.of(2024, 8, 20)); + + given(activityRepository.findAll()).willReturn(List.of(activity1, activity2)); + + // when + List response = activityService.getActivities(); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).getYear()).isEqualTo(2024); + assertThat(response.get(0).getSections()).hasSize(2); + verify(activityRepository).findAll(); + } + + @Test + @DisplayName("활동 생성 성공") + void createActivity() { + // given + Section section = createSection(1L, "교내 경진대회"); + ActivityRequestDto request = createRequestDto(section); // Section 객체로 DTO 생성 + + // findByName 대신 findBySection 사용 가정 (혹은 그 반대) + given(sectionRepository.findByName("교내 경진대회")).willReturn(Optional.of(section)); + + // when + activityService.createActivity(request); + + // then + verify(activityRepository).save(any(Activity.class)); + verify(sectionRepository).findByName("교내 경진대회"); + } + + @Test + @DisplayName("활동 수정 성공") + void updateActivity() { + // given + long activityId = 1L; + Section oldSection = createSection(1L, "교내 경진대회"); + Section updatedSection = createSection(2L, "외부 경진대회"); + ActivityRequestDto request = createRequestDto(updatedSection); // Section 객체로 DTO 생성 + Activity existingActivity = createActivity(activityId, "기존 제목", oldSection, LocalDate.now()); + + given(activityRepository.findById(activityId)).willReturn(Optional.of(existingActivity)); + given(sectionRepository.findByName("외부 경진대회")).willReturn(Optional.of(updatedSection)); + + // when + activityService.updateActivity(activityId, request); + + // then + assertThat(existingActivity.getSection()).isEqualTo(updatedSection); + assertThat(existingActivity.getTitle()).isEqualTo(request.getTitle()); + verify(activityRepository).findById(activityId); + } + + @Test + @DisplayName("활동 수정 실패 - 존재하지 않는 ID") + void updateActivity_whenNotFound_thenThrowException() { + // given + long nonExistentId = 99L; + Section dummySection = createSection(99L, "아무 섹션"); + ActivityRequestDto request = createRequestDto(dummySection); // Section 객체로 DTO 생성 + given(activityRepository.findById(nonExistentId)).willReturn(Optional.empty()); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> activityService.updateActivity(nonExistentId, request)); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND); + verify(activityRepository).findById(nonExistentId); + } + + @Test + @DisplayName("활동 삭제 성공") + void deleteActivity() { + // given + long activityId = 1L; + given(activityRepository.existsById(activityId)).willReturn(true); + + // when + activityService.deleteActivity(activityId); + + // then + verify(activityRepository).existsById(activityId); + verify(activityRepository).deleteById(activityId); + } + + @Test + @DisplayName("활동 삭제 실패 - 존재하지 않는 ID") + void deleteActivity_whenNotFound_thenThrowException() { + // given + long nonExistentId = 99L; + given(activityRepository.existsById(nonExistentId)).willReturn(false); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> activityService.deleteActivity(nonExistentId)); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND); + verify(activityRepository).existsById(nonExistentId); + verify(activityRepository, never()).deleteById(nonExistentId); + } + + // --- Helper Methods --- + + // [수정] 파라미터 타입을 String에서 Section으로 변경 + private ActivityRequestDto createRequestDto(Section section) { + return ActivityRequestDto.builder() + .activityDate(LocalDate.of(2024, 5, 10)) + .section(section.getName()) // Section 객체를 직접 전달 + .title("새로운 활동 제목") + .award("최우수상") + .build(); + } + + private Section createSection(Long id, String sectionName) { + return Section.builder() + .id(id) + .name(sectionName) + .build(); + } + + private Activity createActivity(Long id, String title, Section section, LocalDate date) { + return Activity.builder() + .id(id) + .activityDate(date) + .section(section) + .title(title) + .award("테스트 상") + .build(); + } +} diff --git a/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java b/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java index d50dd1a..b7f3ce4 100644 --- a/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java +++ b/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java @@ -10,8 +10,8 @@ import dmu.dasom.api.domain.applicant.service.ApplicantServiceImpl; import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; -import dmu.dasom.api.domain.email.enums.MailType; -import dmu.dasom.api.domain.email.service.EmailService; +import dmu.dasom.api.domain.google.enums.MailType; +import dmu.dasom.api.domain.google.service.EmailService; import dmu.dasom.api.domain.google.service.GoogleApiService; import dmu.dasom.api.domain.recruit.dto.ResultCheckRequestDto; import dmu.dasom.api.domain.recruit.dto.ResultCheckResponseDto; diff --git a/src/test/java/dmu/dasom/api/domain/email/EmailServiceTest.java b/src/test/java/dmu/dasom/api/domain/email/EmailServiceTest.java index 6491bb0..591ea7b 100644 --- a/src/test/java/dmu/dasom/api/domain/email/EmailServiceTest.java +++ b/src/test/java/dmu/dasom/api/domain/email/EmailServiceTest.java @@ -1,10 +1,10 @@ package dmu.dasom.api.domain.email; -import dmu.dasom.api.domain.common.exception.CustomException; -import dmu.dasom.api.domain.common.exception.ErrorCode; -import dmu.dasom.api.domain.email.enums.MailType; -import dmu.dasom.api.domain.email.service.EmailService; -import jakarta.mail.MessagingException; + +import dmu.dasom.api.domain.google.enums.MailTemplate; +import dmu.dasom.api.domain.google.enums.MailType; +import dmu.dasom.api.domain.google.service.EmailLogService; +import dmu.dasom.api.domain.google.service.EmailService; import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,92 +18,84 @@ import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class EmailServiceTest { @Mock private JavaMailSender javaMailSender; - @Mock private TemplateEngine templateEngine; - + @Mock + private EmailLogService emailLogService; @InjectMocks private EmailService emailService; - private MimeMessage mimeMessage; - @BeforeEach - void setUp() { + void setUp() throws Exception{ MockitoAnnotations.openMocks(this); - mimeMessage = mock(MimeMessage.class); - when(javaMailSender.createMimeMessage()).thenReturn(mimeMessage); - // 테스트 환경에서 from 값을 설정 + MimeMessage mimeMessage = mock(MimeMessage.class); + when(javaMailSender.createMimeMessage()).thenReturn(mock(MimeMessage.class)); + + doNothing().when(mimeMessage).setSubject(anyString(), anyString()); ReflectionTestUtils.setField(emailService, "from", "test_email@example.com"); } - @Test - @DisplayName("서류 합격 메일 발송 테스트") - void sendEmail_documentResult() throws MessagingException { + private void testSendEmailSuccess(MailType mailType) throws Exception { // given String to = "applicant@example.com"; String name = "지원자"; - MailType mailType = MailType.DOCUMENT_RESULT; + MailTemplate expectedTemplate = MailTemplate.getMailType(mailType); + String expectedHtmlBody = "Test HTML for " + mailType + ""; - String expectedTemplate = "email-template"; - String expectedHtmlBody = "Document Pass"; - when(templateEngine.process(eq(expectedTemplate), any(Context.class))).thenReturn(expectedHtmlBody); + when(templateEngine.process(eq(expectedTemplate.getTemplateName()), any(Context.class))).thenReturn(expectedHtmlBody); - // when + //when emailService.sendEmail(to, name, mailType); // then + // 비동기 처리를 위해 잠시 대기 후 검증 ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); - verify(javaMailSender).send(messageCaptor.capture()); + verify(javaMailSender, timeout(1000)).send(messageCaptor.capture()); - MimeMessage sentMessage = messageCaptor.getValue(); - assertNotNull(sentMessage); - verify(templateEngine).process(eq(expectedTemplate), any(Context.class)); - } + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + verify(templateEngine).process(eq(expectedTemplate.getTemplateName()), contextCaptor.capture()); + Context capturedContext = contextCaptor.getValue(); - @Test - @DisplayName("최종 합격 메일 발송 테스트") - void sendEmail_finalResult() throws MessagingException { - // given - String to = "applicant@example.com"; - String name = "지원자"; - MailType mailType = MailType.FINAL_RESULT; + assertEquals(name, capturedContext.getVariable("name")); + assertEquals("https://dmu-dasom.or.kr/recruit/result", capturedContext.getVariable("buttonUrl")); - String expectedTemplate = "email-template"; - String expectedHtmlBody = "Final Pass"; - when(templateEngine.process(eq(expectedTemplate), any(Context.class))).thenReturn(expectedHtmlBody); - - // when - emailService.sendEmail(to, name, mailType); + MimeMessage capturedMessage = messageCaptor.getValue(); + verify(capturedMessage).setSubject(expectedTemplate.getSubject(), "UTF-8"); + } - // then - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); - verify(javaMailSender).send(messageCaptor.capture()); + @Test + @DisplayName("성공 - 서류 결과 메일 발송 테스트") + void sendDocumentResultMessage_Success() throws Exception { + testSendEmailSuccess(MailType.DOCUMENT_RESULT); + } - MimeMessage sentMessage = messageCaptor.getValue(); - assertNotNull(sentMessage); - verify(templateEngine).process(eq(expectedTemplate), any(Context.class)); + @Test + @DisplayName("성공 - 최종 결과 메일 발송 테스트") + void sendFinalResultMessage_Success() throws Exception { + testSendEmailSuccess(MailType.FINAL_RESULT); } @Test - @DisplayName("잘못된 MailType 처리 테스트") - void sendEmail_invalidMailType() { - // given + @DisplayName("실패 - MailType이 null일 경우, 예외 발생 테스트") + void sendEmail_nullMailType_shouldNotSend() { + //given String to = "applicant@example.com"; String name = "지원자"; - // when & then - CustomException exception = assertThrows(CustomException.class, () -> { - emailService.sendEmail(to, name, null); - }); + // when + emailService.sendEmail(to, name, null); - assertEquals(ErrorCode.MAIL_TYPE_NOT_VALID, exception.getErrorCode()); + // then + verify(javaMailSender, never()).send(any(MimeMessage.class)); + verify(emailLogService, timeout(1000)).logEmailSending(eq(to), any(), any()); } } \ No newline at end of file diff --git a/src/test/java/dmu/dasom/api/domain/executive/service/ExecutiveServiceTest.java b/src/test/java/dmu/dasom/api/domain/executive/service/ExecutiveServiceTest.java new file mode 100644 index 0000000..93825df --- /dev/null +++ b/src/test/java/dmu/dasom/api/domain/executive/service/ExecutiveServiceTest.java @@ -0,0 +1,205 @@ +package dmu.dasom.api.domain.executive.service; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.executive.dto.ExecutiveCreationResponseDto; +import dmu.dasom.api.domain.executive.dto.ExecutiveRequestDto; +import dmu.dasom.api.domain.executive.dto.ExecutiveResponseDto; +import dmu.dasom.api.domain.executive.dto.ExecutiveUpdateRequestDto; +import dmu.dasom.api.domain.executive.entity.ExecutiveEntity; +import dmu.dasom.api.domain.executive.enums.Team; +import dmu.dasom.api.domain.executive.repository.ExecutiveRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.test.context.support.WithMockUser; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ExecutiveServiceTest { + + @Mock + private ExecutiveRepository executiveRepository; + + // 생성자 주입 + @InjectMocks + private ExecutiveServiceImpl executiveService; + + @Test + @DisplayName("임원진 멤버 조회 - 성공") + void getExecutiveById_success() { + // given + Long id = 1L; + ExecutiveEntity entity = ExecutiveEntity.builder() + .id(1L) + .name("김다솜") + .position("회장") + .role("동아리 운영 총괄") + .githubUsername("DASOM") + .team(Team.PRESIDENT) + .build(); + + when(executiveRepository.findById(id)).thenReturn(Optional.of(entity)); + + // when + ExecutiveResponseDto responseDto = executiveService.getExecutiveById(id); + + // then + assertThat(responseDto.getId()).isEqualTo(id); + assertThat(responseDto.getName()).isEqualTo("김다솜"); + assertThat(responseDto.getPosition()).isEqualTo("회장"); + assertThat(responseDto.getRole()).isEqualTo("동아리 운영 총괄"); + assertThat(responseDto.getGithub_username()).isEqualTo("DASOM"); + assertThat(responseDto.getTeam()).isEqualTo(Team.PRESIDENT); + + // verify ( 호출 검증 ) + verify(executiveRepository, times(1)).findById(id); // 메소드를 정확히 한 번만 호출했는지? + } + + @Test + @DisplayName("임원진 멤버 조회 - 실패") + void getExecutiveById_fail() { + // given + Long id = 1L; + when(executiveRepository.findById(1L)).thenReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, () -> { + executiveService.getExecutiveById(1L); + }); + + // then + assertEquals(ErrorCode.EXECUTIVE_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("임원진 멤버 생성 - 성공") + void createExecutive_success() { + // given + Long id = 1L; + ExecutiveRequestDto dto = new ExecutiveRequestDto( + id, + "김다솜", + "회장", + "동아리 운영 총괄", + "DASOM", + Team.PRESIDENT, + 1 + ); + + ExecutiveEntity entity = ExecutiveEntity.builder() + .id(1L) + .name("김다솜") + .position("회장") + .role("동아리 운영 총괄") + .githubUsername("DASOM") + .team(Team.PRESIDENT) + .build(); + + when(executiveRepository.save(any(ExecutiveEntity.class))).thenReturn(entity); + + // when + ExecutiveCreationResponseDto responseDto = executiveService.createExecutive(dto); + + // then + assertThat(responseDto.getId()).isEqualTo(id); + } + + @Test + @DisplayName("임원진 멤버 생성 - 실패_권한 없음") + @WithMockUser(roles = "MEMBER") + public void createExecutive_fail_authority() { + + } + + @Test + @DisplayName("임원진 멤버 삭제 - 성공") + void deleteExecutive_success() { + // given + Long id = 1L; + + ExecutiveEntity entity = ExecutiveEntity.builder() + .id(1L) + .name("김다솜") + .position("회장") + .role("동아리 운영 총괄") + .githubUsername("DASOM") + .team(Team.PRESIDENT) + .build(); + + when(executiveRepository.findById(id)).thenReturn(Optional.of(entity)); + doNothing().when(executiveRepository).delete(entity); + + // when + executiveService.deleteExective(id); + + // then + verify(executiveRepository, times(1)).findById(id); + verify(executiveRepository, times(1)).delete(entity); + } + + @Test + @DisplayName("임원진 멤버 삭제 - 실패") + void deleteExecutive_fail() { + // given + Long id = 999L; + when(executiveRepository.findById(id)).thenReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, () -> {executiveService.deleteExective(id);}); + + // then + assertEquals(ErrorCode.EXECUTIVE_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("임원진 멤버 수정 - 성공") + void updateExecutive_success() { + + //given + Long id = 1L; + + ExecutiveEntity entity = ExecutiveEntity.builder() + .id(1L) + .name("김다솜") + .position("회장") + .role("동아리 운영 총괄") + .githubUsername("DASOM") + .team(Team.PRESIDENT) + .build(); + + ExecutiveUpdateRequestDto updateEntity = new ExecutiveUpdateRequestDto( + "김솜다", + "부회장", + "동아리 운영 총괄", + "MOSAD", + Team.ACADEMIC, + 1 + ); + + when(executiveRepository.findById(id)).thenReturn(Optional.of(entity)); + + //when + ExecutiveResponseDto updateExecutive = executiveService.updateExecutive(id, updateEntity); + + //then + assertThat(updateExecutive.getName()).isEqualTo("김솜다"); + assertThat(updateExecutive.getPosition()).isEqualTo("부회장"); + assertThat(updateExecutive.getRole()).isEqualTo("동아리 운영 총괄"); + assertThat(updateExecutive.getGithub_username()).isEqualTo("MOSAD"); + assertThat(updateExecutive.getTeam()).isEqualTo(Team.ACADEMIC); + + // verify ( 호출 검증 ) + verify(executiveRepository, times(1)).findById(id); // 메소드를 정확히 한 번만 호출했는지? + } +} \ No newline at end of file diff --git a/src/test/java/dmu/dasom/api/domain/member/MemberServiceTest.java b/src/test/java/dmu/dasom/api/domain/member/MemberServiceTest.java index 9ba112c..89724b3 100644 --- a/src/test/java/dmu/dasom/api/domain/member/MemberServiceTest.java +++ b/src/test/java/dmu/dasom/api/domain/member/MemberServiceTest.java @@ -6,6 +6,7 @@ import dmu.dasom.api.domain.member.entity.Member; import dmu.dasom.api.domain.member.repository.MemberRepository; import dmu.dasom.api.domain.member.service.MemberServiceImpl; +import dmu.dasom.api.domain.recruit.service.RecruitService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,6 +14,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; import java.util.Optional; @@ -28,6 +30,9 @@ class MemberServiceTest { @Mock MemberRepository memberRepository; + @Mock + RecruitService recruitService; // RecruitService 주입 + @InjectMocks private MemberServiceImpl memberService; @@ -92,23 +97,49 @@ void checkByEmail_false() { assertFalse(result); } + @Test - @DisplayName("회원가입 - 성공") - void signUp_success() { - // given - SignupRequestDto request = mock(SignupRequestDto.class); - when(request.getEmail()).thenReturn("test@example.com"); - when(request.getPassword()).thenReturn("password"); + @DisplayName("회원가입 - 기수 선택값 전달 시 사용") + void signUp_withGenerationProvided() { + // 실제 DTO 객체 사용 + SignupRequestDto request = new SignupRequestDto(); + // Reflection 또는 생성자/Setter로 값 설정 + ReflectionTestUtils.setField(request, "email", "test@example.com"); + ReflectionTestUtils.setField(request, "password", "password"); + ReflectionTestUtils.setField(request, "generation", "35기"); + when(encoder.encode("password")).thenReturn("encodedPassword"); when(memberRepository.existsByEmail("test@example.com")).thenReturn(false); - // when memberService.signUp(request); - // then - verify(memberRepository, times(1)).save(any()); + verify(memberRepository, times(1)).save(argThat(member -> + "35기".equals(member.getGeneration()) + )); + verify(recruitService, never()).getCurrentGeneration(); } + @Test + @DisplayName("회원가입 - 기수 선택값 없으면 기본값 사용") + void signUp_withGenerationDefault() { + SignupRequestDto request = new SignupRequestDto(); + ReflectionTestUtils.setField(request, "email", "test@example.com"); + ReflectionTestUtils.setField(request, "password", "password"); + ReflectionTestUtils.setField(request, "generation", null); + + when(encoder.encode("password")).thenReturn("encodedPassword"); + when(memberRepository.existsByEmail("test@example.com")).thenReturn(false); + when(recruitService.getCurrentGeneration()).thenReturn("34기"); + + memberService.signUp(request); + + verify(memberRepository, times(1)).save(argThat(member -> + "34기".equals(member.getGeneration()) + )); + verify(recruitService, times(1)).getCurrentGeneration(); + } + + @Test @DisplayName("회원가입 - 실패") void signUp_fail() { diff --git a/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java b/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java index 205f4a5..e91580a 100644 --- a/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java +++ b/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java @@ -33,7 +33,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) class RecruitServiceTest { @@ -102,6 +101,36 @@ void modifyRecruitSchedule_fail() { assertEquals(ErrorCode.INVALID_TIME_FORMAT, exception.getErrorCode()); } + @Test + @DisplayName("모집 기수 수정") + void modifyGeneration_success() { + // given + Recruit generationRecruit = mock(Recruit.class); + String newGeneration = "35기"; + when(recruitRepository.findByKey(ConfigKey.GENERATION)).thenReturn(Optional.of(generationRecruit)); + + // when + recruitService.modifyGeneration(newGeneration); + + // then + verify(generationRecruit, times(1)).updateGeneration(newGeneration); + } + + @Test + @DisplayName("기수 조회") + void getCurrentGeneration_success() { + // given + Recruit generationRecruit = mock(Recruit.class); + when(recruitRepository.findByKey(ConfigKey.GENERATION)).thenReturn(Optional.of(generationRecruit)); + when(generationRecruit.getValue()).thenReturn("34기"); + // when + String currentGeneration = recruitService.getCurrentGeneration(); + // then + assertEquals("34기", currentGeneration); + verify(recruitRepository, times(1)).findByKey(ConfigKey.GENERATION); + verify(generationRecruit, times(1)).getValue(); + } + @Test @DisplayName("면접 일정 생성 - 성공") void createInterviewSlots_success() { @@ -196,4 +225,5 @@ void reserveInterviewSlot_fail_alreadyReserved() { assertEquals(ErrorCode.ALREADY_RESERVED, exception.getErrorCode()); } + }