diff --git a/build.gradle b/build.gradle index f261f5d..5a51020 100644 --- a/build.gradle +++ b/build.gradle @@ -2,46 +2,60 @@ // Generated by https://start.springboot.io // 优质的 spring/boot/data/security/cloud 框架中文文档尽在 => https://springdoc.cn plugins { - id 'java' - id 'org.springframework.boot' version '3.4.5' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.4.5' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.example' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-websocket' - compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - runtimeOnly 'com.h2database:h2' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'com.h2database:h2' // runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + //Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + //Smtp + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // swagger + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0" } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/main/java/com/JoinUs/dp/domain/club/README.txt b/src/main/java/com/JoinUs/dp/domain/club/README.txt new file mode 100644 index 0000000..c5d78f7 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/club/README.txt @@ -0,0 +1,3 @@ +임시로 만든 club 도메인 +전부 삭제하고 다른 club 도메인으로 교체해도 무방함 +대신 interview.SetData 클래스 파일을 삭제하거나(SetData 파일은 없어도 상관없음) 약간의 코드변경을 해야함 \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/domain/club/entity/Club.java b/src/main/java/com/JoinUs/dp/domain/club/entity/Club.java new file mode 100644 index 0000000..1f83024 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/club/entity/Club.java @@ -0,0 +1,25 @@ +package com.JoinUs.dp.domain.club.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter @Setter +public class Club { + protected Club() {} + + @Id + @GeneratedValue + private Long id; + + @Column + private String name; + + public Club(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/club/repository/ClubRepository.java b/src/main/java/com/JoinUs/dp/domain/club/repository/ClubRepository.java new file mode 100644 index 0000000..8c79ad8 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/club/repository/ClubRepository.java @@ -0,0 +1,7 @@ +package com.JoinUs.dp.domain.club.repository; + +import com.JoinUs.dp.domain.club.entity.Club; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ClubRepository extends JpaRepository { +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/SetData.java b/src/main/java/com/JoinUs/dp/domain/interview/SetData.java new file mode 100644 index 0000000..77a392b --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/SetData.java @@ -0,0 +1,152 @@ +package com.JoinUs.dp.domain.interview; + +import com.JoinUs.dp.domain.club.entity.Club; +import com.JoinUs.dp.domain.club.repository.ClubRepository; +import com.JoinUs.dp.domain.interview.entity.Applicant; +import com.JoinUs.dp.domain.interview.entity.Interview; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewConfirmedEnum; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewResultEnum; +import com.JoinUs.dp.domain.interview.repository.ApplicantRepository; +import com.JoinUs.dp.domain.interview.repository.InterviewRepository; +import com.JoinUs.dp.domain.interview.service.InterviewService; +import com.JoinUs.dp.domain.user.dto.request.RegisterDto; +import com.JoinUs.dp.domain.user.entity.User; +import com.JoinUs.dp.domain.user.repository.UserRepository; +import com.JoinUs.dp.domain.user.service.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + + +// 테스트 데이터 생성 클래스 파일 (삭제해도 무방함) +@Component("interviewDataSeeder") +@RequiredArgsConstructor +public class SetData implements CommandLineRunner { + private final InterviewService interviewService; + private final AuthService authService; + private final InterviewRepository interviewRepository; + private final ClubRepository clubRepository; + private final UserRepository userRepository; + private final ApplicantRepository applicantRepository; + + private void UserDataSeeding(){ + authService.register(new RegisterDto( + "user1@gmail.com", + "user1", + "user1" + )); + authService.register(new RegisterDto( + "user2@gmail.com", + "user2", + "user2" + )); + authService.register(new RegisterDto( + "user3@gmail.com", + "user3", + "user3" + )); + authService.register(new RegisterDto( + "user4@gmail.com", + "user4", + "user4" + )); + authService.register(new RegisterDto( + "user5@gmail.com", + "user5", + "user5" + )); + authService.register(new RegisterDto( + "user6@gmail.com", + "user6", + "user6" + )); + authService.register(new RegisterDto( + "user7@gmail.com", + "user7", + "user7" + )); + + } + + private void ClubDataSeeding(){ + List clubs = List.of( + new Club("밴드부"), + new Club("OTT") + ); + clubRepository.saveAll(clubs); + } + +// private void InterviewDataSeeding(){ +// Club club = clubRepository.findById(1L).orElse(null); +// DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"); +// +// List interviews = List.of( +// new Interview( +// club, +// LocalDateTime.parse("2025-10-11T13:00", fmt), +// LocalDateTime.parse("2025-10-11T13:30", fmt), +// "산학협동관 I208호", +// 3L +// ), +// new Interview( +// club, +// LocalDateTime.parse("2025-10-11T13:00", fmt), +// LocalDateTime.parse("2025-10-11T13:30", fmt), +// "산학협동관 I210호", +// 3L +// ), +// new Interview( +// club, +// LocalDateTime.parse("2025-10-11T13:30", fmt), +// LocalDateTime.parse("2025-10-11T14:00", fmt), +// "산학협동관 I208호", +// 3L +// ) +// ); +// interviewRepository.saveAll(interviews); +// } + +// +// private void ApplyInterview(Long interviewId, List userNameList) { +// for (String userName : userNameList) { +// User user = userRepository.findByUsername(userName); +// String userEmail = user.getEmail(); +// interviewService.apply(interviewId, userEmail); +// } +// } +// +// private void SetInterviewResult(List userNameList, InterviewResultEnum status) { +// for (String userName : userNameList) { +// Applicant applicant = applicantRepository.findByUser_Username(userName); +// applicant.setInterviewResult(status); +// applicantRepository.save(applicant); +// } +// } +// +// private void SetUserConfirmStatus(List userNameList, InterviewConfirmedEnum status) { +// for (String userName : userNameList) { +// Applicant applicant = applicantRepository.findByUser_Username(userName); +// applicant.setConfirmStatus(status); +// applicantRepository.save(applicant); +// } +//} + + public void run(String[] args){ + UserDataSeeding(); + ClubDataSeeding(); +// InterviewDataSeeding(); + +// ApplyInterview(1L, List.of("user1", "user2", "user3")); +// ApplyInterview(2L, List.of("user4", "user5")); +// ApplyInterview(3L, List.of("user6", "user7")); +// +// SetInterviewResult(List.of("user1", "user2", "user3", "user4"), InterviewResultEnum.PASSED); +// SetInterviewResult(List.of("user5", "user6", "user7"), InterviewResultEnum.FAILED); +// SetUserConfirmStatus(List.of("user1", "user2"), InterviewConfirmedEnum.CONFIRMED); +// SetUserConfirmStatus(List.of("user3"), InterviewConfirmedEnum.DECLINED); + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/controller/InterviewController.java b/src/main/java/com/JoinUs/dp/domain/interview/controller/InterviewController.java new file mode 100644 index 0000000..9cf5ca1 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/controller/InterviewController.java @@ -0,0 +1,122 @@ +package com.JoinUs.dp.domain.interview.controller; + + +import com.JoinUs.dp.domain.interview.dto.request.CreateInterviewRequestDto; +import com.JoinUs.dp.domain.interview.dto.response.CheckDetailedInterviewResponseDto; +import com.JoinUs.dp.domain.interview.dto.response.CheckInterviewResponseDto; +import com.JoinUs.dp.domain.interview.dto.response.InterviewResponseDto; +import com.JoinUs.dp.domain.interview.dto.response.UnconfirmedUserResponseDto; +import com.JoinUs.dp.domain.interview.service.InterviewService; +import com.JoinUs.dp.global.common.ApiPath; +import com.JoinUs.dp.global.dto.Response; +import com.JoinUs.dp.global.utility.JwtProvider; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequiredArgsConstructor +public class InterviewController { + private final InterviewService interviewService; + private final JwtProvider jwtProvider; + + // 1) 면접 회차 생성 + @Operation(summary = "면접 회차 생성") + @PostMapping(ApiPath.CLUB_INTERVIEWS) + public ResponseEntity> create( + @PathVariable Long clubId, + @RequestBody CreateInterviewRequestDto req + ) { + return ResponseEntity.ok( + new Response<>( + HttpStatus.OK, + interviewService.create(clubId, req), + "면접 회차 생성 완료" + ) + ); + } + + // 2) 면접 회차 조회 + @Operation(summary = "면접 회차 조회") + @GetMapping(ApiPath.CLUB_INTERVIEWS) + public ResponseEntity> check(@PathVariable Long clubId) { + return ResponseEntity.ok( + new Response<>( + HttpStatus.OK, + interviewService.check(clubId), + "면접 회차 조회" + ) + ); + } + +// // 3) 면접 신청 +// @PostMapping(ApiPath.INTERVIEW_APPLY) +// public void apply( +// @PathVariable Long interviewId, +// @RequestBody ApplyInterviewRequestDto req +// ) { +// interviewService.apply(interviewId, req); +// } + + private String resolveBearer(String authorization) { + if (authorization == null || !authorization.startsWith("Bearer ")) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "토큰이 없습니다"); + } + return authorization.substring(7); // "Bearer " 제거 + } + + // 3) 면접 신청 + @Operation(summary = "면접 신청") + @SecurityRequirement(name = "bearerAuth") + @PostMapping("/interviews/{interviewId}/apply") + public void apply(@PathVariable Long interviewId, HttpServletRequest request) { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); // Swagger가 전역으로 붙여줌 + if (authorization == null || authorization.isBlank()) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authorization 헤더가 없습니다"); + } + String token = resolveBearer(authorization); + + // 유효성 확인 + if (!jwtProvider.validateToken(token)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰"); + } + // 토큰 -> 이메일 + String email = jwtProvider.extractEmail(token); + +// interviewService.applyWithToken(interviewId, token); + interviewService.apply(interviewId, email); + } + + + // 4) 신청자 면접 현황 조회 + @Operation(summary = "신청자 면접 현황 조회") + @GetMapping(ApiPath.CLUB_INTERVIEW_APPS) + public ResponseEntity> checkDetailed(@PathVariable Long clubId) { + return ResponseEntity.ok( + new Response<>( + HttpStatus.OK, + interviewService.checkDetailed(clubId), + "신청자 면접 현황 조회" + ) + ); + } + + // 5) 미확정/가입거절 인원 조회 + @Operation(summary = "미확정/가입거절 인원 조회") + @GetMapping(ApiPath.CLUB_VACANCIES) + public ResponseEntity> checkUnconfirmedUser(@PathVariable Long clubId) { + return ResponseEntity.ok( + new Response<>( + HttpStatus.OK, + interviewService.checkUnconfirmedUser(clubId), + "미확정/가입거절 인원 조회" + ) + ); + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/dto/request/CreateInterviewRequestDto.java b/src/main/java/com/JoinUs/dp/domain/interview/dto/request/CreateInterviewRequestDto.java new file mode 100644 index 0000000..45a3f18 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/request/CreateInterviewRequestDto.java @@ -0,0 +1,34 @@ +package com.JoinUs.dp.domain.interview.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +// 면접 회차 생성 바디 dto +public record CreateInterviewRequestDto ( + @NotNull + LocalDate date, + + @NotNull + @Schema(type = "string", format = "time", example = "01:30") + LocalTime startTime, + + @NotNull + @Schema(type = "string", format = "time", example = "02:00") + LocalTime endTime, + + @NotNull + int duration, + + @NotNull + int maxApplicants, + + @NotNull + String location + +){ +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/dto/response/ApplicantResponseDto.java b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/ApplicantResponseDto.java new file mode 100644 index 0000000..76202b7 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/ApplicantResponseDto.java @@ -0,0 +1,40 @@ +package com.JoinUs.dp.domain.interview.dto.response; + +import com.JoinUs.dp.domain.interview.entity.Applicant; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewConfirmedEnum; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewResultEnum; +import com.JoinUs.dp.domain.user.entity.User; +import com.JoinUs.dp.domain.user.repository.UserRepository; +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; +import java.util.List; + +// 면접 신청자 정보 응답용 dto +public record ApplicantResponseDto( + Long userId, + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime appliedAt, + + InterviewResultEnum interviewResult, + InterviewConfirmedEnum decided + +) { + // ApplicantEntity -> ApplicantResponseDto + public static ApplicantResponseDto from(Applicant applicant) { + return new ApplicantResponseDto( + applicant.getUserId(), + applicant.getAppliedAt(), + applicant.getInterviewResult(), + applicant.getConfirmStatus() + ); + } + // List -> List + public static List fromList(List applicants) { + return applicants.stream() + .map(i -> { + return from(i); + }).toList(); + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/dto/response/CheckDetailedInterviewResponseDto.java b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/CheckDetailedInterviewResponseDto.java new file mode 100644 index 0000000..b619c9d --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/CheckDetailedInterviewResponseDto.java @@ -0,0 +1,12 @@ +package com.JoinUs.dp.domain.interview.dto.response; + +import java.util.List; + +// 동아리 내 면접 회차 상세 조회 dto +// 면접 현황 조회에 사용됨 +public record CheckDetailedInterviewResponseDto( + Long clubId, + String clubName, + List interviews +) { +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/dto/response/CheckInterviewResponseDto.java b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/CheckInterviewResponseDto.java new file mode 100644 index 0000000..18a8af7 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/CheckInterviewResponseDto.java @@ -0,0 +1,12 @@ +package com.JoinUs.dp.domain.interview.dto.response; + +import java.util.List; + +// 동아리 내 면접 회차 조회 dto +// 면접 회차 조회에 사용됨 +public record CheckInterviewResponseDto( + Long clubId, + String clubName, + List interviews +) { +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/dto/response/DetailedInterviewResponseDto.java b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/DetailedInterviewResponseDto.java new file mode 100644 index 0000000..3ca5771 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/DetailedInterviewResponseDto.java @@ -0,0 +1,22 @@ +package com.JoinUs.dp.domain.interview.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +// 특정 면접 회차 상세 정보 (면접 신청자 정보 포합) dto +public record DetailedInterviewResponseDto( + Long interviewId, + LocalDate date, + LocalTime startTime, + LocalTime endTime, + String location, + int maxApplicants, + int applied, + + List applicants +) { +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/dto/response/InterviewResponseDto.java b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/InterviewResponseDto.java new file mode 100644 index 0000000..58a24a7 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/InterviewResponseDto.java @@ -0,0 +1,31 @@ +package com.JoinUs.dp.domain.interview.dto.response; + +import com.JoinUs.dp.domain.interview.entity.Interview; + +import java.time.LocalDate; +import java.time.LocalTime; + +// 단일 면접 회차 정보 dto +public record InterviewResponseDto( + Long interviewId, + String location, + LocalDate date, + LocalTime startTime, + LocalTime endTime, + int duration, + int maxApplicants, + int applied +) { + public static InterviewResponseDto from(Interview interview) { + return new InterviewResponseDto( + interview.getId(), + interview.getLocation(), + interview.getDate(), + interview.getStartTime(), + interview.getEndTime(), + interview.getDuration(), + interview.getMaxApplicants(), + interview.getApplied() + ); + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/dto/response/UnconfirmedUserResponseDto.java b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/UnconfirmedUserResponseDto.java new file mode 100644 index 0000000..4280c65 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/UnconfirmedUserResponseDto.java @@ -0,0 +1,13 @@ +package com.JoinUs.dp.domain.interview.dto.response; + +import java.util.List; + +// 면접 합격자 중 가입 미확정/가입거절 유저 정보 dto +public record UnconfirmedUserResponseDto( + Long clubId, + String clubName, + Long applicantCount, + Long pendingUserCount, + List pendingUsers +) { +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/entity/Applicant.java b/src/main/java/com/JoinUs/dp/domain/interview/entity/Applicant.java new file mode 100644 index 0000000..11d42f6 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/entity/Applicant.java @@ -0,0 +1,61 @@ +package com.JoinUs.dp.domain.interview.entity; + + +import com.JoinUs.dp.domain.interview.entity.enums.InterviewConfirmedEnum; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewResultEnum; +import com.JoinUs.dp.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "InterviewApplications") +@Getter @Setter +public class Applicant { + protected Applicant() {} + + @Id + @GeneratedValue + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "interview_id", nullable = false) + private Long interviewId; + + @CreationTimestamp + @Column(name = "applied_at", columnDefinition = "timestamp default current_timestamp", nullable = true, updatable = false) + private LocalDateTime appliedAt; + + + // 테이블 필드에 안적혀있었지만 신청자 면접 현황 조회, 미확정 인원 조회 API 구현을 위해 그대로 놔둠 + @Column + private InterviewResultEnum interviewResult; // 면접 현황 (PENDING, PASSED, FAILED) + @Column + private InterviewConfirmedEnum confirmStatus; // 가입 확정 여부 (PENDING, CONFIRMED, DECLINED) + @Column(name = "club_id", nullable = false) + private Long clubId; + + // 생성자 + // interviewResult 는 PENDING(대기), confirmStatus 는 PENDING 로 초기화 됨 + public Applicant( + Long userId, + Long interviewId, + Long clubId + ) { + this.interviewId = interviewId; + this.userId = userId; + this.clubId = clubId; + + + this.interviewResult = InterviewResultEnum.PENDING; + this.confirmStatus = InterviewConfirmedEnum.PENDING; + } + + +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/entity/Interview.java b/src/main/java/com/JoinUs/dp/domain/interview/entity/Interview.java new file mode 100644 index 0000000..8cde46e --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/entity/Interview.java @@ -0,0 +1,78 @@ +package com.JoinUs.dp.domain.interview.entity; + + +import com.JoinUs.dp.domain.club.entity.Club; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +@Entity +@Table(name = "Interviews") +@Getter @Setter +public class Interview { + protected Interview() {} + + @Id + @GeneratedValue + @Column(name = "interview_id", nullable = false) + private Long id; + + @Column(name = "club_id", nullable = false) + private Long clubId; + + @Column(name = "date", nullable = false) + private LocalDate date; + + @Column(name = "start_time", nullable = false) + private LocalTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalTime endTime; + + @Column(name = "duration", nullable = false) + private int duration; + + @Column(name = "max_applicants", nullable = false) + private int maxApplicants; + + + // 테이블 필드에 없던거지만 필요해보여서 넣은거 + @Column + private int applied; + @Column + private String location; + + + + // 생성자 + // applied 는 0으로 초기화됨 + public Interview( + Long clubId, + LocalDate date, + LocalTime startTime, + LocalTime endTime, + int duration, + int maxApplicants, + + String location + ) { + this.clubId = clubId; + this.date = date; + this.startTime = startTime; + this.endTime = endTime; + this.duration = duration; + this.maxApplicants = maxApplicants; + + this.applied = 0; + this.location = location; + } + + // 신청 인원 1 증가 + public void increaseApplied() { + this.applied = this.applied + 1; + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/entity/enums/InterviewConfirmedEnum.java b/src/main/java/com/JoinUs/dp/domain/interview/entity/enums/InterviewConfirmedEnum.java new file mode 100644 index 0000000..b51634c --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/entity/enums/InterviewConfirmedEnum.java @@ -0,0 +1,6 @@ +package com.JoinUs.dp.domain.interview.entity.enums; + +// 가입 확정 여부 +public enum InterviewConfirmedEnum { + PENDING, CONFIRMED, DECLINED +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/entity/enums/InterviewResultEnum.java b/src/main/java/com/JoinUs/dp/domain/interview/entity/enums/InterviewResultEnum.java new file mode 100644 index 0000000..f9c8a5f --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/entity/enums/InterviewResultEnum.java @@ -0,0 +1,6 @@ +package com.JoinUs.dp.domain.interview.entity.enums; + +// 면접 현황 +public enum InterviewResultEnum { + PENDING, PASSED, FAILED +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/repository/ApplicantRepository.java b/src/main/java/com/JoinUs/dp/domain/interview/repository/ApplicantRepository.java new file mode 100644 index 0000000..d64486f --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/repository/ApplicantRepository.java @@ -0,0 +1,43 @@ +package com.JoinUs.dp.domain.interview.repository; + +import com.JoinUs.dp.domain.interview.entity.Applicant; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewConfirmedEnum; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewResultEnum; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + + +public interface ApplicantRepository extends JpaRepository { + + // 특정 유저가 특정 동아리에 이미 지원했는지 조회 + // 면접 중복 신청 방지에 사용됨 + boolean existsByUserIdAndClubId(Long userId, Long clubId); + + // 특정 면접 회차에 지원한 모든 유저(신청자) 목록 + // 면접 현황 조회 기능에 사용됨 + List findAllByInterviewId(Long interviewId); + + // 특정 동아리에 합격한 유저(신청자)들의 목록. 동아리Id, 합격여부, 확정여부 로 필터링 됨 + // 미확정/가입거절 인원 조회에 사용됨 + List findAllByClubIdAndInterviewResultAndConfirmStatus( + Long clubId, + InterviewResultEnum interviewResult, + InterviewConfirmedEnum confirmStatus + ); + + // clubId 동아리에 신청한 유저 수 조회 + Long countByClubId(Long clubId); + + // 미확정/가입거절, 거절 인원수 조회 + Long countByClubIdAndInterviewResultAndConfirmStatus( + Long clubId, + InterviewResultEnum interviewResult, + InterviewConfirmedEnum confirmStatus + ); + + + // 테스트 데이터 생성용 코드(domain.interview.SetData 파일 삭제시 삭제해도됨) +// Applicant findByUser_Username(String username); + +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/repository/InterviewRepository.java b/src/main/java/com/JoinUs/dp/domain/interview/repository/InterviewRepository.java new file mode 100644 index 0000000..19c777c --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/repository/InterviewRepository.java @@ -0,0 +1,12 @@ +package com.JoinUs.dp.domain.interview.repository; + +import com.JoinUs.dp.domain.interview.entity.Interview; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface InterviewRepository extends JpaRepository { + // clubId 로 모든 면접 회차 조회 + // 면접 회차 조회 및 면접 현황 조회 기능에 사용됨 + List findAllByClubId(Long clubId); +} diff --git a/src/main/java/com/JoinUs/dp/domain/interview/service/InterviewService.java b/src/main/java/com/JoinUs/dp/domain/interview/service/InterviewService.java new file mode 100644 index 0000000..1cf1804 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/service/InterviewService.java @@ -0,0 +1,248 @@ +package com.JoinUs.dp.domain.interview.service; + + +import com.JoinUs.dp.domain.club.entity.Club; +import com.JoinUs.dp.domain.club.repository.ClubRepository; +import com.JoinUs.dp.domain.interview.dto.request.CreateInterviewRequestDto; +import com.JoinUs.dp.domain.interview.dto.response.*; +import com.JoinUs.dp.domain.interview.entity.Applicant; +import com.JoinUs.dp.domain.interview.entity.Interview; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewConfirmedEnum; +import com.JoinUs.dp.domain.interview.entity.enums.InterviewResultEnum; +import com.JoinUs.dp.domain.interview.repository.ApplicantRepository; +import com.JoinUs.dp.domain.interview.repository.InterviewRepository; +import com.JoinUs.dp.domain.user.entity.User; +import com.JoinUs.dp.domain.user.repository.UserRepository; +import com.JoinUs.dp.global.utility.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class InterviewService { + private final ClubRepository clubRepository; + private final UserRepository userRepository; + private final InterviewRepository interviewRepository; + private final ApplicantRepository applicantRepository; + private final JwtProvider jwtProvider; + + // 1) 면접 회차 생성 + @Transactional + public InterviewResponseDto create(Long clubId, CreateInterviewRequestDto req) { + // 동아리 존재 여부 확인 + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "clubId: " + clubId + " 를 찾을 수 없음" + ) + ); + + // 시작 시간이 종료 시간보다 늦은 시간으로 주어졌을 때 에러 발생 + // 예시) startAt: 10:00, endAt: 9:00 -> 에러 + if (req.startTime().isAfter(req.endTime())) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "잘못된 시간 설정" + ); + } + + // 데이터베이스 속 생성된 모든 면접 회차와 POST 한 면접 회차 비교 + // 시간과 장소가 겹칠때 에러 발생 + List interviews = interviewRepository.findAll(); + for (Interview interview : interviews) { + if (req.startTime().isBefore(interview.getEndTime()) && req.endTime().isAfter(interview.getStartTime())) { + if (interview.getLocation().equals(req.location())) { + Club creatingInterviewClub = clubRepository.findById(interview.getClubId()).orElseThrow(); + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "(요청: " + club.getName() + ", 비교: " + creatingInterviewClub.getName() + ") 면접 시간, 장소 겹침" + ); + } + } + } + + // 면접 회차 생성 + Interview createdInterview = new Interview( + clubId, + req.date(), + req.startTime(), + req.endTime(), + req.duration(), + req.maxApplicants(), + req.location() + ); + + // 생성된 면접 회차 저장 + interviewRepository.save(createdInterview); + + // InterviewEntity (entity) -> InterviewResponseDto (dto) 변환 후 리턴 + return InterviewResponseDto.from(createdInterview); + } + + + // 2) 면접 회차 조회 + @Transactional(readOnly = true) + public CheckInterviewResponseDto check(Long clubId) { + // 동아리 존재 여부 확인 + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "clubId: " + clubId + " 를 찾을 수 없음" + ) + ); + + // clubId 동아리 내의 모든 면접 회차 조회 + List interviews = interviewRepository.findAllByClubId(clubId); + // InterviewEntity (entity) -> InterviewResponseDto (dto) 로 변환 + List interviewsResponse = interviews.stream() + .map(interviewEntity -> { + return InterviewResponseDto.from(interviewEntity); + }).toList(); + + // 면접 회차 조회 dto 반환 (clubId, clubName, 면접회차dto) + return new CheckInterviewResponseDto( + clubId, + club.getName(), + interviewsResponse + ); + } + + // 3) 면접 신청 (이메일로 식별) + @Transactional + public void apply(Long interviewId, String email) { +// public void applyWithToken(Long interviewId, String token) { +// // 유효성 확인 +// if (!jwtProvider.validateToken(token)) { +// throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰"); +// } +// +// // 토큰 -> 이메일 +// String email = jwtProvider.extractEmail(token); + + // 이메일로 유저 조회 + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "email: " + email + " 를 찾을 수 없음")); + + // 면접 회차 조회 + Interview interview = interviewRepository.findById(interviewId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "interviewId: " + interviewId + " 를 찾을 수 없음")); + + // 중복 신청 체크 + if (applicantRepository.existsByUserIdAndClubId(user.getId(), interview.getClubId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "동일 club 중복 신청"); + } + + // 정원 체크 + if (interview.getApplied() >= interview.getMaxApplicants()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "신청 인원 초과"); + } + + // 카운트 증가 및 저장 + interview.increaseApplied(); + applicantRepository.save(new Applicant( + user.getId(), + interviewId, + interview.getClubId() + )); + } + + // 4) 면접 현황 조회 + @Transactional(readOnly = true) + public CheckDetailedInterviewResponseDto checkDetailed(Long clubId) { + // 동아리 존재 여부 확인 + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "clubId: " + clubId + " 를 찾을 수 없음" + ) + ); + + // clubId 동아리 내의 모든 면접 회차 조회 + List interviews = interviewRepository.findAllByClubId(clubId); + + // 각 회차별로 해당 회차별 신청자 정보까지 합친 Dto 구성 + List detailedInterviewResponse = interviews.stream() + .map(interviewEntity -> { + List applicants = applicantRepository.findAllByInterviewId(interviewEntity.getId()); + return new DetailedInterviewResponseDto( + interviewEntity.getId(), + interviewEntity.getDate(), + interviewEntity.getStartTime(), + interviewEntity.getEndTime(), + interviewEntity.getLocation(), + interviewEntity.getMaxApplicants(), + interviewEntity.getApplied(), + ApplicantResponseDto.fromList(applicants) + ); + }).toList(); + + // 면접 현황 dto 반환 (동아리Id, 동아리이름, 면접회차(면접회차 정보 + 면접신청 유저 정보)) + return new CheckDetailedInterviewResponseDto( + clubId, + club.getName(), + detailedInterviewResponse + ); + } + + + // 5) 미확정/가입거절 인원 조회 + // 추가합격 기능을 위해 만들어짐 + @Transactional(readOnly = true) + public UnconfirmedUserResponseDto checkUnconfirmedUser(Long clubId) { + // 동아리 존재 여부 확인 + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "clubId: " + clubId + " 를 찾을 수 없음" + ) + ); + + // clubId 동아리에 신청한 신청자들의 수 카운팅 + Long applicantCount = applicantRepository.countByClubId(clubId); + + // confirmStatus 가 아직 기본값인 PENDING(대기) 인 유저들의 수 카운팅 + Long pendingUserCount = applicantRepository.countByClubIdAndInterviewResultAndConfirmStatus( + clubId, + InterviewResultEnum.FAILED, + InterviewConfirmedEnum.PENDING + ); + + // confirmStatus 가 아직 기본값인 PENDING(대기) 상태인 유저들을 찾은 후 + // 면접신청 유저 entity 리스트 -> 면접신청 유저 dto 리스트 로 변환 + List pendingUsers = ApplicantResponseDto.fromList( + applicantRepository.findAllByClubIdAndInterviewResultAndConfirmStatus( + clubId, + InterviewResultEnum.FAILED, + InterviewConfirmedEnum.PENDING + ) + ); + +// +// Long confirmedCount = applicantRepository.countByInterview_Club_IdAndInterviewResultAndConfirmStatus( +// clubId, +// InterviewResultEnum.PASSED, +// InterviewConfirmedEnum.CONFIRMED +// ); +// Long declinedCount = applicantRepository.countByInterview_Club_IdAndInterviewResultAndConfirmStatus( +// clubId, +// InterviewResultEnum.PASSED, +// InterviewConfirmedEnum.DECLINED +// ); + + // 미확정 인원 조회 dto 반환 (동아리Id, 동아리이름, 미확정인원수, 확정인원수, 거절인원수, 미확정 유저 리스트, 거절 유저 리스트) + return new UnconfirmedUserResponseDto( + clubId, + club.getName(), + applicantCount, + pendingUserCount, + pendingUsers + ); + } +} + + diff --git a/src/main/java/com/JoinUs/dp/domain/jwt/dto/JwtDto.java b/src/main/java/com/JoinUs/dp/domain/jwt/dto/JwtDto.java new file mode 100644 index 0000000..731d4c1 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/jwt/dto/JwtDto.java @@ -0,0 +1,7 @@ +package com.JoinUs.dp.domain.jwt.dto; + +public record JwtDto( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/JoinUs/dp/domain/mail/dto/RequestSendEmailDto.java b/src/main/java/com/JoinUs/dp/domain/mail/dto/RequestSendEmailDto.java new file mode 100644 index 0000000..c007329 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/mail/dto/RequestSendEmailDto.java @@ -0,0 +1,13 @@ +package com.JoinUs.dp.domain.mail.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; + +@Getter +public class RequestSendEmailDto { + + @NotEmpty(message = "이메일 주소는 필수입니다.") + @Email(message = "유효한 이메일 주소를 입력하세요.") + private String email; +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/domain/mail/dto/RequestVerificationCodeDto.java b/src/main/java/com/JoinUs/dp/domain/mail/dto/RequestVerificationCodeDto.java new file mode 100644 index 0000000..d8f09ab --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/mail/dto/RequestVerificationCodeDto.java @@ -0,0 +1,14 @@ +package com.JoinUs.dp.domain.mail.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; + +@Getter +public class RequestVerificationCodeDto { + + @NotEmpty(message = "이메일 주소는 필수입니다.") + @Email(message = "유효한 이메일 주소를 입력하세요.") + private String email; + private String code; +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/domain/mail/repository/MailRepository.java b/src/main/java/com/JoinUs/dp/domain/mail/repository/MailRepository.java new file mode 100644 index 0000000..7488f9b --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/mail/repository/MailRepository.java @@ -0,0 +1,40 @@ +package com.JoinUs.dp.domain.mail.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class MailRepository { + + private final RedisTemplate redisTemplate; + + private static final long CODE_TTL_MINUTES = 10; + + // 인증 코드 저장 + public void saveCode(String email, String code) { + String key = "EMAIL_CODE:" + email; + redisTemplate.opsForValue().set(key, code, CODE_TTL_MINUTES, TimeUnit.MINUTES); + } + + // 인증 코드 조회 + public String getCode(String email) { + String key = "EMAIL_CODE:" + email; + return redisTemplate.opsForValue().get(key); + } + + // 인증 코드 삭제 (optional) + public void deleteCode(String email) { + String key = "EMAIL_CODE:" + email; + redisTemplate.delete(key); + } + + // 코드 검증 + public boolean verifyCode(String email, String inputCode) { + String code = getCode(email); + return code != null && code.equals(inputCode); + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/mail/service/MailService.java b/src/main/java/com/JoinUs/dp/domain/mail/service/MailService.java new file mode 100644 index 0000000..15ea76d --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/mail/service/MailService.java @@ -0,0 +1,90 @@ +package com.JoinUs.dp.domain.mail.service; + +import com.JoinUs.dp.domain.mail.dto.RequestSendEmailDto; +import com.JoinUs.dp.domain.mail.dto.RequestVerificationCodeDto; +import com.JoinUs.dp.domain.mail.repository.MailRepository; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.io.UnsupportedEncodingException; +import java.util.concurrent.ThreadLocalRandom; + +@Service +@RequiredArgsConstructor +public class MailService { + + private final JavaMailSender javaMailSender; + private final MailRepository mailRepository; + + + /** + * 인증 이메일 발송 + */ + // MailService + public void sendEmail(RequestSendEmailDto dto) { + String code = generateRandomCode(); + sendHtmlEmail(dto.getEmail(), createEmailContent(code)); + mailRepository.saveCode(dto.getEmail(), code); + } + + /** + * 인증코드 HTML 이메일 내용 생성 + */ + private String createEmailContent(String verificationCode) { + return """ + + +

