From cb7102d1ea7f422f65ed8a2c0a526bb6e4a98f87 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 5 Sep 2025 03:55:51 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20S3=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC,=20=EB=B3=B4=EC=95=88=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EB=B3=B4=EC=99=84=20=EB=B0=8F=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 16 +++- .../gitdeun/common/config/SecurityPath.java | 5 +- .../s3/controller/S3BucketController.java | 6 -- .../common/s3/service/S3BucketService.java | 89 ++++++++++++++----- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/build.gradle b/build.gradle index a899501..089e5f4 100644 --- a/build.gradle +++ b/build.gradle @@ -81,13 +81,17 @@ dependencies { implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:${springCloudAwsVersion}") implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + // 파일의 매직 바이트 검사용 Apache Tika 라이브러리 + implementation 'org.apache.tika:tika-core:3.2.2' + // queryDsl implementation "com.querydsl:querydsl-core:${queryDslVersion}" implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" annotationProcessor ( "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta", + 'jakarta.annotation:jakarta.annotation-api', // JPA 메타모델 생성용 - "jakarta.persistence:jakarta.persistence-api:3.1.0" + 'jakarta.persistence:jakarta.persistence-api' ) // db @@ -117,12 +121,18 @@ dependencies { /** Q 클래스 생성 경로 지정 **/ def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile +// gradle clean 시에 QClass 디렉토리 삭제 +clean { + delete file(querydslDir) +} + +// 🔧 소스셋에 generated 디렉토리 추가 sourceSets { - main { java.srcDirs += querydslDir } + main.java.srcDirs += [ querydslDir ] } tasks.withType(JavaCompile).configureEach { - options.annotationProcessorGeneratedSourcesDirectory = querydslDir + options.generatedSourceOutputDirectory.set(querydslDir) } tasks.named('test') { diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java index feaf6eb..24d0f9d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -24,8 +24,11 @@ public class SecurityPath { "/api/history/**", "/api/invitations/**", "/api/notifications/**", + "/api/skills/**", + "/api/recruitments/**", + "/api/applications/**", "/api/s3/bucket/**", - "/api/proxy/**" + "/api/webhook/**" }; // hasRole("ADMIN") diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java index de56ba0..b349f85 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java @@ -21,18 +21,12 @@ public class S3BucketController { private final S3BucketService s3BucketService; - private static final int MAX_FILE_COUNT = 10; @PostMapping("/upload") public ResponseEntity> uploadFiles( @RequestParam("files") List files, @RequestParam("path") String path ) { - // FILE-005: 업로드 가능한 파일 개수를 초과했습니다. - if (files.size() > MAX_FILE_COUNT) { - throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); - } - List fileUrls = s3BucketService.upload(files, path); return ResponseEntity.ok(fileUrls); } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java index 6ed3ec5..3554e71 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java @@ -5,6 +5,7 @@ import io.awspring.cloud.s3.S3Resource; import io.awspring.cloud.s3.S3Template; import lombok.RequiredArgsConstructor; +import org.apache.tika.Tika; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -12,8 +13,11 @@ import software.amazon.awssdk.core.exception.SdkException; import java.io.IOException; +import java.io.InputStream; +import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; @Service @@ -21,32 +25,41 @@ public class S3BucketService { private final S3Template s3Template; + private final Tika tika = new Tika(); @Value("${cloud.aws.s3.bucket.name}") private String bucketName; + // 이미지 및 파일 업로드 제약 조건 + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static final int MAX_FILE_COUNT = 5; + private static final Set ALLOWED_IMAGE_TYPES = Set.of("image/jpeg", "image/png", "image/gif"); + private static final Set ALLOWED_DOCUMENT_TYPES = Set.of("application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + public List upload(List files, String path) { - // FILE-002: 파일 목록이 비어있거나 유효하지 않습니다. + // 1. 파일 목록이 비어있는지 확인 if (files == null || files.stream().allMatch(MultipartFile::isEmpty)) { throw new GlobalException(ErrorCode.INVALID_FILE_LIST); } + // 2. 파일 개수 제한 확인 + if (files.size() > MAX_FILE_COUNT) { + throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); + } + List uploadedUrls = new ArrayList<>(); for (MultipartFile file : files) { - if (file.isEmpty()) continue; + try (InputStream inputStream = file.getInputStream()) { + // 3. 각 파일에 대해 크기 및 실제 타입(매직 바이트) 검증 + validateSingleFile(file.getSize(), inputStream); - if (!isValidFileType(file.getOriginalFilename())) { - // FILE-004: 지원하지 않는 파일 형식입니다. - throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); - } + String fullPath = generateValidPath(path) + createUniqueFileName(file.getOriginalFilename()); - String fullPath = generateValidPath(path) + createUniqueFileName(file.getOriginalFilename()); - - try { + // 4. S3에 업로드 (주의: 검증 시 사용한 inputStream을 재사용할 수 없으므로, S3Template이 내부적으로 새 스트림을 열도록 file을 전달) S3Resource s3Resource = s3Template.upload(bucketName, fullPath, file.getInputStream()); uploadedUrls.add(s3Resource.getURL().toString()); + } catch (IOException | SdkException e) { - // FILE-501: 파일 업로드 중 서버 오류가 발생했습니다. throw new GlobalException(ErrorCode.FILE_UPLOAD_FAILED); } } @@ -92,10 +105,10 @@ public S3Resource download(String url) { private String extractKeyFromUrl(String url) { try { - String urlPrefix = "https://" + bucketName + ".s3."; - int startIndex = url.indexOf(urlPrefix); - int keyStartIndex = url.indexOf('/', startIndex + urlPrefix.length()); - return url.substring(keyStartIndex + 1); + URI uri = new URI(url); + String path = uri.getPath(); + // 맨 앞의 '/'를 제거하여 순수한 객체 키만 반환 + return path.substring(1); } catch (Exception e) { // FILE-007: S3 URL 형식이 올바르지 않습니다. throw new GlobalException(ErrorCode.INVALID_S3_URL); @@ -106,10 +119,15 @@ private String generateValidPath(String path) { if (path == null || path.trim().isEmpty()) { return ""; } - if (path.contains("..")) { + // 경로에 허용된 문자(영문, 숫자, 슬래시, 하이픈, 언더스코어) 외에 다른 문자가 있는지 확인 + if (!path.matches("^[a-zA-Z0-9/_-]+$")) { // FILE-003: 파일 경로나 이름이 유효하지 않습니다. throw new GlobalException(ErrorCode.INVALID_FILE_PATH); } + // ".." 문자열 체크는 유지 + if (path.contains("..")) { + throw new GlobalException(ErrorCode.INVALID_FILE_PATH); + } return path.replaceAll("^/+|/+$", "") + "/"; } @@ -118,10 +136,41 @@ private String createUniqueFileName(String originalFileName) { return UUID.randomUUID() + "." + extension; } - private boolean isValidFileType(String filename) { - if (filename == null) return false; - String extension = StringUtils.getFilenameExtension(filename.toLowerCase()); - List allowedExtensions = List.of("jpg", "jpeg", "png", "gif", "pdf", "docs"); // 허용 확장자 - return allowedExtensions.contains(extension); + private void validateSingleFile(long fileSize, InputStream inputStream) { + // 파일 크기 검증 + if (fileSize > MAX_FILE_SIZE) { + throw new GlobalException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + try { + // Tika를 사용하여 InputStream에서 실제 MIME 타입 감지 + String actualMimeType = tika.detect(inputStream); + + // 허용된 이미지 또는 문서 타입이 아니면 예외 발생 + if (actualMimeType == null || (!ALLOWED_IMAGE_TYPES.contains(actualMimeType) && !ALLOWED_DOCUMENT_TYPES.contains(actualMimeType))) { + throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); + } + } catch (IOException e) { + // 스트림 읽기 중 오류 발생 시 + throw new GlobalException(ErrorCode.FILE_UPLOAD_FAILED); + } } + + /*private void validateFiles(List files) { + if (files.size() > MAX_FILE_COUNT) { + throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); + } + + for (MultipartFile file : files) { + if (file.getSize() > MAX_FILE_SIZE) { + throw new GlobalException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + String contentType = file.getContentType(); + // 이미지 또는 문서 타입에 해당하지 않으면 예외 발생 + if (contentType == null || (!ALLOWED_IMAGE_TYPES.contains(contentType) && !ALLOWED_DOCUMENT_TYPES.contains(contentType))) { + throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); + } + } + }*/ } \ No newline at end of file From 52ae1e8024f7b021211dd21ad8180c176cfa46a8 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 5 Sep 2025 03:56:55 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Application=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=82=B4=20UserSkill=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/controller/ApplicationController.java | 4 ++++ .../Application/repository/ApplicationRepository.java | 9 +++++++++ .../gitdeun/Application/service/ApplicationService.java | 4 ++++ src/main/java/com/teamEWSN/gitdeun/user/entity/User.java | 6 ++++++ 4 files changed, 23 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java b/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java new file mode 100644 index 0000000..3094c23 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java @@ -0,0 +1,4 @@ +package com.teamEWSN.gitdeun.Application.controller; + +public class ApplicationController { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java b/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java new file mode 100644 index 0000000..8173c62 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.Application.repository; + +import com.teamEWSN.gitdeun.Application.entity.Application; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ApplicationRepository extends JpaRepository { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java b/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java new file mode 100644 index 0000000..ce1c652 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java @@ -0,0 +1,4 @@ +package com.teamEWSN.gitdeun.Application.service; + +public class ApplicationService { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index c9e9dde..5811d2d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -2,12 +2,14 @@ import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.userskill.entity.UserSkill; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Set; @Entity @Getter @@ -38,6 +40,10 @@ public class User extends AuditedEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List socialConnections = new ArrayList<>(); + // 사용자 기술 + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private Set skills; + @Column(name = "deleted_at") private LocalDateTime deletedAt; From 51a240da33dd48b417fef855fb654c911210de9d Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 5 Sep 2025 12:42:45 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=AA=A8=EC=A7=91=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RecruitmentController.java | 139 +++++++ .../dto/RecruitmentCreateRequestDto.java | 44 +++ .../dto/RecruitmentDetailResponseDto.java | 36 ++ .../Recruitment/dto/RecruitmentImageDto.java | 12 + .../dto/RecruitmentListResponseDto.java | 32 ++ .../dto/RecruitmentUpdateRequestDto.java | 42 +++ .../Recruitment/entity/Recruitment.java | 32 +- .../Recruitment/entity/RecruitmentStatus.java | 7 +- .../mapper/RecruitmentImageMapper.java | 19 + .../Recruitment/mapper/RecruitmentMapper.java | 72 ++++ .../RecruitmentCustomRepository.java | 19 + .../RecruitmentImageRepository.java | 14 + .../repository/RecruitmentRepository.java | 26 ++ .../repository/RecruitmentRepositoryImpl.java | 65 ++++ .../RecruitmentRequiredSkillRepository.java | 9 + .../service/RecruitmentSchedulingService.java | 42 +++ .../service/RecruitmentService.java | 349 ++++++++++++++++++ .../gitdeun/common/exception/ErrorCode.java | 7 + 18 files changed, 958 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentCreateRequestDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentDetailResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentImageDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentUpdateRequestDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentImageMapper.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentImageRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRequiredSkillRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentSchedulingService.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java new file mode 100644 index 0000000..13efcaf --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java @@ -0,0 +1,139 @@ +package com.teamEWSN.gitdeun.Recruitment.controller; + +import com.teamEWSN.gitdeun.Recruitment.dto.*; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.Recruitment.service.RecruitmentService; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class RecruitmentController { + + private final RecruitmentService recruitmentService; + + /** + * 신규 모집 공고 생성 API + * + * @param userDetails 현재 로그인한 사용자 정보 + * @param requestDto 생성할 공고 정보 + * @return 생성된 공고의 상세 정보 + */ + @PostMapping(value = "/recruitments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createRecruitment( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @ModelAttribute RecruitmentCreateRequestDto requestDto + ) { + RecruitmentDetailResponseDto responseDto = recruitmentService.createRecruitment(userDetails.getId(), requestDto); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + } + + /** + * 내가 작성한 모집 공고 목록 조회 API + * + * @param userDetails 현재 로그인한 사용자 정보 + * @param pageable 페이징 정보 + * @return 내 모집 공고 목록 + */ + @GetMapping("/users/me/recruitments") + public ResponseEntity> getMyRecruitments( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(size = 10) Pageable pageable + ) { + Page response = recruitmentService.getMyRecruitments(userDetails.getId(), pageable); + return ResponseEntity.ok(response); + } + + /** + * 특정 모집 공고 상세 조회 API + * + * @param recruitmentId 조회할 공고 ID + * @return 공고 상세 정보 + */ + @GetMapping("/recruitments/{id}") + public ResponseEntity getRecruitment(@PathVariable("id") Long recruitmentId) { + RecruitmentDetailResponseDto responseDto = recruitmentService.getRecruitment(recruitmentId); + return ResponseEntity.ok(responseDto); + } + + /** + * 모집 공고 목록 필터링 조회 API + * + * @param status 모집 상태 (FORTHCOMING, RECRUITING, CLOSED, COMPLETED) + * @param field 모집 분야 (BACKEND, FRONTEND 등) + * @param pageable 페이징 정보 + * @return 필터링된 공고 목록 + */ + @GetMapping("/recruitments") + public ResponseEntity> searchRecruitments( + @RequestParam(required = false) RecruitmentStatus status, + @RequestParam(required = false) List field, + @PageableDefault(size = 10) Pageable pageable + ) { + Page response = recruitmentService.searchRecruitments(status, field, pageable); + return ResponseEntity.ok(response); + } + + /** + * 모집 공고 수정 API (작성자만 가능) + * + * @param recruitmentId 수정할 공고 ID + * @param userDetails 현재 로그인한 사용자 정보 + * @param requestDto 수정할 공고 정보 + * @return 수정된 공고 상세 정보 + */ + @PutMapping(value = "/recruitments/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateRecruitment( + @PathVariable("id") Long recruitmentId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @ModelAttribute RecruitmentUpdateRequestDto requestDto + ) { + RecruitmentDetailResponseDto responseDto = recruitmentService.updateRecruitment(recruitmentId, userDetails.getId(), requestDto); + return ResponseEntity.ok(responseDto); + } + + /** + * 모집 공고 삭제 API (작성자만 가능) + * + * @param recruitmentId 삭제할 공고 ID + * @param userDetails 현재 로그인한 사용자 정보 + * @return 응답 없음 (204 No Content) + */ + @DeleteMapping("/recruitments/{id}") + public ResponseEntity deleteRecruitment( + @PathVariable("id") Long recruitmentId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + recruitmentService.deleteRecruitment(recruitmentId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + /** + * 사용자 맞춤 추천 공고 목록 조회 API (가중치 기반) + * + * @param userDetails 현재 로그인한 사용자 정보 + * @param pageable 페이징 정보 + * @return 추천 공고 목록 + */ + @GetMapping("/recruitments/recommendations") + public ResponseEntity> getRecommendedRecruitments( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(size = 10) Pageable pageable + ) { + Page response = recruitmentService.getRecommendedRecruitments(userDetails.getId(), pageable); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentCreateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentCreateRequestDto.java new file mode 100644 index 0000000..71e52d3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentCreateRequestDto.java @@ -0,0 +1,44 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import jakarta.validation.constraints.*; +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Getter +public class RecruitmentCreateRequestDto { + + @NotBlank(message = "제목을 입력해주세요.") + @Size(max = 120, message = "제목은 120자를 넘을 수 없습니다.") + private String title; + + @NotBlank(message = "내용을 입력해주세요.") + private String content; + + @NotNull(message = "모집 시작일을 입력해주세요.") + private LocalDateTime startAt; + + @NotNull(message = "모집 마감일을 입력해주세요.") + @Future(message = "마감일은 현재보다 미래여야 합니다.") + private LocalDateTime endAt; + + @NotNull(message = "총 팀원 수를 입력해주세요.") + @Min(value = 1, message = "총 팀원 수는 1명 이상이어야 합니다.") + private Integer teamSizeTotal; + + @NotNull(message = "모집 인원을 입력해주세요.") + @Min(value = 1, message = "모집 인원은 1명 이상이어야 합니다.") + private Integer recruitQuota; + + @Size(min = 1, message = "모집 분야를 하나 이상 선택해주세요.") + private Set fieldTags; + + private Set languageTags; + + private List images; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentDetailResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentDetailResponseDto.java new file mode 100644 index 0000000..b72af40 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentDetailResponseDto.java @@ -0,0 +1,36 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Getter +@Builder +public class RecruitmentDetailResponseDto { + private Long id; + private String title; + private String content; + private RecruitmentStatus status; + + private LocalDateTime startAt; + private LocalDateTime endAt; + + private int teamSizeTotal; + private int recruitQuota; + private int views; + + private String recruiterNickname; + private String recruiterProfileImage; + + private Set fieldTags; + private Set languageTags; + + private List images; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentImageDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentImageDto.java new file mode 100644 index 0000000..3cd1875 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentImageDto.java @@ -0,0 +1,12 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class RecruitmentImageDto { + private Long imageId; + private String imageUrl; +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java new file mode 100644 index 0000000..c0da4d1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java @@ -0,0 +1,32 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Set; + +@Getter +@Builder +public class RecruitmentListResponseDto { + private Long id; + private String title; + private String thumbnailUrl; // 썸네일 이미지(이미지 리스트 중 맨 앞) + private RecruitmentStatus status; + + private Set languageTags; // 개발 기술 및 지원 분야 태그 + private Set fieldTags; + + private LocalDateTime startAt; // 모집 기간 + private LocalDateTime endAt; + + private Integer views; + private Integer recruitQuota; + + // private String recruiterNickname; + + private Double matchScore; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentUpdateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentUpdateRequestDto.java new file mode 100644 index 0000000..cc5a47e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentUpdateRequestDto.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import jakarta.validation.constraints.*; +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Getter +public class RecruitmentUpdateRequestDto { + @NotBlank(message = "제목을 입력해주세요.") + @Size(max = 120, message = "제목은 120자를 넘을 수 없습니다.") + private String title; + + @NotBlank(message = "내용을 입력해주세요.") + private String content; + + @NotNull(message = "모집 마감일을 입력해주세요.") + @Future(message = "마감일은 현재보다 미래여야 합니다.") + private LocalDateTime endAt; + + @NotNull(message = "총 팀원 수를 입력해주세요.") + @Min(value = 1, message = "총 팀원 수는 1명 이상이어야 합니다.") + private Integer teamSizeTotal; + + @NotNull(message = "모집 인원을 입력해주세요.") + @Min(value = 1, message = "모집 인원은 1명 이상이어야 합니다.") + private Integer recruitQuota; + + @Size(min = 1, message = "모집 분야를 하나 이상 선택해주세요.") + private Set fieldTags; + + private Set languageTags; + + private List keepImageIds; // 유지할 기존 이미지 ID + private List newImages; // 새로 추가할 이미지 +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java index 95a2498..671dc22 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java @@ -1,5 +1,7 @@ package com.teamEWSN.gitdeun.Recruitment.entity; +import com.teamEWSN.gitdeun.Application.entity.Application; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; import jakarta.persistence.*; @@ -23,7 +25,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class Recruitment { +public class Recruitment extends AuditedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -96,17 +98,37 @@ public class Recruitment { @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true) private List recruitmentImages = new ArrayList<>(); - /*// 지원 신청 리스트 + // 지원 신청 리스트 @OneToMany(mappedBy = "recruitment", fetch = FetchType.LAZY) @Builder.Default - private List applications = new ArrayList<>();*/ + private List applications = new ArrayList<>(); // 추천 가중치용 요구 기술(언어/프레임워크/툴 등, 선택) - @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private Set requiredSkills = new HashSet<>(); - + /** + * 모집 인원을 1 감소시키고, 인원이 0이 되면 상태를 COMPLETED로 변경 + */ + public void decreaseQuota() { + if (this.recruitQuota > 0) { + this.recruitQuota--; + if (this.recruitQuota == 0) { + this.status = RecruitmentStatus.COMPLETED; + } + } + } + + /** + * 모집 인원을 1 증가시키고, 상태가 COMPLETED였다면 RECRUITING으로 변경 + */ + public void increaseQuota() { + this.recruitQuota++; + if (this.status == RecruitmentStatus.COMPLETED && LocalDateTime.now().isBefore(this.endAt)) { + this.status = RecruitmentStatus.RECRUITING; + } + } public void increaseView() { this.views++; } } diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java index fbccbe8..f4fd110 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java @@ -1,7 +1,8 @@ package com.teamEWSN.gitdeun.Recruitment.entity; public enum RecruitmentStatus { - Forthcoming, // 모집 예정 - RECRUITING, // 모집 중 - CLOSED, // 마감 + FORTHCOMING, // 모집 예정 + RECRUITING, // 모집 중 + CLOSED, // 마감 (기간 종료) + COMPLETED // 완료 (인원 충족) } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentImageMapper.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentImageMapper.java new file mode 100644 index 0000000..fd08890 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentImageMapper.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.Recruitment.mapper; + +import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentImageDto; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentImage; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface RecruitmentImageMapper { + + @Mapping(source = "id", target = "imageId") + RecruitmentImageDto toDto(RecruitmentImage recruitmentImage); + + @Mapping(source = "imageId", target = "id") + RecruitmentImage toEntity(RecruitmentImageDto dto); + + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java new file mode 100644 index 0000000..c90ce90 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java @@ -0,0 +1,72 @@ +package com.teamEWSN.gitdeun.Recruitment.mapper; + +import com.teamEWSN.gitdeun.Recruitment.dto.*; +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentImage; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import org.mapstruct.*; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface RecruitmentMapper { + + @Mapping(source = "recruitment", target = "matchScore", qualifiedByName = "calculateScore") + @Mapping(source = "recruitmentImages", target = "thumbnailUrl", qualifiedByName = "mapThumbnailUrl") + RecruitmentListResponseDto toListResponseDto(Recruitment recruitment); + + @Mapping(source = "recruiter.nickname", target = "recruiterNickname") + @Mapping(source = "recruiter.profileImage", target = "recruiterProfileImage") + @Mapping(source = "recruitmentImages", target = "images", qualifiedByName = "mapImages") + RecruitmentDetailResponseDto toDetailResponseDto(Recruitment recruitment); + + Recruitment toEntity(RecruitmentCreateRequestDto createDto); + + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateRecruitmentFromDto(RecruitmentUpdateRequestDto updateDto, @MappingTarget Recruitment recruitment); + + @Named("calculateScore") + default Double calculateScore(Recruitment recruitment, @Context Set userSkills) { + // 점수 계산 로직을 매퍼로 이동 + return RecommendationScoreCalculator.calculate(recruitment, userSkills); + } + + /** + * 썸네일 이미지 매핑 (첫 번째 이미지를 썸네일로 사용) + */ + @Named("mapThumbnailUrl") + default String mapThumbnailUrl(List recruitmentImages) { + if (recruitmentImages == null || recruitmentImages.isEmpty()) { + return null; + } + + return recruitmentImages.stream() + .filter(image -> image.getDeletedAt() == null) + .findFirst() + .map(RecruitmentImage::getImageUrl) + .orElse(null); + } + + /** + * RecruitmentImage List를 RecruitmentImageDto List로 변환 + * (삭제되지 않은 이미지만 포함) + */ + @Named("mapImages") + default List mapImages(List recruitmentImages) { + if (recruitmentImages == null || recruitmentImages.isEmpty()) { + return null; + } + return recruitmentImages.stream() + .filter(image -> image.getDeletedAt() == null) // soft delete 처리 + .map(this::toRecruitmentImageDto) + .collect(Collectors.toList()); + } + + /** + * RecruitmentImage를 RecruitmentImageDto로 변환 + */ + @Mapping(source = "id", target = "imageId") + RecruitmentImageDto toRecruitmentImageDto(RecruitmentImage recruitmentImage); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java new file mode 100644 index 0000000..0851a96 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentListResponseDto; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Set; + +public interface RecruitmentCustomRepository { + Page searchRecruitments( + RecruitmentStatus status, + List fields, + Pageable pageable + ); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentImageRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentImageRepository.java new file mode 100644 index 0000000..a03112e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentImageRepository.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface RecruitmentImageRepository extends JpaRepository { + List findByRecruitmentAndDeletedAtIsNull(Recruitment recruitment); + + List findByRecruitmentIdAndDeletedAtIsNull(Long recruitmentId); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java new file mode 100644 index 0000000..665b872 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java @@ -0,0 +1,26 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface RecruitmentRepository extends JpaRepository, RecruitmentCustomRepository{ + // 스케줄링을 위한 조회 + List findAllByStatusAndStartAtBefore(RecruitmentStatus status, LocalDateTime now); + List findAllByStatusAndEndAtBefore(RecruitmentStatus status, LocalDateTime now); + + // 내 공고 목록 조회 + Page findByRecruiterId(Long recruiterId, Pageable pageable); + + // 공고 추천을 위한 + @Query("SELECT r FROM Recruitment r LEFT JOIN FETCH r.requiredSkills rs " + "WHERE r.status = :status") + List findAllByStatusWithRequiredSkills(RecruitmentStatus status); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java new file mode 100644 index 0000000..5efcb27 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java @@ -0,0 +1,65 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentListResponseDto; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.CollectionUtils; + +import java.util.List; + +import static com.teamEWSN.gitdeun.Recruitment.entity.QRecruitment.recruitment; + +@Repository +@RequiredArgsConstructor +public class RecruitmentRepositoryImpl implements RecruitmentCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchRecruitments( + RecruitmentStatus status, List fields, Pageable pageable + ) { + List content = queryFactory + .select(Projections.bean(RecruitmentListResponseDto.class, + recruitment.id, + recruitment.title, + recruitment.status, + recruitment.languageTags, + recruitment.fieldTags, + recruitment.startAt, + recruitment.endAt, + recruitment.recruitQuota + )) + .from(recruitment) + .where(statusEq(status), fieldIn(fields)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(recruitment.id.desc()) + .fetch(); + + Long total = queryFactory + .select(recruitment.count()) + .from(recruitment) + .where(statusEq(status), fieldIn(fields)) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + + private BooleanExpression statusEq(RecruitmentStatus status) { + return status != null ? recruitment.status.eq(status) : null; + } + + private BooleanExpression fieldIn(List fields) { + return !CollectionUtils.isEmpty(fields) ? recruitment.fieldTags.any().in(fields) : null; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRequiredSkillRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRequiredSkillRepository.java new file mode 100644 index 0000000..0e9e301 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRequiredSkillRepository.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentRequiredSkill; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RecruitmentRequiredSkillRepository extends JpaRepository { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentSchedulingService.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentSchedulingService.java new file mode 100644 index 0000000..f2d87f9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentSchedulingService.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.Recruitment.service; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecruitmentSchedulingService { + + private final RecruitmentRepository recruitmentRepository; + + @Transactional + @Scheduled(cron = "0 0 0 * * *") // 매일 자정 실행 + public void updateRecruitmentStatus() { + log.info("모집 공고 상태 업데이트 스케줄러 시작"); + LocalDateTime now = LocalDateTime.now(); + + // 모집 예정 -> 모집 중 + List startingRecruitments = recruitmentRepository + .findAllByStatusAndStartAtBefore(RecruitmentStatus.FORTHCOMING, now); + startingRecruitments.forEach(r -> r.setStatus(RecruitmentStatus.RECRUITING)); + log.info("{}개의 공고를 '모집 중'으로 변경했습니다.", startingRecruitments.size()); + + // 모집 중 -> 모집 마감 + List closingRecruitments = recruitmentRepository + .findAllByStatusAndEndAtBefore(RecruitmentStatus.RECRUITING, now); + closingRecruitments.forEach(r -> r.setStatus(RecruitmentStatus.CLOSED)); + log.info("{}개의 공고를 '모집 마감'으로 변경했습니다.", closingRecruitments.size()); + log.info("모집 공고 상태 업데이트 스케줄러 종료"); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java new file mode 100644 index 0000000..76805cf --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java @@ -0,0 +1,349 @@ +package com.teamEWSN.gitdeun.Recruitment.service; + +import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentCreateRequestDto; +import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentDetailResponseDto; +import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentListResponseDto; +import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentUpdateRequestDto; +import com.teamEWSN.gitdeun.Recruitment.entity.*; +import com.teamEWSN.gitdeun.Recruitment.mapper.RecruitmentMapper; +import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentImageRepository; +import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentRepository; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.s3.service.S3BucketService; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class RecruitmentService { + private final RecruitmentRepository recruitmentRepository; + private final RecruitmentImageRepository recruitmentImageRepository; + private final UserRepository userRepository; + private final RecruitmentMapper recruitmentMapper; + private final S3BucketService s3BucketService; + + + /** + * 새로운 모집 공고를 생성합니다. + * 모집 기간에 따라 초기 상태(모집 예정, 모집 중)가 자동으로 설정됩니다. + * @param userId 모집 공고를 생성하는 사용자 ID + * @param requestDto 생성할 공고의 정보가 담긴 DTO + * @return 생성된 공고의 상세 정보 DTO + */ + @Transactional + public RecruitmentDetailResponseDto createRecruitment(Long userId, RecruitmentCreateRequestDto requestDto) { + User recruiter = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + validateRecruitmentDates(requestDto.getStartAt(), requestDto.getEndAt()); + + // DTO를 엔티티로 변환 + Recruitment recruitment = recruitmentMapper.toEntity(requestDto); + recruitment.setRecruiter(recruiter); + + // 상태 설정 + RecruitmentStatus initialStatus = requestDto.getStartAt().isAfter(LocalDateTime.now()) ? + RecruitmentStatus.FORTHCOMING : RecruitmentStatus.RECRUITING; + recruitment.setStatus(initialStatus); + + // Recruitment 엔티티를 먼저 저장 (ID 생성을 위해) + Recruitment savedRecruitment = recruitmentRepository.save(recruitment); + + // requiredSkills 처리 + processRequiredSkills(savedRecruitment, requestDto.getRequiredSkills()); + + // 이미지 파일 처리 + if (!CollectionUtils.isEmpty(requestDto.getImages())) { + // S3에 파일 업로드 후 URL 리스트를 받아옴 + String s3Path = "recruitments/" + savedRecruitment.getId(); + List uploadedUrls = s3BucketService.upload(requestDto.getImages(), s3Path); + + // URL을 RecruitmentImage 엔티티로 변환 + List images = uploadedUrls.stream() + .map(url -> RecruitmentImage.builder() + .imageUrl(url) + .recruitment(savedRecruitment) + .build()) + .collect(Collectors.toList()); + + // 이미지 정보 저장 + recruitmentImageRepository.saveAll(images); + savedRecruitment.setRecruitmentImages(images); + } + + return recruitmentMapper.toDetailResponseDto(savedRecruitment); + } + + /** + * 현재 로그인한 사용자가 작성한 모든 모집 공고 목록을 조회합니다. + * @param userId 현재 사용자 ID + * @param pageable 페이징 정보 + * @return 페이징 처리된 내 모집 공고 목록 + */ + @Transactional(readOnly = true) + public Page getMyRecruitments(Long userId, Pageable pageable) { + return recruitmentRepository.findByRecruiterId(userId, pageable) + .map(recruitmentMapper::toListResponseDto); + } + + /** + * 특정 모집 공고의 상세 정보를 조회합니다. + * 이 메서드가 호출될 때마다 해당 공고의 조회수가 1 증가합니다. + * @param recruitmentId 조회할 공고의 ID + * @return 공고의 상세 정보 DTO + */ + @Transactional + public RecruitmentDetailResponseDto getRecruitment(Long recruitmentId) { + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + recruitment.increaseView(); + return recruitmentMapper.toDetailResponseDto(recruitment); + } + + /** + * 상태(status)와 모집 분야(field)를 기준으로 모집 공고를 필터링하여 검색합니다. + * @param status 검색할 모집 상태 (선택 사항) + * @param fields 검색할 모집 분야 목록 (선택 사항) + * @param pageable 페이징 정보 + * @return 페이징 처리된 검색 결과 목록 + */ + @Transactional(readOnly = true) + public Page searchRecruitments(RecruitmentStatus status, List fields, Pageable pageable) { + return recruitmentRepository.searchRecruitments(status, fields, pageable).map(this::addThumbnailUrl); + } + + /** + * 특정 모집 공고를 수정합니다. + * 공고 작성자만 수정할 수 있습니다. + * @param recruitmentId 수정할 공고의 ID + * @param userId 요청한 사용자의 ID + * @param requestDto 수정할 공고의 정보가 담긴 DTO + * @return 수정된 공고의 상세 정보 DTO + */ + @Transactional + public RecruitmentDetailResponseDto updateRecruitment(Long recruitmentId, Long userId, RecruitmentUpdateRequestDto requestDto) { + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + if (!recruitment.getRecruiter().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + validateRecruitmentDates(recruitment.getStartAt(), requestDto.getEndAt()); + + recruitmentMapper.updateRecruitmentFromDto(requestDto, recruitment); + + // 기존 requiredSkills 제거 + recruitment.getRequiredSkills().clear(); + + // 새로운 requiredSkills 설정 + processRequiredSkills(recruitment, requestDto.getRequiredSkills()); + + // 이미지 업데이트 로직 + updateImages(recruitment, requestDto.getKeepImageIds(), requestDto.getNewImages()); + + return recruitmentMapper.toDetailResponseDto(recruitment); + } + + /** + * 게시글의 이미지를 업데이트합니다. + * @param recruitment 이미지를 업데이트할 Recruitment 엔티티 + * @param keepImageIds 유지할 기존 이미지의 ID 리스트 + * @param newImages 새로 추가할 이미지 파일 리스트 + */ + private void updateImages(Recruitment recruitment, List keepImageIds, List newImages) { + // 1. 기존 이미지 중 유지하지 않는 것만 soft-delete + List existingImages = recruitmentImageRepository.findByRecruitmentAndDeletedAtIsNull(recruitment); + List finalKeepIds = keepImageIds == null ? new ArrayList<>() : keepImageIds; + + List imagesToDelete = existingImages.stream() + .filter(img -> !finalKeepIds.contains(img.getId())) + .collect(Collectors.toList()); + + if (!imagesToDelete.isEmpty()) { + imagesToDelete.forEach(RecruitmentImage::softDelete); + recruitmentImageRepository.saveAll(imagesToDelete); + } + + // 2. 새 이미지 S3에 업로드 및 DB에 저장 + if (!CollectionUtils.isEmpty(newImages)) { + String s3Path = "recruitments/" + recruitment.getId(); + List uploadedUrls = s3BucketService.upload(newImages, s3Path); + + List imagesToAdd = uploadedUrls.stream() + .map(url -> RecruitmentImage.builder().imageUrl(url).recruitment(recruitment).build()) + .collect(Collectors.toList()); + recruitmentImageRepository.saveAll(imagesToAdd); + } + } + + /** + * 특정 모집 공고를 삭제합니다. + * 공고 작성자만 삭제할 수 있습니다. + * @param recruitmentId 삭제할 공고의 ID + * @param userId 요청한 사용자의 ID + */ + @Transactional + public void deleteRecruitment(Long recruitmentId, Long userId) { + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + if (!recruitment.getRecruiter().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + recruitmentRepository.delete(recruitment); + } + + /** + * 사용자의 기술 스택을 기반으로 맞춤 모집 공고를 추천합니다. + * 사용자가 보유한 기술과 공고의 요구 기술이 많이 일치할수록 상단에 노출됩니다. + * @param userId 추천을 받을 사용자의 ID + * @param pageable 페이징 정보 + * @return 페이징 처리된 추천 공고 목록 + */ + @Transactional(readOnly = true) + public Page getRecommendedRecruitments(Long userId, Pageable pageable) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Set userSkills = user.getSkills().stream() + .map(userSkill -> DeveloperSkill.valueOf(userSkill.getSkill().toUpperCase())) + .collect(Collectors.toSet()); + + // Repository에서 기본 데이터만 조회 + List recruitments = recruitmentRepository.findAllByStatusWithRequiredSkills(RecruitmentStatus.RECRUITING); + + List matchedRecruitments = recruitments.stream() + .map(recruitment -> calculateRecommendationScore(recruitment, userSkills)) + .filter(dto -> dto.getMatchScore() > 0.0) // 하나라도 일치하는 기술이 있는 공고만 + .sorted((a, b) -> Double.compare(b.getMatchScore(), a.getMatchScore())) // 점수 높은 순 + .collect(Collectors.toList()); + + // 페이징 적용 + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), scoredRecruitments.size()); + List pagedContent = scoredRecruitments.subList(start, end); + + return new PageImpl<>(pagedContent, pageable, scoredRecruitments.size()); + } + + /** Helper Method */ + private RecruitmentListResponseDto addThumbnailUrl(RecruitmentListResponseDto dto) { + if (dto.getThumbnailUrl() == null) { + // 첫 번째 이미지 조회 + List images = recruitmentImageRepository.findByRecruitmentIdAndDeletedAtIsNull(dto.getId()); + String thumbnailUrl = images.stream() + .findFirst() + .map(RecruitmentImage::getImageUrl) + .orElse(null); + + return RecruitmentListResponseDto.builder() + .id(dto.getId()) + .title(dto.getTitle()) + .thumbnailUrl(thumbnailUrl) + .status(dto.getStatus()) + .languageTags(dto.getLanguageTags()) + .fieldTags(dto.getFieldTags()) + .startAt(dto.getStartAt()) + .endAt(dto.getEndAt()) + .recruitQuota(dto.getRecruitQuota()) + .build(); + } + return dto; + } + + private void processRequiredSkills(Recruitment recruitment, Set requiredSkillsDto) { + if (requiredSkillsDto == null || requiredSkillsDto.isEmpty()) { + return; + } + + Set requiredSkills = requiredSkillsDto.stream() + .map(skill -> RecruitmentRequiredSkill.builder() + .recruitment(recruitment) + .skill(skill) + .category(SkillCategory.LANGUAGE) // 기본값으로 LANGUAGE 설정 + .weight(1.0) // 기본 가중치 + .build()) + .collect(Collectors.toSet()); + + recruitment.setRequiredSkills(requiredSkills); + } + + private void validateRecruitmentDates(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt.isAfter(endAt)) { + throw new GlobalException(ErrorCode.INVALID_DATE_RANGE); + } + if (endAt.isBefore(LocalDateTime.now())) { + throw new GlobalException(ErrorCode.END_DATE_IN_PAST); + } + } + + + // 추천 공고 가중치 계산 + private RecruitmentListResponseDto calculateRecommendationScore(Recruitment recruitment, Set userSkills) { + double score = 0.0; + + // 1차 매칭: requiredSkills 기반 (가중치 적용) + Set requiredSkills = recruitment.getRequiredSkills(); + if (!requiredSkills.isEmpty()) { + score = calculateWeightedScore(requiredSkills, userSkills); + } else { + // 2차 매칭: languageTags 기반 (fallback) + score = calculateSimpleScore(recruitment.getLanguageTags(), userSkills); + } + + // 최근 공고 보너스 + if (recruitment.getCreatedAt().isAfter(LocalDateTime.now().minusDays(7))) { + score = Math.min(score + 0.05, 1.0); + } + + return recruitmentMapper.toListResponseDto(recruitment); + } + + private double calculateWeightedScore(Set requiredSkills, Set userSkills) { + double totalWeight = 0.0; + double matchedWeight = 0.0; + + for (RecruitmentRequiredSkill required : requiredSkills) { + if (required.getCategory() == SkillCategory.LANGUAGE) { + double weight = required.getWeight(); // 기본값(1.0)이든 커스텀이든 상관없이 사용 + totalWeight += weight; + + if (userSkills.contains(required.getSkill())) { + matchedWeight += weight; + } + } + } + + return totalWeight > 0 ? matchedWeight / totalWeight : 0.0; + } + + private double calculateSimpleScore(Set languageTags, Set userSkills) { + if (languageTags.isEmpty()) return 0.0; + + long matchCount = languageTags.stream() + .mapToLong(tag -> userSkills.contains(tag) ? 1 : 0) + .sum(); + + return (double) matchCount / languageTags.size(); + } + +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 8ad6020..73580ee 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -70,6 +70,13 @@ public enum ErrorCode { PINNEDHISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PINNEDHISTORY-003", "핀 고정 기록을 찾을 수 없습니다."), PINNED_HISTORY_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "PINNEDHISTORY-004", "최대 8개까지만 핀으로 고정할 수 있습니다."), + // 모집 공고 관련 + RECRUITMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "RECRUIT-001", "모집 공고를 찾을 수 없습니다."), + INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "RECRUIT-002", "시작일은 마감일보다 빠를 수 없습니다."), + END_DATE_IN_PAST(HttpStatus.BAD_REQUEST, "RECRUIT-003", "마감일은 현재보다 미래여야 합니다."), + QUOTA_FILLED(HttpStatus.BAD_REQUEST, "RECRUIT-004", "모집 인원이 모두 충원되었습니다."), + + // S3 파일 관련 // Client Errors (4xx) FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "요청한 파일을 찾을 수 없습니다."), From 5ba45de8b7a03d71f8b6716a5dd8b163def68982 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 6 Sep 2025 12:13:34 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=AA=A8=EC=A7=91=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EC=B6=94=EC=B2=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/RecruitmentListResponseDto.java | 4 + .../entity/RecruitmentRequiredSkill.java | 2 +- .../mapper/RecruitmentImageMapper.java | 19 -- .../Recruitment/mapper/RecruitmentMapper.java | 17 +- .../repository/RecruitmentRepository.java | 5 +- .../service/RecruitmentService.java | 317 +++++++++--------- .../util/RecommendationScoreCalculator.java | 91 +++++ 7 files changed, 259 insertions(+), 196 deletions(-) delete mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentImageMapper.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Recruitment/service/util/RecommendationScoreCalculator.java diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java index c0da4d1..3aa32d7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java @@ -5,6 +5,7 @@ import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; import lombok.Builder; import lombok.Getter; +import lombok.With; import java.time.LocalDateTime; import java.util.Set; @@ -14,6 +15,8 @@ public class RecruitmentListResponseDto { private Long id; private String title; + + @With private String thumbnailUrl; // 썸네일 이미지(이미지 리스트 중 맨 앞) private RecruitmentStatus status; @@ -28,5 +31,6 @@ public class RecruitmentListResponseDto { // private String recruiterNickname; + @With private Double matchScore; } diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java index 4903203..784bf2d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java @@ -32,7 +32,7 @@ public class RecruitmentRequiredSkill { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 32) - private SkillCategory category; // 추천은 LANGUAGE만 사용 + private SkillCategory category; // 추천은 현재 LANGUAGE만 사용 @Column(nullable = false) @Builder.Default diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentImageMapper.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentImageMapper.java deleted file mode 100644 index fd08890..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentImageMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.teamEWSN.gitdeun.Recruitment.mapper; - -import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentImageDto; -import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentImage; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.ReportingPolicy; - -@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface RecruitmentImageMapper { - - @Mapping(source = "id", target = "imageId") - RecruitmentImageDto toDto(RecruitmentImage recruitmentImage); - - @Mapping(source = "imageId", target = "id") - RecruitmentImage toEntity(RecruitmentImageDto dto); - - -} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java index c90ce90..c394bbf 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java @@ -3,17 +3,14 @@ import com.teamEWSN.gitdeun.Recruitment.dto.*; import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentImage; -import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; import org.mapstruct.*; import java.util.List; -import java.util.Set; import java.util.stream.Collectors; @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface RecruitmentMapper { - @Mapping(source = "recruitment", target = "matchScore", qualifiedByName = "calculateScore") @Mapping(source = "recruitmentImages", target = "thumbnailUrl", qualifiedByName = "mapThumbnailUrl") RecruitmentListResponseDto toListResponseDto(Recruitment recruitment); @@ -27,14 +24,9 @@ public interface RecruitmentMapper { @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) void updateRecruitmentFromDto(RecruitmentUpdateRequestDto updateDto, @MappingTarget Recruitment recruitment); - @Named("calculateScore") - default Double calculateScore(Recruitment recruitment, @Context Set userSkills) { - // 점수 계산 로직을 매퍼로 이동 - return RecommendationScoreCalculator.calculate(recruitment, userSkills); - } /** - * 썸네일 이미지 매핑 (첫 번째 이미지를 썸네일로 사용) + * 썸네일 URL 매핑 (이미지 목록의 첫 번째 이미지) */ @Named("mapThumbnailUrl") default String mapThumbnailUrl(List recruitmentImages) { @@ -50,8 +42,7 @@ default String mapThumbnailUrl(List recruitmentImages) { } /** - * RecruitmentImage List를 RecruitmentImageDto List로 변환 - * (삭제되지 않은 이미지만 포함) + * 이미지 리스트 매핑 */ @Named("mapImages") default List mapImages(List recruitmentImages) { @@ -59,13 +50,13 @@ default List mapImages(List recruitmentIm return null; } return recruitmentImages.stream() - .filter(image -> image.getDeletedAt() == null) // soft delete 처리 + .filter(image -> image.getDeletedAt() == null) .map(this::toRecruitmentImageDto) .collect(Collectors.toList()); } /** - * RecruitmentImage를 RecruitmentImageDto로 변환 + * 이미지 DTO 변환 */ @Mapping(source = "id", target = "imageId") RecruitmentImageDto toRecruitmentImageDto(RecruitmentImage recruitmentImage); diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java index 665b872..89b7995 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java @@ -20,7 +20,6 @@ public interface RecruitmentRepository extends JpaRepository, // 내 공고 목록 조회 Page findByRecruiterId(Long recruiterId, Pageable pageable); - // 공고 추천을 위한 - @Query("SELECT r FROM Recruitment r LEFT JOIN FETCH r.requiredSkills rs " + "WHERE r.status = :status") - List findAllByStatusWithRequiredSkills(RecruitmentStatus status); + // 상태 기반 조회(추천 시) + List findAllByStatusIn(List statuses); } diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java index 76805cf..dde44d6 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java @@ -1,13 +1,11 @@ package com.teamEWSN.gitdeun.Recruitment.service; -import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentCreateRequestDto; -import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentDetailResponseDto; -import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentListResponseDto; -import com.teamEWSN.gitdeun.Recruitment.dto.RecruitmentUpdateRequestDto; +import com.teamEWSN.gitdeun.Recruitment.dto.*; import com.teamEWSN.gitdeun.Recruitment.entity.*; import com.teamEWSN.gitdeun.Recruitment.mapper.RecruitmentMapper; import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentImageRepository; import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentRepository; +import com.teamEWSN.gitdeun.Recruitment.service.util.RecommendationScoreCalculator; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.s3.service.S3BucketService; @@ -15,6 +13,7 @@ import com.teamEWSN.gitdeun.user.repository.UserRepository; import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -24,11 +23,10 @@ import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class RecruitmentService { @@ -38,10 +36,8 @@ public class RecruitmentService { private final RecruitmentMapper recruitmentMapper; private final S3BucketService s3BucketService; - /** * 새로운 모집 공고를 생성합니다. - * 모집 기간에 따라 초기 상태(모집 예정, 모집 중)가 자동으로 설정됩니다. * @param userId 모집 공고를 생성하는 사용자 ID * @param requestDto 생성할 공고의 정보가 담긴 DTO * @return 생성된 공고의 상세 정보 DTO @@ -57,7 +53,7 @@ public RecruitmentDetailResponseDto createRecruitment(Long userId, RecruitmentCr Recruitment recruitment = recruitmentMapper.toEntity(requestDto); recruitment.setRecruiter(recruiter); - // 상태 설정 + // 상태 설정 (시작일이 현재보다 미래면 모집 예정, 아니면 모집 중) RecruitmentStatus initialStatus = requestDto.getStartAt().isAfter(LocalDateTime.now()) ? RecruitmentStatus.FORTHCOMING : RecruitmentStatus.RECRUITING; recruitment.setStatus(initialStatus); @@ -65,26 +61,10 @@ public RecruitmentDetailResponseDto createRecruitment(Long userId, RecruitmentCr // Recruitment 엔티티를 먼저 저장 (ID 생성을 위해) Recruitment savedRecruitment = recruitmentRepository.save(recruitment); - // requiredSkills 처리 - processRequiredSkills(savedRecruitment, requestDto.getRequiredSkills()); - // 이미지 파일 처리 if (!CollectionUtils.isEmpty(requestDto.getImages())) { - // S3에 파일 업로드 후 URL 리스트를 받아옴 - String s3Path = "recruitments/" + savedRecruitment.getId(); - List uploadedUrls = s3BucketService.upload(requestDto.getImages(), s3Path); - - // URL을 RecruitmentImage 엔티티로 변환 - List images = uploadedUrls.stream() - .map(url -> RecruitmentImage.builder() - .imageUrl(url) - .recruitment(savedRecruitment) - .build()) - .collect(Collectors.toList()); - - // 이미지 정보 저장 - recruitmentImageRepository.saveAll(images); - savedRecruitment.setRecruitmentImages(images); + List savedImages = uploadAndSaveImages(savedRecruitment, requestDto.getImages()); + savedRecruitment.setRecruitmentImages(savedImages); } return recruitmentMapper.toDetailResponseDto(savedRecruitment); @@ -98,8 +78,17 @@ public RecruitmentDetailResponseDto createRecruitment(Long userId, RecruitmentCr */ @Transactional(readOnly = true) public Page getMyRecruitments(Long userId, Pageable pageable) { - return recruitmentRepository.findByRecruiterId(userId, pageable) - .map(recruitmentMapper::toListResponseDto); + Page recruitmentPage = recruitmentRepository.findByRecruiterId(userId, pageable); + + List content = recruitmentPage.getContent().stream() + .map(recruitment -> { + RecruitmentListResponseDto dto = recruitmentMapper.toListResponseDto(recruitment); + // 썸네일 URL 설정 (필요시) + return addThumbnailUrl(dto, recruitment.getRecruitmentImages()); + }) + .collect(Collectors.toList()); + + return new PageImpl<>(content, pageable, recruitmentPage.getTotalElements()); } /** @@ -126,7 +115,15 @@ public RecruitmentDetailResponseDto getRecruitment(Long recruitmentId) { */ @Transactional(readOnly = true) public Page searchRecruitments(RecruitmentStatus status, List fields, Pageable pageable) { - return recruitmentRepository.searchRecruitments(status, fields, pageable).map(this::addThumbnailUrl); + return recruitmentRepository.searchRecruitments(status, fields, pageable) + .map(dto -> { + // 썸네일 URL이 없는 경우에만 조회 + if (dto.getThumbnailUrl() == null) { + List images = recruitmentImageRepository.findByRecruitmentIdAndDeletedAtIsNull(dto.getId()); + return addThumbnailUrl(dto, images); + } + return dto; + }); } /** @@ -150,48 +147,14 @@ public RecruitmentDetailResponseDto updateRecruitment(Long recruitmentId, Long u recruitmentMapper.updateRecruitmentFromDto(requestDto, recruitment); - // 기존 requiredSkills 제거 - recruitment.getRequiredSkills().clear(); - - // 새로운 requiredSkills 설정 - processRequiredSkills(recruitment, requestDto.getRequiredSkills()); - - // 이미지 업데이트 로직 - updateImages(recruitment, requestDto.getKeepImageIds(), requestDto.getNewImages()); - - return recruitmentMapper.toDetailResponseDto(recruitment); - } - - /** - * 게시글의 이미지를 업데이트합니다. - * @param recruitment 이미지를 업데이트할 Recruitment 엔티티 - * @param keepImageIds 유지할 기존 이미지의 ID 리스트 - * @param newImages 새로 추가할 이미지 파일 리스트 - */ - private void updateImages(Recruitment recruitment, List keepImageIds, List newImages) { - // 1. 기존 이미지 중 유지하지 않는 것만 soft-delete - List existingImages = recruitmentImageRepository.findByRecruitmentAndDeletedAtIsNull(recruitment); - List finalKeepIds = keepImageIds == null ? new ArrayList<>() : keepImageIds; - - List imagesToDelete = existingImages.stream() - .filter(img -> !finalKeepIds.contains(img.getId())) - .collect(Collectors.toList()); + // 이미지 업데이트 - 삭제 후 새 이미지 추가 + deleteUnusedImages(recruitment, requestDto.getKeepImageIds()); - if (!imagesToDelete.isEmpty()) { - imagesToDelete.forEach(RecruitmentImage::softDelete); - recruitmentImageRepository.saveAll(imagesToDelete); + if (!CollectionUtils.isEmpty(requestDto.getNewImages())) { + uploadAndSaveImages(recruitment, requestDto.getNewImages()); } - // 2. 새 이미지 S3에 업로드 및 DB에 저장 - if (!CollectionUtils.isEmpty(newImages)) { - String s3Path = "recruitments/" + recruitment.getId(); - List uploadedUrls = s3BucketService.upload(newImages, s3Path); - - List imagesToAdd = uploadedUrls.stream() - .map(url -> RecruitmentImage.builder().imageUrl(url).recruitment(recruitment).build()) - .collect(Collectors.toList()); - recruitmentImageRepository.saveAll(imagesToAdd); - } + return recruitmentMapper.toDetailResponseDto(recruitment); } /** @@ -223,127 +186,161 @@ public Page getRecommendedRecruitments(Long userId, User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + // 사용자 기술 스택 조회 Set userSkills = user.getSkills().stream() .map(userSkill -> DeveloperSkill.valueOf(userSkill.getSkill().toUpperCase())) .collect(Collectors.toSet()); - // Repository에서 기본 데이터만 조회 - List recruitments = recruitmentRepository.findAllByStatusWithRequiredSkills(RecruitmentStatus.RECRUITING); + // RECRUITING과 FORTHCOMING 상태인 공고만 조회 + List targetStatuses = List.of( + RecruitmentStatus.RECRUITING, + RecruitmentStatus.FORTHCOMING + ); + + List recruitments = recruitmentRepository.findAllByStatusIn(targetStatuses); + // 매칭 점수 계산 및 정렬 List matchedRecruitments = recruitments.stream() - .map(recruitment -> calculateRecommendationScore(recruitment, userSkills)) - .filter(dto -> dto.getMatchScore() > 0.0) // 하나라도 일치하는 기술이 있는 공고만 - .sorted((a, b) -> Double.compare(b.getMatchScore(), a.getMatchScore())) // 점수 높은 순 + .map(recruitment -> { + // 기본 DTO 생성 + RecruitmentListResponseDto dto = recruitmentMapper.toListResponseDto(recruitment); + + // 썸네일 URL 설정 + dto = addThumbnailUrl(dto, recruitment.getRecruitmentImages()); + + // 매칭 점수 계산 및 설정 + double matchScore = RecommendationScoreCalculator.calculate(recruitment, userSkills); + dto = withMatchScore(dto, matchScore); + + return dto; + }) + .filter(dto -> dto.getMatchScore() > 0.0) // 매칭 점수가 0인 경우 제외 + .sorted((a, b) -> Double.compare(b.getMatchScore(), a.getMatchScore())) // 점수 높은 순 정렬 .collect(Collectors.toList()); // 페이징 적용 int start = (int) pageable.getOffset(); - int end = Math.min(start + pageable.getPageSize(), scoredRecruitments.size()); - List pagedContent = scoredRecruitments.subList(start, end); + int end = Math.min(start + pageable.getPageSize(), matchedRecruitments.size()); + List pagedContent = start < matchedRecruitments.size() ? + matchedRecruitments.subList(start, end) : new ArrayList<>(); - return new PageImpl<>(pagedContent, pageable, scoredRecruitments.size()); + return new PageImpl<>(pagedContent, pageable, matchedRecruitments.size()); } - /** Helper Method */ - private RecruitmentListResponseDto addThumbnailUrl(RecruitmentListResponseDto dto) { - if (dto.getThumbnailUrl() == null) { - // 첫 번째 이미지 조회 - List images = recruitmentImageRepository.findByRecruitmentIdAndDeletedAtIsNull(dto.getId()); - String thumbnailUrl = images.stream() - .findFirst() - .map(RecruitmentImage::getImageUrl) - .orElse(null); - - return RecruitmentListResponseDto.builder() - .id(dto.getId()) - .title(dto.getTitle()) - .thumbnailUrl(thumbnailUrl) - .status(dto.getStatus()) - .languageTags(dto.getLanguageTags()) - .fieldTags(dto.getFieldTags()) - .startAt(dto.getStartAt()) - .endAt(dto.getEndAt()) - .recruitQuota(dto.getRecruitQuota()) - .build(); - } - return dto; - } + // =============== Private Helper Methods =============== - private void processRequiredSkills(Recruitment recruitment, Set requiredSkillsDto) { - if (requiredSkillsDto == null || requiredSkillsDto.isEmpty()) { - return; + /** + * DTO에 썸네일 URL을 추가합니다. + */ + private RecruitmentListResponseDto addThumbnailUrl(RecruitmentListResponseDto dto, List images) { + if (dto.getThumbnailUrl() != null) { + return dto; } - Set requiredSkills = requiredSkillsDto.stream() - .map(skill -> RecruitmentRequiredSkill.builder() - .recruitment(recruitment) - .skill(skill) - .category(SkillCategory.LANGUAGE) // 기본값으로 LANGUAGE 설정 - .weight(1.0) // 기본 가중치 - .build()) - .collect(Collectors.toSet()); + String thumbnailUrl = images.stream() + .filter(image -> image.getDeletedAt() == null) + .findFirst() + .map(RecruitmentImage::getImageUrl) + .orElse(null); - recruitment.setRequiredSkills(requiredSkills); + return withThumbnailUrl(dto, thumbnailUrl); } - private void validateRecruitmentDates(LocalDateTime startAt, LocalDateTime endAt) { - if (startAt.isAfter(endAt)) { - throw new GlobalException(ErrorCode.INVALID_DATE_RANGE); - } - if (endAt.isBefore(LocalDateTime.now())) { - throw new GlobalException(ErrorCode.END_DATE_IN_PAST); - } + /** + * DTO에 매칭 점수를 추가합니다. + */ + private RecruitmentListResponseDto withMatchScore(RecruitmentListResponseDto dto, Double matchScore) { + return RecruitmentListResponseDto.builder() + .id(dto.getId()) + .title(dto.getTitle()) + .thumbnailUrl(dto.getThumbnailUrl()) + .status(dto.getStatus()) + .languageTags(dto.getLanguageTags()) + .fieldTags(dto.getFieldTags()) + .startAt(dto.getStartAt()) + .endAt(dto.getEndAt()) + .recruitQuota(dto.getRecruitQuota()) + .views(dto.getViews()) + .matchScore(matchScore) + .build(); } + /** + * DTO에 썸네일 URL을 추가합니다. + */ + private RecruitmentListResponseDto withThumbnailUrl(RecruitmentListResponseDto dto, String thumbnailUrl) { + return RecruitmentListResponseDto.builder() + .id(dto.getId()) + .title(dto.getTitle()) + .thumbnailUrl(thumbnailUrl) + .status(dto.getStatus()) + .languageTags(dto.getLanguageTags()) + .fieldTags(dto.getFieldTags()) + .startAt(dto.getStartAt()) + .endAt(dto.getEndAt()) + .recruitQuota(dto.getRecruitQuota()) + .views(dto.getViews()) + .matchScore(dto.getMatchScore()) + .build(); + } - // 추천 공고 가중치 계산 - private RecruitmentListResponseDto calculateRecommendationScore(Recruitment recruitment, Set userSkills) { - double score = 0.0; - - // 1차 매칭: requiredSkills 기반 (가중치 적용) - Set requiredSkills = recruitment.getRequiredSkills(); - if (!requiredSkills.isEmpty()) { - score = calculateWeightedScore(requiredSkills, userSkills); - } else { - // 2차 매칭: languageTags 기반 (fallback) - score = calculateSimpleScore(recruitment.getLanguageTags(), userSkills); + /** + * 이미지 파일들을 S3에 업로드하고 DB에 저장합니다. + * @param recruitment 이미지를 연결할 공고 엔티티 + * @param imageFiles 업로드할 이미지 파일들 + * @return 저장된 이미지 엔티티 목록 + */ + private List uploadAndSaveImages(Recruitment recruitment, List imageFiles) { + if (CollectionUtils.isEmpty(imageFiles)) { + return Collections.emptyList(); } - // 최근 공고 보너스 - if (recruitment.getCreatedAt().isAfter(LocalDateTime.now().minusDays(7))) { - score = Math.min(score + 0.05, 1.0); - } + // S3에 이미지 업로드 + String s3Path = "recruitments/" + recruitment.getId(); + List uploadedUrls = s3BucketService.upload(imageFiles, s3Path); + + // 이미지 엔티티 생성 + List images = uploadedUrls.stream() + .map(url -> RecruitmentImage.builder() + .imageUrl(url) + .recruitment(recruitment) + .build()) + .collect(Collectors.toList()); - return recruitmentMapper.toListResponseDto(recruitment); + // DB에 저장 + return recruitmentImageRepository.saveAll(images); } - private double calculateWeightedScore(Set requiredSkills, Set userSkills) { - double totalWeight = 0.0; - double matchedWeight = 0.0; + /** + * 기존 이미지 중 유지할 ID 목록에 없는 이미지를 소프트 삭제합니다. + * + * @param recruitment 공고 엔티티 + * @param keepImageIds 유지할 이미지 ID 목록 + */ + private void deleteUnusedImages(Recruitment recruitment, List keepImageIds) { + List existingImages = recruitmentImageRepository.findByRecruitmentAndDeletedAtIsNull(recruitment); + List finalKeepIds = keepImageIds == null ? Collections.emptyList() : keepImageIds; - for (RecruitmentRequiredSkill required : requiredSkills) { - if (required.getCategory() == SkillCategory.LANGUAGE) { - double weight = required.getWeight(); // 기본값(1.0)이든 커스텀이든 상관없이 사용 - totalWeight += weight; + List imagesToDelete = existingImages.stream() + .filter(img -> !finalKeepIds.contains(img.getId())) + .collect(Collectors.toList()); - if (userSkills.contains(required.getSkill())) { - matchedWeight += weight; - } - } + if (!imagesToDelete.isEmpty()) { + imagesToDelete.forEach(RecruitmentImage::softDelete); + recruitmentImageRepository.saveAll(imagesToDelete); } - return totalWeight > 0 ? matchedWeight / totalWeight : 0.0; } - private double calculateSimpleScore(Set languageTags, Set userSkills) { - if (languageTags.isEmpty()) return 0.0; - - long matchCount = languageTags.stream() - .mapToLong(tag -> userSkills.contains(tag) ? 1 : 0) - .sum(); - - return (double) matchCount / languageTags.size(); + /** + * 모집 공고의 날짜 유효성을 검사합니다. + */ + private void validateRecruitmentDates(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt.isAfter(endAt)) { + throw new GlobalException(ErrorCode.INVALID_DATE_RANGE); + } + if (endAt.isBefore(LocalDateTime.now())) { + throw new GlobalException(ErrorCode.END_DATE_IN_PAST); + } } - -} - +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/util/RecommendationScoreCalculator.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/util/RecommendationScoreCalculator.java new file mode 100644 index 0000000..d8a7861 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/util/RecommendationScoreCalculator.java @@ -0,0 +1,91 @@ +package com.teamEWSN.gitdeun.Recruitment.service.util; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +public class RecommendationScoreCalculator { + + /** + * 모집 공고와 사용자 기술 간의 매칭 점수를 계산 + * @param recruitment 모집 공고 엔티티 + * @param userSkills 사용자 기술 스택 + * @return 0.0 ~ 1.0 사이의 매칭 점수 + */ + public static double calculate(Recruitment recruitment, Set userSkills) { + double score = 0.0; + + // 1. 기본 매칭 점수 계산 (단순 매칭만 사용) + score = calculateSimpleScore(recruitment.getLanguageTags(), userSkills); + + // 2. 상태별 점수 조정 + score = applyStatusAdjustment(score, recruitment); + + // 3. 날짜별 점수 조정 + score = applyDateAdjustment(score, recruitment); + + return score; + } + + /** + * 단순 매칭 점수 계산 (모든 기술의 중요도가 동일) + */ + private static double calculateSimpleScore(Set languageTags, Set userSkills) { + if (languageTags.isEmpty()) return 0.0; + + long matchCount = languageTags.stream() + .mapToLong(tag -> userSkills.contains(tag) ? 1 : 0) + .sum(); + + return (double) matchCount / languageTags.size(); + } + + /** + * 공고 상태에 따른 점수 조정 + */ + private static double applyStatusAdjustment(double score, Recruitment recruitment) { + // 모집 중인 공고가 모집 예정 공고보다 우선순위 높음 + if (recruitment.getStatus() == RecruitmentStatus.RECRUITING) { + score += 0.05; + } + + return Math.min(score, 1.0); + } + + /** + * 공고 등록일/마감일에 따른 점수 조정 + */ + private static double applyDateAdjustment(double score, Recruitment recruitment) { + // 최근 등록된 공고에 가산점 부여 + if (recruitment.getCreatedAt().isAfter(LocalDateTime.now().minusDays(7))) { + score += 0.05; + } + + // 마감이 임박한 경우 가산점 + if (recruitment.getStatus() == RecruitmentStatus.RECRUITING) { + long daysUntilEnd = ChronoUnit.DAYS.between(LocalDateTime.now(), recruitment.getEndAt()); + if (daysUntilEnd <= 2) { + score += 0.03; + } + } + + // 모집 예정인 공고는 시작일이 가까울수록 점수 상승 + if (recruitment.getStatus() == RecruitmentStatus.FORTHCOMING) { + long daysUntilStart = ChronoUnit.DAYS.between(LocalDateTime.now(), recruitment.getStartAt()); + + if (daysUntilStart <= 3) { + // 3일 이내에 시작하는 공고는 가산점 + score += 0.02; + } else if (daysUntilStart > 14) { + // 2주 이상 먼 공고는 감점 + score -= 0.05; + } + } + + return Math.min(Math.max(score, 0.0), 1.0); // 0~1 범위로 제한 + } +} From fa05caa0aa1ed23a51c437d1af49fc2c4304b168 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 6 Sep 2025 16:15:02 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EB=AA=A8=EC=A7=91=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ApplicationController.java | 139 +++++++- .../dto/ApplicationCreateRequestDto.java | 20 ++ .../dto/ApplicationListResponseDto.java | 30 ++ .../dto/ApplicationResponseDto.java | 40 +++ .../dto/ApplicationStatusUpdateDto.java | 15 + .../Application/mapper/ApplicationMapper.java | 31 ++ .../repository/ApplicationRepository.java | 48 +++ .../service/ApplicationService.java | 311 +++++++++++++++++- .../gitdeun/common/exception/ErrorCode.java | 19 +- .../notification/entity/NotificationType.java | 3 + 10 files changed, 651 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationCreateRequestDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationStatusUpdateDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java b/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java index 3094c23..4e84013 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java +++ b/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java @@ -1,4 +1,141 @@ package com.teamEWSN.gitdeun.Application.controller; +import com.teamEWSN.gitdeun.Application.dto.*; +import com.teamEWSN.gitdeun.Application.service.ApplicationService; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +@Tag(name = "Application", description = "모집 공고 지원 관련 API") public class ApplicationController { -} + + private final ApplicationService applicationService; + + /** + * 모집 공고에 지원하기 + */ + @PostMapping("/recruitments/{recruitmentId}/applications") + @Operation(summary = "모집 공고 지원", description = "특정 모집 공고에 지원합니다.") + public ResponseEntity createApplication( + @PathVariable Long recruitmentId, + @Valid @RequestBody ApplicationCreateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Creating application for recruitment: {} by user: {}", recruitmentId, userDetails.getId()); + ApplicationResponseDto response = applicationService.createApplication( + recruitmentId, requestDto, userDetails.getId() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 내 지원 목록 조회 + */ + @GetMapping("/users/me/applications") + @Operation(summary = "내 지원 목록 조회", description = "로그인한 사용자의 지원 목록을 조회합니다.") + public ResponseEntity> getMyApplications( + @PageableDefault(size = 10, sort = "createdAt,desc") Pageable pageable, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Getting applications for user: {}", userDetails.getId()); + Page applications = applicationService.getMyApplications( + userDetails.getId(), pageable + ); + return ResponseEntity.ok(applications); + } + + /** + * 특정 공고의 지원자 목록 조회 (모집자만 가능) + */ + @GetMapping("/recruitments/{recruitmentId}/applications") + @Operation(summary = "공고 지원자 목록 조회", description = "모집자가 자신의 공고에 지원한 지원자 목록을 조회합니다.") + public ResponseEntity> getRecruitmentApplications( + @PathVariable Long recruitmentId, + @PageableDefault(size = 10, sort = "createdAt,desc") Pageable pageable, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Getting applications for recruitment: {} by user: {}", recruitmentId, userDetails.getId()); + Page applications = applicationService.getRecruitmentApplications( + recruitmentId, userDetails.getId(), pageable + ); + return ResponseEntity.ok(applications); + } + + /** + * 지원 상세 조회 + */ + @GetMapping("/applications/{applicationId}") + @Operation(summary = "지원 상세 조회", description = "특정 지원의 상세 정보를 조회합니다.") + public ResponseEntity getApplication( + @PathVariable Long applicationId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Getting application: {} by user: {}", applicationId, userDetails.getId()); + ApplicationResponseDto application = applicationService.getApplication( + applicationId, userDetails.getId() + ); + return ResponseEntity.ok(application); + } + + /** + * 지원 철회 (지원자만 가능) + */ + @PatchMapping("/applications/{applicationId}/withdraw") + @Operation(summary = "지원 철회", description = "본인의 지원을 철회합니다.") + public ResponseEntity withdrawApplication( + @PathVariable Long applicationId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Withdrawing application: {} by user: {}", applicationId, userDetails.getId()); + applicationService.withdrawApplication(applicationId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + /** + * 지원 수락 (모집자만 가능) + */ + @PatchMapping("/applications/{applicationId}/accept") + @Operation(summary = "지원 수락", description = "모집자가 지원을 수락합니다.") + public ResponseEntity acceptApplication( + @PathVariable Long applicationId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Accepting application: {} by recruiter: {}", applicationId, userDetails.getId()); + ApplicationResponseDto response = applicationService.acceptApplication( + applicationId, userDetails.getId() + ); + return ResponseEntity.ok(response); + } + + /** + * 지원 거절 (모집자만 가능) + */ + @PatchMapping("/applications/{applicationId}/reject") + @Operation(summary = "지원 거절", description = "모집자가 지원을 거절합니다.") + public ResponseEntity rejectApplication( + @PathVariable Long applicationId, + @Valid @RequestBody ApplicationStatusUpdateDto updateDto, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Rejecting application: {} by recruiter: {}", applicationId, userDetails.getId()); + ApplicationResponseDto response = applicationService.rejectApplication( + applicationId, userDetails.getId(), updateDto + ); + return ResponseEntity.ok(response); + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationCreateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationCreateRequestDto.java new file mode 100644 index 0000000..c3570f3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationCreateRequestDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.Application.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApplicationCreateRequestDto { + + @NotNull(message = "지원 분야를 선택해주세요.") + private RecruitmentField appliedField; + + @Size(max = 1000, message = "지원 메시지는 1000자 이내로 작성해주세요.") + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java new file mode 100644 index 0000000..1eecd85 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java @@ -0,0 +1,30 @@ +package com.teamEWSN.gitdeun.Application.dto; + +import com.teamEWSN.gitdeun.Application.entity.ApplicationStatus; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApplicationListResponseDto { + + private Long applicationId; + + // 지원자 간략 정보 + private String applicantName; + private String applicantNickname; + private String applicantProfileImage; + + // 공고 간략 정보 + private String recruitmentTitle; + + // 지원 정보 + private RecruitmentField appliedField; + private ApplicationStatus status; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationResponseDto.java new file mode 100644 index 0000000..45b5750 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationResponseDto.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.Application.dto; + +import com.teamEWSN.gitdeun.Application.entity.ApplicationStatus; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApplicationResponseDto { + + private Long applicationId; + + // 지원자 정보 + private Long applicantId; + private String applicantName; + private String applicantEmail; + private String applicantNickname; + private String applicantProfileImage; + + // 공고 정보 + private Long recruitmentId; + private String recruitmentTitle; + private String recruiterName; + + // 지원 정보 + private RecruitmentField appliedField; + private String message; + private ApplicationStatus status; + private String rejectReason; + private boolean active; + + // 날짜 정보 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationStatusUpdateDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationStatusUpdateDto.java new file mode 100644 index 0000000..347ccb1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationStatusUpdateDto.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.Application.dto; + +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApplicationStatusUpdateDto { + + @Size(max = 500, message = "거절 사유는 500자 이내로 작성해주세요.") + private String rejectReason; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java b/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java new file mode 100644 index 0000000..0cab33f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java @@ -0,0 +1,31 @@ +package com.teamEWSN.gitdeun.Application.mapper; + +import com.teamEWSN.gitdeun.Application.dto.ApplicationListResponseDto; +import com.teamEWSN.gitdeun.Application.dto.ApplicationResponseDto; +import com.teamEWSN.gitdeun.Application.entity.Application; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface ApplicationMapper { + + @Mapping(source = "id", target = "applicationId") + @Mapping(source = "applicant.id", target = "applicantId") + @Mapping(source = "applicant.name", target = "applicantName") + @Mapping(source = "applicant.email", target = "applicantEmail") + @Mapping(source = "applicant.nickname", target = "applicantNickname") + @Mapping(source = "applicant.profileImage", target = "applicantProfileImage") + @Mapping(source = "recruitment.id", target = "recruitmentId") + @Mapping(source = "recruitment.title", target = "recruitmentTitle") + @Mapping(source = "recruitment.recruiter.name", target = "recruiterName") + ApplicationResponseDto toResponseDto(Application application); + + @Mapping(source = "id", target = "applicationId") + @Mapping(source = "applicant.name", target = "applicantName") + @Mapping(source = "applicant.nickname", target = "applicantNickname") + @Mapping(source = "applicant.profileImage", target = "applicantProfileImage") + @Mapping(source = "recruitment.title", target = "recruitmentTitle") + ApplicationListResponseDto toListResponseDto(Application application); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java b/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java index 8173c62..973d756 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java @@ -1,9 +1,57 @@ package com.teamEWSN.gitdeun.Application.repository; import com.teamEWSN.gitdeun.Application.entity.Application; +import com.teamEWSN.gitdeun.Application.entity.ApplicationStatus; +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public interface ApplicationRepository extends JpaRepository { + + // 특정 사용자의 모든 지원 내역 조회 (페이징) + Page findByApplicantOrderByCreatedAtDesc(User applicant, Pageable pageable); + + // 특정 사용자의 활성 지원 내역만 조회 + Page findByApplicantAndActiveTrueOrderByCreatedAtDesc(User applicant, Pageable pageable); + + // 특정 공고의 모든 지원자 목록 조회 (페이징) + Page findByRecruitmentOrderByCreatedAtDesc(Recruitment recruitment, Pageable pageable); + + // 특정 공고의 활성 지원자만 조회 + Page findByRecruitmentAndActiveTrueOrderByCreatedAtDesc(Recruitment recruitment, Pageable pageable); + + // 특정 공고의 상태별 지원자 조회 + List findByRecruitmentAndStatusAndActiveTrue(Recruitment recruitment, ApplicationStatus status); + + // 사용자가 특정 공고에 이미 지원했는지 확인 (활성 지원만) + boolean existsByRecruitmentAndApplicantAndActiveTrue(Recruitment recruitment, User applicant); + + // 특정 지원 조회 (지원자 본인 확인용) + Optional findByIdAndApplicant(Long id, User applicant); + + // 특정 지원 조회 (공고 작성자 확인용) + @Query("SELECT a FROM Application a WHERE a.id = :id AND a.recruitment.recruiter = :recruiter") + Optional findByIdAndRecruiter(@Param("id") Long id, @Param("recruiter") User recruiter); + + // 특정 사용자와 공고의 활성 지원 조회 + Optional findByRecruitmentAndApplicantAndActiveTrue(Recruitment recruitment, User applicant); + + // 공고별 상태별 지원자 수 통계 + @Query("SELECT a.status, COUNT(a) FROM Application a " + + "WHERE a.recruitment = :recruitment AND a.active = true " + + "GROUP BY a.status") + List countByRecruitmentGroupByStatus(@Param("recruitment") Recruitment recruitment); + + // 특정 공고의 수락된 지원자 수 + long countByRecruitmentAndStatusAndActiveTrue(Recruitment recruitment, ApplicationStatus status); + } diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java b/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java index ce1c652..4ccabd1 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java @@ -1,4 +1,313 @@ package com.teamEWSN.gitdeun.Application.service; +import com.teamEWSN.gitdeun.Application.dto.*; +import com.teamEWSN.gitdeun.Application.entity.Application; +import com.teamEWSN.gitdeun.Application.entity.ApplicationStatus; +import com.teamEWSN.gitdeun.Application.mapper.ApplicationMapper; +import com.teamEWSN.gitdeun.Application.repository.ApplicationRepository; +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentRepository; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.notification.dto.NotificationCreateDto; +import com.teamEWSN.gitdeun.notification.entity.NotificationType; +import com.teamEWSN.gitdeun.notification.service.NotificationService; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor public class ApplicationService { -} + + private final ApplicationRepository applicationRepository; + private final RecruitmentRepository recruitmentRepository; + private final UserRepository userRepository; + private final ApplicationMapper applicationMapper; + private final NotificationService notificationService; + + /** + * 모집 공고에 지원하기 + */ + @Transactional + public ApplicationResponseDto createApplication(Long recruitmentId, ApplicationCreateRequestDto requestDto, Long userId) { + // 지원자 정보 조회 + User applicant = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 모집 공고 정보 조회 + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> { + log.error("Recruitment not found with id: {}", recruitmentId); + return new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND); + }); + + // 모집 상태 확인 + if (recruitment.getStatus() != RecruitmentStatus.RECRUITING) { + log.warn("Application attempt to non-recruiting post: {}", recruitmentId); + throw new GlobalException(ErrorCode.RECRUITMENT_NOT_RECRUITING); + } + + // 모집 마감일 확인 + if (recruitment.getEndAt().isBefore(LocalDateTime.now())) { + log.warn("Application attempt to expired recruitment: {}", recruitmentId); + throw new GlobalException(ErrorCode.RECRUITMENT_EXPIRED); + } + + // 본인 공고 지원 불가 + if (recruitment.getRecruiter().getId().equals(userId)) { + log.warn("Self-application attempt by user: {}", userId); + throw new GlobalException(ErrorCode.CANNOT_APPLY_OWN_RECRUITMENT); + } + + // 중복 지원 확인 + if (applicationRepository.existsByRecruitmentAndApplicantAndActiveTrue(recruitment, applicant)) { + log.warn("Duplicate application attempt by user: {} to recruitment: {}", userId, recruitmentId); + throw new GlobalException(ErrorCode.ALREADY_APPLIED); + } + + // 지원 분야가 공고의 모집 분야에 포함되는지 확인 + if (!recruitment.getFieldTags().contains(requestDto.getAppliedField())) { + log.warn("Invalid field application: {} not in {}", requestDto.getAppliedField(), recruitment.getFieldTags()); + throw new GlobalException(ErrorCode.INVALID_APPLICATION_FIELD); + } + + // 모집 인원 확인 + if (recruitment.getRecruitQuota() <= 0) { + log.warn("Application to full recruitment: {}", recruitmentId); + throw new GlobalException(ErrorCode.RECRUITMENT_FULL); + } + + // 지원서 생성 + Application application = Application.builder() + .recruitment(recruitment) + .applicant(applicant) + .appliedField(requestDto.getAppliedField()) + .message(requestDto.getMessage()) + .status(ApplicationStatus.PENDING) + .active(true) + .build(); + + Application savedApplication = applicationRepository.save(application); + + // 모집 인원 감소 + recruitment.setRecruitQuota(recruitment.getRecruitQuota() - 1); + + // 모집자에게 알림 전송 + String notificationMessage = String.format( + "'%s'님이 '%s' 공고에 지원했습니다.", + applicant.getName(), + recruitment.getTitle() + ); + + notificationService.createAndSendNotification( + NotificationCreateDto.actionable( + recruitment.getRecruiter(), + NotificationType.APPLICATION_RECEIVED, + notificationMessage, + savedApplication.getId(), + null + ) + ); + + log.info("Application created successfully - ID: {}, User: {}, Recruitment: {}", + savedApplication.getId(), userId, recruitmentId); + + return applicationMapper.toResponseDto(savedApplication); + } + + /** + * 내 지원 목록 조회 + */ + @Transactional(readOnly = true) + public Page getMyApplications(Long userId, Pageable pageable) { + User applicant = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Page applications = applicationRepository + .findByApplicantAndActiveTrueOrderByCreatedAtDesc(applicant, pageable); + + return applications.map(applicationMapper::toListResponseDto); + } + + /** + * 특정 공고의 지원자 목록 조회 (모집자만 가능) + */ + @Transactional(readOnly = true) + public Page getRecruitmentApplications( + Long recruitmentId, Long userId, Pageable pageable) { + + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + // 모집자 본인 확인 + if (!recruitment.getRecruiter().getId().equals(userId)) { + log.warn("Unauthorized access to applications by user: {} for recruitment: {}", userId, recruitmentId); + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Page applications = applicationRepository + .findByRecruitmentAndActiveTrueOrderByCreatedAtDesc(recruitment, pageable); + + return applications.map(applicationMapper::toListResponseDto); + } + + /** + * 지원 상세 조회 + */ + @Transactional(readOnly = true) + public ApplicationResponseDto getApplication(Long applicationId, Long userId) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new GlobalException(ErrorCode.APPLICATION_NOT_FOUND)); + + // 지원자 본인 또는 모집자만 조회 가능 + boolean isApplicant = application.getApplicant().getId().equals(userId); + boolean isRecruiter = application.getRecruitment().getRecruiter().getId().equals(userId); + + if (!isApplicant && !isRecruiter) { + log.warn("Unauthorized access to application: {} by user: {}", applicationId, userId); + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + return applicationMapper.toResponseDto(application); + } + + /** + * 지원 철회 (지원자만 가능) + */ + @Transactional + public void withdrawApplication(Long applicationId, Long userId) { + Application application = applicationRepository.findByIdAndApplicant(applicationId, + userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID))) + .orElseThrow(() -> new GlobalException(ErrorCode.APPLICATION_NOT_FOUND)); + + // 이미 철회된 지원인지 확인 + if (!application.isActive()) { + log.warn("Attempt to withdraw already withdrawn application: {}", applicationId); + throw new GlobalException(ErrorCode.APPLICATION_ALREADY_WITHDRAWN); + } + + // ACCEPTED 상태는 철회 불가 + if (application.getStatus() == ApplicationStatus.ACCEPTED) { + log.warn("Attempt to withdraw accepted application: {}", applicationId); + throw new GlobalException(ErrorCode.CANNOT_WITHDRAW_ACCEPTED); + } + + // 지원 철회 처리 + application.withdraw(); + + // 모집 인원 복구 (PENDING 상태였던 경우만) + if (application.getStatus() == ApplicationStatus.PENDING) { + Recruitment recruitment = application.getRecruitment(); + recruitment.setRecruitQuota(recruitment.getRecruitQuota() + 1); + } + + log.info("Application withdrawn - ID: {}, User: {}", applicationId, userId); + } + + /** + * 지원 수락 (모집자만 가능) + */ + @Transactional + public ApplicationResponseDto acceptApplication(Long applicationId, Long userId) { + User recruiter = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Application application = applicationRepository.findByIdAndRecruiter(applicationId, recruiter) + .orElseThrow(() -> new GlobalException(ErrorCode.APPLICATION_NOT_FOUND)); + + // 활성 상태 확인 + if (!application.isActive()) { + throw new GlobalException(ErrorCode.APPLICATION_NOT_ACTIVE); + } + + // 이미 처리된 지원인지 확인 + if (application.getStatus() != ApplicationStatus.PENDING) { + throw new GlobalException(ErrorCode.APPLICATION_ALREADY_PROCESSED); + } + + // 수락 처리 + application.accept(); + + // 지원자에게 알림 전송 + String notificationMessage = String.format( + "'%s' 공고 지원이 수락되었습니다!", + application.getRecruitment().getTitle() + ); + + notificationService.createAndSendNotification( + NotificationCreateDto.simple( + application.getApplicant(), + NotificationType.APPLICATION_ACCEPTED, + notificationMessage + ) + ); + + log.info("Application accepted - ID: {}, Recruiter: {}", applicationId, userId); + + return applicationMapper.toResponseDto(application); + } + + /** + * 지원 거절 (모집자만 가능) + */ + @Transactional + public ApplicationResponseDto rejectApplication( + Long applicationId, Long userId, ApplicationStatusUpdateDto updateDto) { + + User recruiter = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Application application = applicationRepository.findByIdAndRecruiter(applicationId, recruiter) + .orElseThrow(() -> new GlobalException(ErrorCode.APPLICATION_NOT_FOUND)); + + // 활성 상태 확인 + if (!application.isActive()) { + throw new GlobalException(ErrorCode.APPLICATION_NOT_ACTIVE); + } + + // 이미 처리된 지원인지 확인 + if (application.getStatus() != ApplicationStatus.PENDING) { + throw new GlobalException(ErrorCode.APPLICATION_ALREADY_PROCESSED); + } + + // 거절 처리 + application.reject(updateDto.getRejectReason()); + + // 모집 인원 복구 + Recruitment recruitment = application.getRecruitment(); + recruitment.setRecruitQuota(recruitment.getRecruitQuota() + 1); + + // 지원자에게 알림 전송 + String notificationMessage = String.format( + "'%s' 공고 지원이 거절되었습니다.", + application.getRecruitment().getTitle() + ); + + if (updateDto.getRejectReason() != null && !updateDto.getRejectReason().isEmpty()) { + notificationMessage += " 사유: " + updateDto.getRejectReason(); + } + + notificationService.createAndSendNotification( + NotificationCreateDto.simple( + application.getApplicant(), + NotificationType.APPLICATION_REJECTED, + notificationMessage + ) + ); + + log.info("Application rejected - ID: {}, Recruiter: {}", applicationId, userId); + + return applicationMapper.toResponseDto(application); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 73580ee..ca8aeb2 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -61,6 +61,7 @@ public enum ErrorCode { NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION-001", "알림을 찾을 수 없습니다."), CANNOT_ACCESS_NOTIFICATION(HttpStatus.FORBIDDEN, "NOTIFICATION-002", "해당 알림에 접근할 권한이 없습니다."), + // 방문기록 관련 HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "VISITHISTORY-001", "방문 기록을 찾을 수 없습니다."), @@ -72,9 +73,21 @@ public enum ErrorCode { // 모집 공고 관련 RECRUITMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "RECRUIT-001", "모집 공고를 찾을 수 없습니다."), - INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "RECRUIT-002", "시작일은 마감일보다 빠를 수 없습니다."), - END_DATE_IN_PAST(HttpStatus.BAD_REQUEST, "RECRUIT-003", "마감일은 현재보다 미래여야 합니다."), - QUOTA_FILLED(HttpStatus.BAD_REQUEST, "RECRUIT-004", "모집 인원이 모두 충원되었습니다."), + RECRUITMENT_NOT_RECRUITING(HttpStatus.BAD_REQUEST, "RECRUIT-002", "모집 중인 공고가 아닙니다."), + RECRUITMENT_EXPIRED(HttpStatus.BAD_REQUEST, "RECRUIT-003", "모집 기간이 마감된 공고입니다."), + RECRUITMENT_FULL(HttpStatus.BAD_REQUEST, "RECRUIT-004", "모집 인원이 마감되었습니다."), + INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "RECRUIT-005", "시작일은 종료일보다 이전이어야 합니다."), + END_DATE_IN_PAST(HttpStatus.BAD_REQUEST, "RECRUIT-006", "종료일은 현재보다 이후여야 합니다."), + + // 지원 관련 + APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "APPLICATION-001", "요청한 지원 정보를 찾을 수 없습니다."), + ALREADY_APPLIED(HttpStatus.CONFLICT, "APPLICATION-002", "이미 지원한 공고입니다."), + CANNOT_APPLY_OWN_RECRUITMENT(HttpStatus.BAD_REQUEST, "APPLICATION-003", "본인의 공고에는 지원할 수 없습니다."), + INVALID_APPLICATION_FIELD(HttpStatus.BAD_REQUEST, "APPLICATION-004", "해당 공고에서 모집하지 않는 분야입니다."), + APPLICATION_ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "APPLICATION-005", "이미 철회된 지원입니다."), + CANNOT_WITHDRAW_ACCEPTED(HttpStatus.BAD_REQUEST, "APPLICATION-006", "수락된 지원은 철회할 수 없습니다."), + APPLICATION_NOT_ACTIVE(HttpStatus.BAD_REQUEST, "APPLICATION-007", "활성 상태가 아닌 지원입니다."), + APPLICATION_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "APPLICATION-008", "이미 처리된 지원입니다."), // S3 파일 관련 diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java b/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java index bf5bbc9..349ef80 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java @@ -3,5 +3,8 @@ public enum NotificationType { INVITE_MINDMAP, // 마인드맵 초대 MENTION_COMMENT, // 댓글에서 맨션 + APPLICATION_RECEIVED, // 지원 신청 + APPLICATION_ACCEPTED, // 신청 수락 + APPLICATION_REJECTED, // 신청 거절 SYSTEM_UPDATE; // 시스템 업데이트(webhook) }