From 974d8224ca5e58df9f041f38405df141bab185c5 Mon Sep 17 00:00:00 2001 From: xoals1234 Date: Sun, 12 Oct 2025 05:18:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat/=EB=A9=B4=EC=A0=91=20API=20=EC=99=84?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 60 +++-- .../java/com/JoinUs/dp/domain/club/README.txt | 3 + .../JoinUs/dp/domain/club/entity/Club.java | 25 ++ .../club/repository/ClubRepository.java | 7 + .../JoinUs/dp/domain/interview/SetData.java | 152 +++++++++++ .../controller/InterviewController.java | 115 +++++++++ .../request/CreateInterviewRequestDto.java | 24 ++ .../dto/response/ApplicantResponseDto.java | 43 ++++ .../CheckDetailedInterviewResponseDto.java | 12 + .../response/CheckInterviewResponseDto.java | 12 + .../DetailedInterviewResponseDto.java | 21 ++ .../dto/response/InterviewResponseDto.java | 29 +++ .../response/UnconfirmedUserResponseDto.java | 13 + .../dp/domain/interview/entity/Applicant.java | 54 ++++ .../dp/domain/interview/entity/Interview.java | 60 +++++ .../entity/enums/InterviewConfirmedEnum.java | 6 + .../entity/enums/InterviewResultEnum.java | 6 + .../repository/ApplicantRepository.java | 43 ++++ .../repository/InterviewRepository.java | 12 + .../interview/service/InterviewService.java | 243 ++++++++++++++++++ .../com/JoinUs/dp/domain/jwt/dto/JwtDto.java | 7 + .../domain/mail/dto/RequestSendEmailDto.java | 13 + .../mail/dto/RequestVerificationCodeDto.java | 14 + .../mail/repository/MailRepository.java | 40 +++ .../dp/domain/mail/service/MailService.java | 90 +++++++ .../java/com/JoinUs/dp/domain/user/README.txt | 2 + .../user/controller/AuthController.java | 49 ++++ .../dp/domain/user/dto/request/LoginDto.java | 9 + .../user/dto/request/RefreshTokenDto.java | 8 + .../domain/user/dto/request/RegisterDto.java | 18 ++ .../dp/domain/user/dto/response/TokenDto.java | 11 + .../JoinUs/dp/domain/user/entity/User.java | 34 +++ .../user/repository/UserRedisRepository.java | 40 +++ .../user/repository/UserRepository.java | 9 + .../dp/domain/user/service/AuthService.java | 73 ++++++ .../com/JoinUs/dp/global/common/ApiPath.java | 15 ++ .../global/common/Index/IndexController.java | 15 ++ .../JoinUs/dp/global/config/RedisConfig.java | 23 ++ .../global/config/SecurityConfiguration.java | 14 +- .../dp/global/config/SwaggerConfig.java | 9 + .../com/JoinUs/dp/global/dto/Response.java | 21 ++ .../JoinUs/dp/global/dto/TokenResponse.java | 11 + .../filter/JwtAuthenticationFilter.java | 48 ++++ .../JoinUs/dp/global/utility/JwtProvider.java | 51 ++++ src/main/resources/application.yml | 29 ++- 45 files changed, 1567 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/JoinUs/dp/domain/club/README.txt create mode 100644 src/main/java/com/JoinUs/dp/domain/club/entity/Club.java create mode 100644 src/main/java/com/JoinUs/dp/domain/club/repository/ClubRepository.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/SetData.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/controller/InterviewController.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/dto/request/CreateInterviewRequestDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/dto/response/ApplicantResponseDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/dto/response/CheckDetailedInterviewResponseDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/dto/response/CheckInterviewResponseDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/dto/response/DetailedInterviewResponseDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/dto/response/InterviewResponseDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/dto/response/UnconfirmedUserResponseDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/entity/Applicant.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/entity/Interview.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/entity/enums/InterviewConfirmedEnum.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/entity/enums/InterviewResultEnum.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/repository/ApplicantRepository.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/repository/InterviewRepository.java create mode 100644 src/main/java/com/JoinUs/dp/domain/interview/service/InterviewService.java create mode 100644 src/main/java/com/JoinUs/dp/domain/jwt/dto/JwtDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/mail/dto/RequestSendEmailDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/mail/dto/RequestVerificationCodeDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/mail/repository/MailRepository.java create mode 100644 src/main/java/com/JoinUs/dp/domain/mail/service/MailService.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/README.txt create mode 100644 src/main/java/com/JoinUs/dp/domain/user/controller/AuthController.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/dto/request/LoginDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/dto/request/RefreshTokenDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/dto/request/RegisterDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/dto/response/TokenDto.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/entity/User.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/repository/UserRedisRepository.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/JoinUs/dp/domain/user/service/AuthService.java create mode 100644 src/main/java/com/JoinUs/dp/global/common/Index/IndexController.java create mode 100644 src/main/java/com/JoinUs/dp/global/config/RedisConfig.java create mode 100644 src/main/java/com/JoinUs/dp/global/config/SwaggerConfig.java create mode 100644 src/main/java/com/JoinUs/dp/global/dto/Response.java create mode 100644 src/main/java/com/JoinUs/dp/global/dto/TokenResponse.java create mode 100644 src/main/java/com/JoinUs/dp/global/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/JoinUs/dp/global/utility/JwtProvider.java 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..7aeccb9 --- /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..c85c60a --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/controller/InterviewController.java @@ -0,0 +1,115 @@ +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.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) 면접 회차 생성 + @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) 면접 회차 조회 + @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 " 제거 + } + + @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) 신청자 면접 현황 조회 + @GetMapping(ApiPath.CLUB_INTERVIEW_APPS) + public ResponseEntity> checkDetailed(@PathVariable Long clubId) { + return ResponseEntity.ok( + new Response<>( + HttpStatus.OK, + interviewService.checkDetailed(clubId), + "신청자 면접 현황 조회" + ) + ); + } + + // 5) 미확정/가입거절 인원 조회 + @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..2da5106 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/request/CreateInterviewRequestDto.java @@ -0,0 +1,24 @@ +package com.JoinUs.dp.domain.interview.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +// 면접 회차 생성 바디 dto +public record CreateInterviewRequestDto ( + @NotNull + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime startAt, + + @NotNull + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime endAt, + + @NotNull + String location, + + @NotNull + Long capacity +){ +} 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..c6e1968 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/ApplicantResponseDto.java @@ -0,0 +1,43 @@ +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.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, + + String userEmail, + String username, + + InterviewResultEnum interviewResult, + InterviewConfirmedEnum decided + +) { + // ApplicantEntity -> ApplicantResponseDto + public static ApplicantResponseDto from(Applicant applicant) { + return new ApplicantResponseDto( +// applicantEntity.getUser().getId(), + applicant.getAppliedAt(), + applicant.getUser().getEmail(), + applicant.getUser().getUsername(), + + 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..f0cd4b6 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/DetailedInterviewResponseDto.java @@ -0,0 +1,21 @@ +package com.JoinUs.dp.domain.interview.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; +import java.util.List; + +// 특정 면접 회차 상세 정보 (면접 신청자 정보 포합) dto +public record DetailedInterviewResponseDto( + Long interviewId, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime startAt, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime endAt, + String location, + Long capacity, + Long 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..58cc253 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/dto/response/InterviewResponseDto.java @@ -0,0 +1,29 @@ +package com.JoinUs.dp.domain.interview.dto.response; + +import com.JoinUs.dp.domain.interview.entity.Interview; +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; + +// 단일 면접 회차 정보 dto +public record InterviewResponseDto( + Long interviewId, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime startAt, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime endAt, + String location, + Long capacity, + Long applied +) { + public static InterviewResponseDto from(Interview interview) { + return new InterviewResponseDto( + interview.getId(), + interview.getStartAt(), + interview.getEndAt(), + interview.getLocation(), + interview.getCapacity(), + 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..7f5e0ac --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/entity/Applicant.java @@ -0,0 +1,54 @@ +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 +@Getter @Setter +public class Applicant { + protected Applicant() {} + + @Id + @GeneratedValue + private Long id; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime appliedAt; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(nullable = false) + private Interview interview; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(nullable = false) + private User user; + + + @Column + private InterviewResultEnum interviewResult; // 면접 현황 (PENDING, PASSED, FAILED) + @Column + private InterviewConfirmedEnum confirmStatus; // 가입 확정 여부 (PENDING, CONFIRMED, DECLINED) + + // 생성자 + // interviewResult 는 PENDING(대기), confirmStatus 는 PENDING 로 초기화 됨 + public Applicant( + Interview interview, + User user + ) { + this.interview = interview; + this.user = user; + 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..766937f --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/entity/Interview.java @@ -0,0 +1,60 @@ +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.LocalDateTime; + +@Entity +@Getter @Setter +public class Interview { + protected Interview() {} + + @Id + @GeneratedValue + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(nullable = false) + private Club club; + +// @Column +// private Long clubId; // 어느 동아리에 속하는지 확인용 + + @Column + private LocalDateTime startAt; // 면접 시작 시간 (yyyy-MM-ddTHH:mm) + @Column + private LocalDateTime endAt; // 면접 종료 시간 (yyyy-MM-ddTHH:mm) + @Column + private String location; // 면접 장소 + + @Column + private Long capacity; // 면접 가능 인원 + @Column + private Long applied; // 현재까지 신청 인원 + + // 생성자 + // applied 는 0으로 초기화됨 + public Interview( + Club club, + LocalDateTime startAt, + LocalDateTime endAt, + String location, + Long capacity + ) { + this.club = club; + this.startAt = startAt; + this.endAt = endAt; + this.location = location; + this.capacity = capacity; + this.applied = 0L; + } + + // 신청 인원 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..35bae39 --- /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 existsByUser_EmailAndInterview_Club_Id(String email, Long clubId); + + // 특정 면접 회차에 지원한 모든 유저(신청자) 목록 + // 면접 현황 조회 기능에 사용됨 + List findAllByInterview_Id(Long interviewId); + + // 특정 동아리에 합격한 유저(신청자)들의 목록. 동아리Id, 합격여부, 확정여부 로 필터링 됨 + // 미확정/가입거절 인원 조회에 사용됨 + List findAllByInterview_Club_IdAndInterviewResultAndConfirmStatus( + Long clubId, + InterviewResultEnum interviewResult, + InterviewConfirmedEnum confirmStatus + ); + + // clubId 동아리에 신청한 유저 수 조회 + Long countByInterview_Club_Id(Long clubId); + + // 미확정/가입거절, 거절 인원수 조회 + Long countByInterview_Club_IdAndInterviewResultAndConfirmStatus( + 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..114c847 --- /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 findAllByClub_Id(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..5ded279 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/interview/service/InterviewService.java @@ -0,0 +1,243 @@ +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.startAt().isAfter(req.endAt())) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "잘못된 시간 설정" + ); + } + + // 데이터베이스 속 생성된 모든 면접 회차와 POST 한 면접 회차 비교 + // 시간과 장소가 겹칠때 에러 발생 + List interviews = interviewRepository.findAll(); + for (Interview interview : interviews) { + if (req.startAt().isBefore(interview.getEndAt()) && req.endAt().isAfter(interview.getStartAt())) { + if (interview.getLocation().equals(req.location())) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "(요청: " + club.getName() + ", 비교: " + interview.getClub().getName() + ") 면접 시간, 장소 겹침" + ); + } + } + } + + // 면접 회차 생성 + Interview createdInterview = new Interview( + club, + req.startAt(), + req.endAt(), + req.location(), + req.capacity() + ); + + // 생성된 면접 회차 저장 + 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.findAllByClub_Id(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.findById(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "email: " + email + " 를 찾을 수 없음")); + + // 면접 회차 조회 + Interview interview = interviewRepository.findById(interviewId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "interviewId: " + interviewId + " 를 찾을 수 없음")); + + // 중복 신청 체크 + if (applicantRepository.existsByUser_EmailAndInterview_Club_Id(user.getEmail(), interview.getClub().getId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "동일 club 중복 신청"); + } + + // 정원 체크 + if (interview.getApplied() >= interview.getCapacity()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "신청 인원 초과"); + } + + // 카운트 증가 및 저장 + interview.increaseApplied(); + applicantRepository.save(new Applicant( + interview, + user + )); + } + + // 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.findAllByClub_Id(clubId); + + // 각 회차별로 해당 회차별 신청자 정보까지 합친 Dto 구성 + List detailedInterviewResponse = interviews.stream() + .map(interviewEntity -> { + List applicants = applicantRepository.findAllByInterview_Id(interviewEntity.getId()); + return new DetailedInterviewResponseDto( + interviewEntity.getId(), + interviewEntity.getStartAt(), + interviewEntity.getEndAt(), + interviewEntity.getLocation(), + interviewEntity.getCapacity(), + 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.countByInterview_Club_Id(clubId); + + // confirmStatus 가 아직 기본값인 PENDING(대기) 인 유저들의 수 카운팅 + Long pendingUserCount = applicantRepository.countByInterview_Club_IdAndInterviewResultAndConfirmStatus( + clubId, + InterviewResultEnum.FAILED, + InterviewConfirmedEnum.PENDING + ); + + // confirmStatus 가 아직 기본값인 PENDING(대기) 상태인 유저들을 찾은 후 + // 면접신청 유저 entity 리스트 -> 면접신청 유저 dto 리스트 로 변환 + List pendingUsers = ApplicantResponseDto.fromList( + applicantRepository.findAllByInterview_Club_IdAndInterviewResultAndConfirmStatus( + 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..b50039d --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/entity/User.java @@ -0,0 +1,34 @@ +package com.JoinUs.dp.domain.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +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 + private String email; + + @Column + private String password; + + @Column + 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..073fd78 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/repository/UserRepository.java @@ -0,0 +1,9 @@ +package com.JoinUs.dp.domain.user.repository; + +import com.JoinUs.dp.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + // 테스트 데이터 생성용 코드 (domain.interview.SetData 파일 삭제시 삭제해도됨) + User findByUsername(String userName); +} 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..f8b2268 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/domain/user/service/AuthService.java @@ -0,0 +1,73 @@ +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) { + User user = userRepository.findById(email) + .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) { + if (userRepository.existsById(dto.getEmail())) { + 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 From fa8b7a0a0e105e820ecb06f1ceab00ef3b78a546 Mon Sep 17 00:00:00 2001 From: xoals1234 Date: Mon, 10 Nov 2025 00:15:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JoinUs/dp/domain/interview/SetData.java | 128 +++++++++--------- .../controller/InterviewController.java | 7 + .../request/CreateInterviewRequestDto.java | 22 ++- .../dto/response/ApplicantResponseDto.java | 13 +- .../DetailedInterviewResponseDto.java | 13 +- .../dto/response/InterviewResponseDto.java | 24 ++-- .../dp/domain/interview/entity/Applicant.java | 35 +++-- .../dp/domain/interview/entity/Interview.java | 64 +++++---- .../repository/ApplicantRepository.java | 12 +- .../repository/InterviewRepository.java | 2 +- .../interview/service/InterviewService.java | 49 ++++--- .../JoinUs/dp/domain/user/entity/User.java | 13 +- .../user/repository/UserRepository.java | 11 +- .../dp/domain/user/service/AuthService.java | 6 +- 14 files changed, 226 insertions(+), 173 deletions(-) diff --git a/src/main/java/com/JoinUs/dp/domain/interview/SetData.java b/src/main/java/com/JoinUs/dp/domain/interview/SetData.java index 7aeccb9..77a392b 100644 --- a/src/main/java/com/JoinUs/dp/domain/interview/SetData.java +++ b/src/main/java/com/JoinUs/dp/domain/interview/SetData.java @@ -80,73 +80,73 @@ private void ClubDataSeeding(){ 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); - } -} +// 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); +// 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 index c85c60a..9cf5ca1 100644 --- a/src/main/java/com/JoinUs/dp/domain/interview/controller/InterviewController.java +++ b/src/main/java/com/JoinUs/dp/domain/interview/controller/InterviewController.java @@ -10,6 +10,7 @@ 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; @@ -26,6 +27,7 @@ public class InterviewController { private final JwtProvider jwtProvider; // 1) 면접 회차 생성 + @Operation(summary = "면접 회차 생성") @PostMapping(ApiPath.CLUB_INTERVIEWS) public ResponseEntity> create( @PathVariable Long clubId, @@ -41,6 +43,7 @@ public ResponseEntity> create( } // 2) 면접 회차 조회 + @Operation(summary = "면접 회차 조회") @GetMapping(ApiPath.CLUB_INTERVIEWS) public ResponseEntity> check(@PathVariable Long clubId) { return ResponseEntity.ok( @@ -68,6 +71,8 @@ private String resolveBearer(String authorization) { return authorization.substring(7); // "Bearer " 제거 } + // 3) 면접 신청 + @Operation(summary = "면접 신청") @SecurityRequirement(name = "bearerAuth") @PostMapping("/interviews/{interviewId}/apply") public void apply(@PathVariable Long interviewId, HttpServletRequest request) { @@ -90,6 +95,7 @@ public void apply(@PathVariable Long interviewId, HttpServletRequest request) { // 4) 신청자 면접 현황 조회 + @Operation(summary = "신청자 면접 현황 조회") @GetMapping(ApiPath.CLUB_INTERVIEW_APPS) public ResponseEntity> checkDetailed(@PathVariable Long clubId) { return ResponseEntity.ok( @@ -102,6 +108,7 @@ public ResponseEntity> checkDetailed } // 5) 미확정/가입거절 인원 조회 + @Operation(summary = "미확정/가입거절 인원 조회") @GetMapping(ApiPath.CLUB_VACANCIES) public ResponseEntity> checkUnconfirmedUser(@PathVariable Long clubId) { return ResponseEntity.ok( 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 index 2da5106..45a3f18 100644 --- 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 @@ -1,24 +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 - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") - LocalDateTime startAt, + LocalDate date, @NotNull - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") - LocalDateTime endAt, + @Schema(type = "string", format = "time", example = "01:30") + LocalTime startTime, @NotNull - String location, + @Schema(type = "string", format = "time", example = "02:00") + LocalTime endTime, @NotNull - Long capacity + 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 index c6e1968..76202b7 100644 --- 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 @@ -3,6 +3,8 @@ 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; @@ -10,13 +12,11 @@ // 면접 신청자 정보 응답용 dto public record ApplicantResponseDto( -// Long userId, + Long userId, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") LocalDateTime appliedAt, - String userEmail, - String username, - InterviewResultEnum interviewResult, InterviewConfirmedEnum decided @@ -24,11 +24,8 @@ public record ApplicantResponseDto( // ApplicantEntity -> ApplicantResponseDto public static ApplicantResponseDto from(Applicant applicant) { return new ApplicantResponseDto( -// applicantEntity.getUser().getId(), + applicant.getUserId(), applicant.getAppliedAt(), - applicant.getUser().getEmail(), - applicant.getUser().getUsername(), - applicant.getInterviewResult(), applicant.getConfirmStatus() ); 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 index f0cd4b6..3ca5771 100644 --- 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 @@ -2,19 +2,20 @@ 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, - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") - LocalDateTime startAt, - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") - LocalDateTime endAt, + LocalDate date, + LocalTime startTime, + LocalTime endTime, String location, - Long capacity, - Long applied, + 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 index 58cc253..58a24a7 100644 --- 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 @@ -1,28 +1,30 @@ package com.JoinUs.dp.domain.interview.dto.response; import com.JoinUs.dp.domain.interview.entity.Interview; -import com.fasterxml.jackson.annotation.JsonFormat; -import java.time.LocalDateTime; +import java.time.LocalDate; +import java.time.LocalTime; // 단일 면접 회차 정보 dto public record InterviewResponseDto( Long interviewId, - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") - LocalDateTime startAt, - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") - LocalDateTime endAt, String location, - Long capacity, - Long applied + LocalDate date, + LocalTime startTime, + LocalTime endTime, + int duration, + int maxApplicants, + int applied ) { public static InterviewResponseDto from(Interview interview) { return new InterviewResponseDto( interview.getId(), - interview.getStartAt(), - interview.getEndAt(), interview.getLocation(), - interview.getCapacity(), + interview.getDate(), + interview.getStartTime(), + interview.getEndTime(), + interview.getDuration(), + interview.getMaxApplicants(), interview.getApplied() ); } 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 index 7f5e0ac..11d42f6 100644 --- a/src/main/java/com/JoinUs/dp/domain/interview/entity/Applicant.java +++ b/src/main/java/com/JoinUs/dp/domain/interview/entity/Applicant.java @@ -12,40 +12,47 @@ 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; - @CreationTimestamp - @Column(nullable = false, updatable = false) - private LocalDateTime appliedAt; + @Column(name = "user_id", nullable = false) + private Long userId; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(nullable = false) - private Interview interview; + @Column(name = "interview_id", nullable = false) + private Long interviewId; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(nullable = false) - private User user; + @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( - Interview interview, - User user - ) { - this.interview = interview; - this.user = user; + 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 index 766937f..8cde46e 100644 --- a/src/main/java/com/JoinUs/dp/domain/interview/entity/Interview.java +++ b/src/main/java/com/JoinUs/dp/domain/interview/entity/Interview.java @@ -6,51 +6,69 @@ 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; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(nullable = false) - private Club club; + @Column(name = "club_id", nullable = false) + private Long clubId; -// @Column -// private Long clubId; // 어느 동아리에 속하는지 확인용 + @Column(name = "date", nullable = false) + private LocalDate date; - @Column - private LocalDateTime startAt; // 면접 시작 시간 (yyyy-MM-ddTHH:mm) - @Column - private LocalDateTime endAt; // 면접 종료 시간 (yyyy-MM-ddTHH:mm) - @Column - private String location; // 면접 장소 + @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 Long capacity; // 면접 가능 인원 + private int applied; @Column - private Long applied; // 현재까지 신청 인원 + private String location; + + // 생성자 // applied 는 0으로 초기화됨 public Interview( - Club club, - LocalDateTime startAt, - LocalDateTime endAt, - String location, - Long capacity + Long clubId, + LocalDate date, + LocalTime startTime, + LocalTime endTime, + int duration, + int maxApplicants, + + String location ) { - this.club = club; - this.startAt = startAt; - this.endAt = endAt; + this.clubId = clubId; + this.date = date; + this.startTime = startTime; + this.endTime = endTime; + this.duration = duration; + this.maxApplicants = maxApplicants; + + this.applied = 0; this.location = location; - this.capacity = capacity; - this.applied = 0L; } // 신청 인원 1 증가 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 index 35bae39..d64486f 100644 --- a/src/main/java/com/JoinUs/dp/domain/interview/repository/ApplicantRepository.java +++ b/src/main/java/com/JoinUs/dp/domain/interview/repository/ApplicantRepository.java @@ -12,25 +12,25 @@ public interface ApplicantRepository extends JpaRepository { // 특정 유저가 특정 동아리에 이미 지원했는지 조회 // 면접 중복 신청 방지에 사용됨 - boolean existsByUser_EmailAndInterview_Club_Id(String email, Long clubId); + boolean existsByUserIdAndClubId(Long userId, Long clubId); // 특정 면접 회차에 지원한 모든 유저(신청자) 목록 // 면접 현황 조회 기능에 사용됨 - List findAllByInterview_Id(Long interviewId); + List findAllByInterviewId(Long interviewId); // 특정 동아리에 합격한 유저(신청자)들의 목록. 동아리Id, 합격여부, 확정여부 로 필터링 됨 // 미확정/가입거절 인원 조회에 사용됨 - List findAllByInterview_Club_IdAndInterviewResultAndConfirmStatus( + List findAllByClubIdAndInterviewResultAndConfirmStatus( Long clubId, InterviewResultEnum interviewResult, InterviewConfirmedEnum confirmStatus ); // clubId 동아리에 신청한 유저 수 조회 - Long countByInterview_Club_Id(Long clubId); + Long countByClubId(Long clubId); // 미확정/가입거절, 거절 인원수 조회 - Long countByInterview_Club_IdAndInterviewResultAndConfirmStatus( + Long countByClubIdAndInterviewResultAndConfirmStatus( Long clubId, InterviewResultEnum interviewResult, InterviewConfirmedEnum confirmStatus @@ -38,6 +38,6 @@ Long countByInterview_Club_IdAndInterviewResultAndConfirmStatus( // 테스트 데이터 생성용 코드(domain.interview.SetData 파일 삭제시 삭제해도됨) - Applicant findByUser_Username(String username); +// 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 index 114c847..19c777c 100644 --- a/src/main/java/com/JoinUs/dp/domain/interview/repository/InterviewRepository.java +++ b/src/main/java/com/JoinUs/dp/domain/interview/repository/InterviewRepository.java @@ -8,5 +8,5 @@ public interface InterviewRepository extends JpaRepository { // clubId 로 모든 면접 회차 조회 // 면접 회차 조회 및 면접 현황 조회 기능에 사용됨 - List findAllByClub_Id(Long 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 index 5ded279..1cf1804 100644 --- a/src/main/java/com/JoinUs/dp/domain/interview/service/InterviewService.java +++ b/src/main/java/com/JoinUs/dp/domain/interview/service/InterviewService.java @@ -44,7 +44,7 @@ public InterviewResponseDto create(Long clubId, CreateInterviewRequestDto req) { // 시작 시간이 종료 시간보다 늦은 시간으로 주어졌을 때 에러 발생 // 예시) startAt: 10:00, endAt: 9:00 -> 에러 - if (req.startAt().isAfter(req.endAt())) { + if (req.startTime().isAfter(req.endTime())) { throw new ResponseStatusException( HttpStatus.CONFLICT, "잘못된 시간 설정" @@ -55,11 +55,12 @@ public InterviewResponseDto create(Long clubId, CreateInterviewRequestDto req) { // 시간과 장소가 겹칠때 에러 발생 List interviews = interviewRepository.findAll(); for (Interview interview : interviews) { - if (req.startAt().isBefore(interview.getEndAt()) && req.endAt().isAfter(interview.getStartAt())) { + 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() + ", 비교: " + interview.getClub().getName() + ") 면접 시간, 장소 겹침" + "(요청: " + club.getName() + ", 비교: " + creatingInterviewClub.getName() + ") 면접 시간, 장소 겹침" ); } } @@ -67,11 +68,13 @@ public InterviewResponseDto create(Long clubId, CreateInterviewRequestDto req) { // 면접 회차 생성 Interview createdInterview = new Interview( - club, - req.startAt(), - req.endAt(), - req.location(), - req.capacity() + clubId, + req.date(), + req.startTime(), + req.endTime(), + req.duration(), + req.maxApplicants(), + req.location() ); // 생성된 면접 회차 저장 @@ -94,7 +97,7 @@ public CheckInterviewResponseDto check(Long clubId) { ); // clubId 동아리 내의 모든 면접 회차 조회 - List interviews = interviewRepository.findAllByClub_Id(clubId); + List interviews = interviewRepository.findAllByClubId(clubId); // InterviewEntity (entity) -> InterviewResponseDto (dto) 로 변환 List interviewsResponse = interviews.stream() .map(interviewEntity -> { @@ -122,7 +125,7 @@ public void apply(Long interviewId, String email) { // String email = jwtProvider.extractEmail(token); // 이메일로 유저 조회 - User user = userRepository.findById(email) + User user = userRepository.findByEmail(email) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "email: " + email + " 를 찾을 수 없음")); // 면접 회차 조회 @@ -130,20 +133,21 @@ public void apply(Long interviewId, String email) { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "interviewId: " + interviewId + " 를 찾을 수 없음")); // 중복 신청 체크 - if (applicantRepository.existsByUser_EmailAndInterview_Club_Id(user.getEmail(), interview.getClub().getId())) { + if (applicantRepository.existsByUserIdAndClubId(user.getId(), interview.getClubId())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "동일 club 중복 신청"); } // 정원 체크 - if (interview.getApplied() >= interview.getCapacity()) { + if (interview.getApplied() >= interview.getMaxApplicants()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "신청 인원 초과"); } // 카운트 증가 및 저장 interview.increaseApplied(); applicantRepository.save(new Applicant( - interview, - user + user.getId(), + interviewId, + interview.getClubId() )); } @@ -159,18 +163,19 @@ public CheckDetailedInterviewResponseDto checkDetailed(Long clubId) { ); // clubId 동아리 내의 모든 면접 회차 조회 - List interviews = interviewRepository.findAllByClub_Id(clubId); + List interviews = interviewRepository.findAllByClubId(clubId); // 각 회차별로 해당 회차별 신청자 정보까지 합친 Dto 구성 List detailedInterviewResponse = interviews.stream() .map(interviewEntity -> { - List applicants = applicantRepository.findAllByInterview_Id(interviewEntity.getId()); + List applicants = applicantRepository.findAllByInterviewId(interviewEntity.getId()); return new DetailedInterviewResponseDto( interviewEntity.getId(), - interviewEntity.getStartAt(), - interviewEntity.getEndAt(), + interviewEntity.getDate(), + interviewEntity.getStartTime(), + interviewEntity.getEndTime(), interviewEntity.getLocation(), - interviewEntity.getCapacity(), + interviewEntity.getMaxApplicants(), interviewEntity.getApplied(), ApplicantResponseDto.fromList(applicants) ); @@ -198,10 +203,10 @@ public UnconfirmedUserResponseDto checkUnconfirmedUser(Long clubId) { ); // clubId 동아리에 신청한 신청자들의 수 카운팅 - Long applicantCount = applicantRepository.countByInterview_Club_Id(clubId); + Long applicantCount = applicantRepository.countByClubId(clubId); // confirmStatus 가 아직 기본값인 PENDING(대기) 인 유저들의 수 카운팅 - Long pendingUserCount = applicantRepository.countByInterview_Club_IdAndInterviewResultAndConfirmStatus( + Long pendingUserCount = applicantRepository.countByClubIdAndInterviewResultAndConfirmStatus( clubId, InterviewResultEnum.FAILED, InterviewConfirmedEnum.PENDING @@ -210,7 +215,7 @@ public UnconfirmedUserResponseDto checkUnconfirmedUser(Long clubId) { // confirmStatus 가 아직 기본값인 PENDING(대기) 상태인 유저들을 찾은 후 // 면접신청 유저 entity 리스트 -> 면접신청 유저 dto 리스트 로 변환 List pendingUsers = ApplicantResponseDto.fromList( - applicantRepository.findAllByInterview_Club_IdAndInterviewResultAndConfirmStatus( + applicantRepository.findAllByClubIdAndInterviewResultAndConfirmStatus( clubId, InterviewResultEnum.FAILED, InterviewConfirmedEnum.PENDING 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 index b50039d..f4ee601 100644 --- a/src/main/java/com/JoinUs/dp/domain/user/entity/User.java +++ b/src/main/java/com/JoinUs/dp/domain/user/entity/User.java @@ -1,9 +1,6 @@ package com.JoinUs.dp.domain.user.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -20,12 +17,16 @@ @Table(name="users") public class User { @Id + @GeneratedValue + private Long id; + + @Column(nullable = false, unique = true) private String email; - @Column + @Column(nullable = false) private String password; - @Column + @Column(nullable = false) private String username; @Column 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 index 073fd78..3c5e69a 100644 --- a/src/main/java/com/JoinUs/dp/domain/user/repository/UserRepository.java +++ b/src/main/java/com/JoinUs/dp/domain/user/repository/UserRepository.java @@ -2,8 +2,11 @@ import com.JoinUs.dp.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; -public interface UserRepository extends JpaRepository { - // 테스트 데이터 생성용 코드 (domain.interview.SetData 파일 삭제시 삭제해도됨) - User findByUsername(String userName); -} +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 index f8b2268..06f07d9 100644 --- a/src/main/java/com/JoinUs/dp/domain/user/service/AuthService.java +++ b/src/main/java/com/JoinUs/dp/domain/user/service/AuthService.java @@ -21,7 +21,8 @@ public class AuthService { private final JwtProvider jwtProvider; public TokenDto login(String email, String password) { - User user = userRepository.findById(email) + // [FIX] findById(email) → findByEmail(email) + User user = userRepository.findByEmail(email) // [FIX] .orElseThrow(() -> new RuntimeException("존재하지 않는 사용자입니다.")); if (!passwordEncoder.matches(password, user.getPassword())) { @@ -37,7 +38,8 @@ public TokenDto login(String email, String password) { } public void register(RegisterDto dto) { - if (userRepository.existsById(dto.getEmail())) { + // [FIX] existsById(dto.getEmail()) → existsByEmail(dto.getEmail()) + if (userRepository.existsByEmail(dto.getEmail())) { // [FIX] throw new RuntimeException("이미 존재하는 이메일입니다."); } User user = User.builder()