인증 코드: %s

+

해당 코드를 회원가입 창에 입력하세요.

+
+

본 메일은 자동응답 메일이므로 본 메일에 회신하지 마시기 바랍니다.

+
+ + + """.formatted(verificationCode); + } + + /** + * HTML 이메일 전송 + */ + private void sendHtmlEmail(String toEmail, String content) { + try { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "UTF-8"); + helper.setFrom("이메일 보류", "서비스명"); // 나중에 도메인 메일로 교체 + helper.setTo(toEmail); + helper.setSubject("이메일 인증 번호"); + helper.setText(content, true); + + javaMailSender.send(mimeMessage); + } catch (UnsupportedEncodingException | jakarta.mail.MessagingException e) { + throw new RuntimeException("메일 전송 실패", e); + } + } + + /** + * 랜덤 인증 코드 생성 (6자리 알파벳+숫자) + */ + public String generateRandomCode() { + String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + StringBuilder sb = new StringBuilder(); + ThreadLocalRandom random = ThreadLocalRandom.current(); + + for (int i = 0; i < 6; i++) { + int index = random.nextInt(characters.length()); + sb.append(characters.charAt(index)); + } + return sb.toString(); + } + + /** + * 인증 코드 검증 + */ + public boolean verifyCode(RequestVerificationCodeDto dto) { + return mailRepository.verifyCode(dto.getEmail(), dto.getCode()); + } + +} diff --git a/src/main/java/com/JoinUs/dp/domain/user/README.txt b/src/main/java/com/JoinUs/dp/domain/user/README.txt new file mode 100644 index 0000000..eedbee4 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/README.txt @@ -0,0 +1,2 @@ +효준이가 만든 user 도메인 그대로 가져옴 (feat/ email 시점에 가져옴) +repository.UserRepository 에 임시로 추가한 코드 있음 \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/domain/user/controller/AuthController.java b/src/main/java/com/JoinUs/dp/domain/user/controller/AuthController.java new file mode 100644 index 0000000..b37a269 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/controller/AuthController.java @@ -0,0 +1,49 @@ +package com.JoinUs.dp.domain.user.controller; + +import com.JoinUs.dp.domain.user.dto.request.LoginDto; +import com.JoinUs.dp.domain.user.dto.request.RefreshTokenDto; +import com.JoinUs.dp.domain.user.dto.request.RegisterDto; +import com.JoinUs.dp.domain.user.service.AuthService; +import com.JoinUs.dp.global.common.ApiPath; +import com.JoinUs.dp.global.dto.Response; +import com.JoinUs.dp.global.dto.TokenResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(ApiPath.AUTH_PATH) +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + // 회원가입 + @PostMapping("/register") + public ResponseEntity> register(@RequestBody RegisterDto dto) { + authService.register(dto); + return ResponseEntity.ok(new Response<>(HttpStatus.OK, null, "회원가입 성공")); + } + + // 로그인 요청 + @PostMapping("/login") + public ResponseEntity> login(@RequestBody LoginDto dto) { + var tokens = authService.login(dto.getEmail(), dto.getPassword()); + return ResponseEntity.ok(new Response<>(HttpStatus.OK, + new TokenResponse(tokens.getAccessToken(), tokens.getRefreshToken()), + "토큰이 전달되었습니다.")); + } + + // 리프레시 토큰으로 Access 토큰 재발급 + @PostMapping("/refresh") + public ResponseEntity> refreshToken(@RequestBody RefreshTokenDto request) { + var tokens = authService.refreshAccessToken(request.getRefreshToken()); + return ResponseEntity.ok(new Response<>(HttpStatus.OK, + new TokenResponse(tokens.getAccessToken(), tokens.getRefreshToken()), + "리프레시 토큰이 재발급 되었습니다.")); + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/user/dto/request/LoginDto.java b/src/main/java/com/JoinUs/dp/domain/user/dto/request/LoginDto.java new file mode 100644 index 0000000..816c46f --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/dto/request/LoginDto.java @@ -0,0 +1,9 @@ +package com.JoinUs.dp.domain.user.dto.request; + +import lombok.Data; + +@Data +public class LoginDto { + private String email; + private String password; +} diff --git a/src/main/java/com/JoinUs/dp/domain/user/dto/request/RefreshTokenDto.java b/src/main/java/com/JoinUs/dp/domain/user/dto/request/RefreshTokenDto.java new file mode 100644 index 0000000..e68b79e --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/dto/request/RefreshTokenDto.java @@ -0,0 +1,8 @@ +package com.JoinUs.dp.domain.user.dto.request; + +import lombok.Data; + +@Data +public class RefreshTokenDto { + private String refreshToken; +} diff --git a/src/main/java/com/JoinUs/dp/domain/user/dto/request/RegisterDto.java b/src/main/java/com/JoinUs/dp/domain/user/dto/request/RegisterDto.java new file mode 100644 index 0000000..f01d9c4 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/dto/request/RegisterDto.java @@ -0,0 +1,18 @@ +package com.JoinUs.dp.domain.user.dto.request; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data + +// 테스트 데이터 생성용 코드(domain.interview.SetData 파일 삭제시 삭제해도됨) +@NoArgsConstructor +@AllArgsConstructor + +public class RegisterDto { + private String email; + private String password; + private String username; +} diff --git a/src/main/java/com/JoinUs/dp/domain/user/dto/response/TokenDto.java b/src/main/java/com/JoinUs/dp/domain/user/dto/response/TokenDto.java new file mode 100644 index 0000000..bf827f4 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/dto/response/TokenDto.java @@ -0,0 +1,11 @@ +package com.JoinUs.dp.domain.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TokenDto { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/JoinUs/dp/domain/user/entity/User.java b/src/main/java/com/JoinUs/dp/domain/user/entity/User.java new file mode 100644 index 0000000..f4ee601 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/entity/User.java @@ -0,0 +1,35 @@ +package com.JoinUs.dp.domain.user.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor(access = PRIVATE) +@Table(name="users") +public class User { + @Id + @GeneratedValue + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String username; + + @Column + private String refreshToken; + +} diff --git a/src/main/java/com/JoinUs/dp/domain/user/repository/UserRedisRepository.java b/src/main/java/com/JoinUs/dp/domain/user/repository/UserRedisRepository.java new file mode 100644 index 0000000..e28976c --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/repository/UserRedisRepository.java @@ -0,0 +1,40 @@ +package com.JoinUs.dp.domain.user.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class UserRedisRepository { + + private final RedisTemplate redisTemplate; + + private static final long REFRESH_TOKEN_TTL_DAYS = 7; + + // 리프레시 토큰 저장 + public void saveRefreshToken(String email, String refreshToken) { + String key = "REFRESH_TOKEN:" + email; + redisTemplate.opsForValue().set(key, refreshToken, REFRESH_TOKEN_TTL_DAYS, TimeUnit.DAYS); + } + + // 리프레시 토큰 조회 + public String getRefreshToken(String email) { + String key = "REFRESH_TOKEN:" + email; + return redisTemplate.opsForValue().get(key); + } + + // 리프레시 토큰 삭제 (로그아웃 등) + public void deleteRefreshToken(String email) { + String key = "REFRESH_TOKEN:" + email; + redisTemplate.delete(key); + } + + // 검증 + public boolean verifyRefreshToken(String email, String refreshToken) { + String savedToken = getRefreshToken(email); + return savedToken != null && savedToken.equals(refreshToken); + } +} diff --git a/src/main/java/com/JoinUs/dp/domain/user/repository/UserRepository.java b/src/main/java/com/JoinUs/dp/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..3c5e69a --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/repository/UserRepository.java @@ -0,0 +1,12 @@ +package com.JoinUs.dp.domain.user.repository; + +import com.JoinUs.dp.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + // [FIX] 이메일 전용 조회/중복체크 + Optional findByEmail(String email); // [UPDATED - TEAM SCHEMA] + boolean existsByEmail(String email); // [UPDATED - TEAM SCHEMA] +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/domain/user/service/AuthService.java b/src/main/java/com/JoinUs/dp/domain/user/service/AuthService.java new file mode 100644 index 0000000..06f07d9 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/service/AuthService.java @@ -0,0 +1,75 @@ +package com.JoinUs.dp.domain.user.service; + +import com.JoinUs.dp.domain.user.dto.request.RegisterDto; +import com.JoinUs.dp.domain.user.dto.response.TokenDto; +import com.JoinUs.dp.domain.user.entity.User; +import com.JoinUs.dp.domain.user.repository.UserRedisRepository; +import com.JoinUs.dp.domain.user.repository.UserRepository; +import com.JoinUs.dp.global.utility.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final UserRedisRepository userRedisRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + + public TokenDto login(String email, String password) { + // [FIX] findById(email) → findByEmail(email) + User user = userRepository.findByEmail(email) // [FIX] + .orElseThrow(() -> new RuntimeException("존재하지 않는 사용자입니다.")); + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new RuntimeException("비밀번호가 일치하지 않습니다."); + } + + String accessToken = jwtProvider.generateAccessToken(email); + String refreshToken = jwtProvider.generateRefreshToken(email); + + userRedisRepository.saveRefreshToken(email, refreshToken); + + return new TokenDto(accessToken, refreshToken); + } + + public void register(RegisterDto dto) { + // [FIX] existsById(dto.getEmail()) → existsByEmail(dto.getEmail()) + if (userRepository.existsByEmail(dto.getEmail())) { // [FIX] + throw new RuntimeException("이미 존재하는 이메일입니다."); + } + User user = User.builder() + .email(dto.getEmail()) + .password(passwordEncoder.encode(dto.getPassword())) + .username(dto.getUsername()) + .build(); + userRepository.save(user); + } + + public TokenDto refreshAccessToken(String refreshToken) { + if (!jwtProvider.validateToken(refreshToken)) { + throw new RuntimeException("유효하지 않은 리프레시 토큰입니다."); + } + + String email = jwtProvider.extractEmail(refreshToken); + + if (!userRedisRepository.verifyRefreshToken(email, refreshToken)) { + throw new RuntimeException("리프레시 토큰이 유효하지 않습니다."); + } + + String newAccessToken = jwtProvider.generateAccessToken(email); + String newRefreshToken = jwtProvider.generateRefreshToken(email); + + userRedisRepository.saveRefreshToken(email, newRefreshToken); + + return new TokenDto(newAccessToken, newRefreshToken); + } + + public void logout(String email) { + userRedisRepository.deleteRefreshToken(email); + } +} diff --git a/src/main/java/com/JoinUs/dp/global/common/ApiPath.java b/src/main/java/com/JoinUs/dp/global/common/ApiPath.java index 146dc83..fb6cf03 100644 --- a/src/main/java/com/JoinUs/dp/global/common/ApiPath.java +++ b/src/main/java/com/JoinUs/dp/global/common/ApiPath.java @@ -7,6 +7,7 @@ @Component public class ApiPath { public static final String API_ROOT = "/api/v1"; + public static final String AUTH_PATH = API_ROOT + "/auth"; // 도메인별 API_PATH 예시 @@ -21,4 +22,18 @@ public class ApiPath { public void init() { H2_PATH = h2Path; } + + // Clubs (면접 API) + public static final String CLUBS = API_ROOT + "/clubs"; + // 면접 회차 생성/조회 + public static final String CLUB_INTERVIEWS = CLUBS + "/{clubId}/interviews"; + // 신청자 면접 현황 조회 + public static final String CLUB_INTERVIEW_APPS = CLUBS + "/{clubId}/interview-applications"; + // 미확정/가입거절 인원 조회 + public static final String CLUB_VACANCIES = CLUBS + "/{clubId}/vacancies"; + + // Interviews (면접 API) + public static final String INTERVIEWS = API_ROOT + "/interviews"; + // 면접 신청 + public static final String INTERVIEW_APPLY = INTERVIEWS + "/{interviewId}/apply"; } diff --git a/src/main/java/com/JoinUs/dp/global/common/Index/IndexController.java b/src/main/java/com/JoinUs/dp/global/common/Index/IndexController.java new file mode 100644 index 0000000..6fb6831 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/global/common/Index/IndexController.java @@ -0,0 +1,15 @@ +package com.JoinUs.dp.global.common.Index; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/") +public class IndexController { + + @GetMapping + public String index() { + return "Hello World"; + } +} diff --git a/src/main/java/com/JoinUs/dp/global/config/RedisConfig.java b/src/main/java/com/JoinUs/dp/global/config/RedisConfig.java new file mode 100644 index 0000000..3604476 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/global/config/RedisConfig.java @@ -0,0 +1,23 @@ +package com.JoinUs.dp.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + // 직렬화 (문자열 기반) + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + + return template; + } +} diff --git a/src/main/java/com/JoinUs/dp/global/config/SecurityConfiguration.java b/src/main/java/com/JoinUs/dp/global/config/SecurityConfiguration.java index dc231ed..acd818b 100644 --- a/src/main/java/com/JoinUs/dp/global/config/SecurityConfiguration.java +++ b/src/main/java/com/JoinUs/dp/global/config/SecurityConfiguration.java @@ -1,5 +1,6 @@ package com.JoinUs.dp.global.config; +import com.JoinUs.dp.global.filter.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,6 +14,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @@ -20,6 +22,8 @@ @RequiredArgsConstructor public class SecurityConfiguration { + private final JwtAuthenticationFilter jwtFilter; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -33,11 +37,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(authorizeRequests -> - authorizeRequests.anyRequest().permitAll() + authorizeRequests + .anyRequest().permitAll() +// .requestMatchers(ApiPath.H2_PATH + "/**").permitAll() +// .requestMatchers(ApiPath.AUTH_PATH +"/register",ApiPath.AUTH_PATH +"/login",ApiPath.AUTH_PATH + "/refresh").permitAll() // 로그인, 토큰 재발급 허용 +// .anyRequest().authenticated() ) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } } diff --git a/src/main/java/com/JoinUs/dp/global/config/SwaggerConfig.java b/src/main/java/com/JoinUs/dp/global/config/SwaggerConfig.java new file mode 100644 index 0000000..a4f2787 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/global/config/SwaggerConfig.java @@ -0,0 +1,9 @@ +package com.JoinUs.dp.global.config; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springframework.context.annotation.Configuration; + +@SecurityScheme(name = "bearerAuth", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT") +@Configuration +class SwaggerConfig {} diff --git a/src/main/java/com/JoinUs/dp/global/dto/Response.java b/src/main/java/com/JoinUs/dp/global/dto/Response.java new file mode 100644 index 0000000..e126c29 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/global/dto/Response.java @@ -0,0 +1,21 @@ +package com.JoinUs.dp.global.dto; + +import lombok.Data; +import org.springframework.http.HttpStatus; + +import java.time.LocalDateTime; + +@Data +public class Response { + private int status; + private T data; + private String message; + private LocalDateTime timestamp; + + public Response(HttpStatus status, T data, String message) { + this.status = status.value(); + this.data = data; + this.message = message; + this.timestamp = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/global/dto/TokenResponse.java b/src/main/java/com/JoinUs/dp/global/dto/TokenResponse.java new file mode 100644 index 0000000..218154c --- /dev/null +++ b/src/main/java/com/JoinUs/dp/global/dto/TokenResponse.java @@ -0,0 +1,11 @@ +package com.JoinUs.dp.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TokenResponse { + private String access_token; + private String refresh_token; +} diff --git a/src/main/java/com/JoinUs/dp/global/filter/JwtAuthenticationFilter.java b/src/main/java/com/JoinUs/dp/global/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b62ed8d --- /dev/null +++ b/src/main/java/com/JoinUs/dp/global/filter/JwtAuthenticationFilter.java @@ -0,0 +1,48 @@ +package com.JoinUs.dp.global.filter; + +import com.JoinUs.dp.global.utility.JwtProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String token = resolveToken(request); + + if (token != null && jwtProvider.validateToken(token)) { + String email = jwtProvider.extractEmail(token); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(email, null, null); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (bearer != null && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/JoinUs/dp/global/utility/JwtProvider.java b/src/main/java/com/JoinUs/dp/global/utility/JwtProvider.java new file mode 100644 index 0000000..ac9459a --- /dev/null +++ b/src/main/java/com/JoinUs/dp/global/utility/JwtProvider.java @@ -0,0 +1,51 @@ +package com.JoinUs.dp.global.utility; + +import io.jsonwebtoken.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +public class JwtProvider { + + @Value("${jwt.secret}") + private String secretKey; + + private final long ACCESS_TOKEN_VALIDITY = 1000L * 60 * 30; // 30분 + private final long REFRESH_TOKEN_VALIDITY = 1000L * 60 * 60 * 24 * 7; // 7일 + + public String generateAccessToken(String email) { + return generateToken(email, ACCESS_TOKEN_VALIDITY); + } + + public String generateRefreshToken(String email) { + return generateToken(email, REFRESH_TOKEN_VALIDITY); + } + + private String generateToken(String email, long validity) { + return Jwts.builder() + .setSubject(email) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + validity)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + public String extractEmail(String token) { + return Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8b6f0c6..96f995b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,7 +2,18 @@ spring: application: name: dp - datasource: +# data: +# redis: +# host: redis +# port: 6379 +# + + data: + redis: + host: localhost + port: 6379 + + datasource: url: jdbc:h2:mem:test driver-class-name: org.h2.Driver username: sa @@ -17,3 +28,19 @@ spring: hibernate: ddl-auto: update show-sql: true + + mail: + host: smtp.gmail.com + port: 587 + username: your_email@gmail.com + password: your_app_password + properties: + mail: + smtp: + auth: true + starttls: + enable: true + timeout: 5000 + +jwt: + secret: 1234567890987654321JWTSECRET1234567890987654321 \ No newline at end of file