Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// src/main/java/com/chaineeproject/chainee/controller/EmployerApplicationController.java
package com.chaineeproject.chainee.controller;

import com.chaineeproject.chainee.dto.ApplicationAcceptResponse;
import com.chaineeproject.chainee.dto.PostApplicantsView;
import com.chaineeproject.chainee.dto.ResumeView;
import com.chaineeproject.chainee.service.EmployerApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api") // 메서드별로 풀 패스 지정
@Tag(name = "Employer Application", description = "구인자용 지원/관리 API")
@SecurityRequirement(name = "bearerAuth")
public class EmployerApplicationController {

private final EmployerApplicationService employerApplicationService;

private Long meId(Jwt jwt) { return Long.valueOf(jwt.getSubject()); }

/** 지원 수락 */
@PostMapping("/job/applications/{applicationId}/accept")
@Operation(summary = "지원 수락",
description = "구인자(작성자)가 특정 지원서를 수락합니다. 응답으로 구직자 DID, payment, deadline을 반환합니다.")
public ResponseEntity<ApplicationAcceptResponse> accept(
@AuthenticationPrincipal Jwt jwt,
@PathVariable Long applicationId
) {
var res = employerApplicationService.acceptApplication(applicationId, meId(jwt));
return ResponseEntity.ok(res);
}

/** 공고 지원자 관리 목록 */
@GetMapping("/job/posts/{postId}/applicants")
@Operation(summary = "공고 지원자 관리 목록",
description = "공고 제목, 총 지원자 수, 지원자 리스트(이름/지원 날짜/positions)를 반환합니다.")
public ResponseEntity<PostApplicantsView> applicantsOfPost(
@AuthenticationPrincipal Jwt jwt,
@PathVariable Long postId
) {
return ResponseEntity.ok(
employerApplicationService.getApplicantsOfPost(meId(jwt), postId)
);
}

/** 구인자 전용 이력서 열람 (applicationId로 접근) */
@GetMapping("/job/applications/{applicationId}/resume")
@Operation(summary = "지원서 기반 이력서 조회(구인자용)",
description = "공고 작성자가 자신에게 접수된 지원서의 이력서를 열람합니다.")
public ResponseEntity<ResumeView> getResumeForEmployer(
@AuthenticationPrincipal Jwt jwt,
@PathVariable Long applicationId
) {
return ResponseEntity.ok(
employerApplicationService.getResumeForEmployer(meId(jwt), applicationId)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class ResumeController {
schema = @Schema(implementation = ResumeCreateRequest.class),
examples = @ExampleObject(value = """
{
"applicantDid": "did:example:xyz789",
"applicantId": 123,
"title": "프론트엔드 개발자 이력서",
"name": "홍길동",
"introduction": "React와 Next.js 기반 프로젝트를 다수 진행했습니다.",
Expand All @@ -53,12 +53,9 @@ public class ResumeController {
schema = @Schema(implementation = ResumeResponse.class)))
)
public ResponseEntity<ResumeResponse> createResume(
@AuthenticationPrincipal Jwt jwt, // 보안정책상 /api/resumes/** 가 authenticated 라면 주입
@AuthenticationPrincipal Jwt jwt, // 인증 정책에 따라 사용 여부 결정
@Valid @RequestBody ResumeCreateRequest request
) {
// 현재 정책이 DID 기반 생성이면 uid 없이 진행해도 됨.
// 만약 로그인 사용자의 소유로도 연결하려면 아래처럼 꺼내서 service로 전달:
// Long currentUserId = com.chaineeproject.chainee.security.SecurityUtils.uidOrNull(jwt);
Long resumeId = resumeService.createResume(request);
return ResponseEntity.ok(new ResumeResponse(true, "RESUME_CREATED", resumeId));
}
Expand Down Expand Up @@ -112,31 +109,6 @@ public ResponseEntity<ResumeView> getMyResume(
return ResponseEntity.ok(resumeService.getMyResume(currentUserId, resumeId));
}

// ===== 조회(공고 작성자: 지원서 통해 접근) =====
@GetMapping("/applications/{applicationId}")
@Operation(
summary = "지원서 기반 이력서 조회(공고 작성자용)",
description = """
공고 작성자가 자신에게 접수된 지원서의 이력서를 열람합니다.
- 현재 로그인 사용자는 해당 지원서의 공고 작성자여야 합니다.
""",
responses = {
@ApiResponse(responseCode = "200", content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ResumeView.class))),
@ApiResponse(responseCode = "403", description = "권한 없음"),
@ApiResponse(responseCode = "404", description = "지원서/이력서 없음")
}
)
public ResponseEntity<ResumeView> getResumeForPostAuthor(
@AuthenticationPrincipal Jwt jwt,
@PathVariable Long applicationId
) {
Long currentUserId = com.chaineeproject.chainee.security.SecurityUtils.uidOrNull(jwt);
if (currentUserId == null) return ResponseEntity.status(401).build();
return ResponseEntity.ok(resumeService.getResumeByApplicationForPostAuthor(currentUserId, applicationId));
}

// ===== 수정(본인) =====
@PutMapping("/{resumeId}")
@Operation(
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/chaineeproject/chainee/dto/ApplicantSummary.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// src/main/java/com/chaineeproject/chainee/dto/ApplicantSummary.java
package com.chaineeproject.chainee.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;

public record ApplicantSummary(
@Schema(example = "555") Long applicationId,
@Schema(example = "3") Long applicantId,
@Schema(example = "홍길동") String name,
@Schema(example = "2025-09-02T10:00:00") LocalDateTime appliedAt,
@Schema(example = "[\"Backend\",\"DevOps\"]") List<String> positions
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// src/main/java/com/chaineeproject/chainee/dto/ApplicationAcceptResponse.java
package com.chaineeproject.chainee.dto;

import io.swagger.v3.oas.annotations.media.Schema;

import java.math.BigDecimal;
import java.time.LocalDate;

public record ApplicationAcceptResponse(
@Schema(description = "처리 성공 여부", example = "true")
boolean success,
@Schema(description = "지원 상태", example = "accepted")
String status,
@Schema(description = "구직자 DID 주소", example = "did:key:z6Mkv...")
String applicantDid,
@Schema(description = "공고 지급 금액", example = "1500000")
BigDecimal payment,
@Schema(description = "공고 마감일(채용 마감)", example = "2025-09-30")
LocalDate deadline
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// src/main/java/com/chaineeproject/chainee/dto/PostApplicantsView.java
package com.chaineeproject.chainee.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

public record PostApplicantsView(
@Schema(example = "101") Long postId,
@Schema(example = "백엔드 개발자 구합니다") String postTitle,
@Schema(example = "7") long totalApplicants,
List<ApplicantSummary> applicants
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ public enum ErrorCode {
RESUME_NOT_FOUND(HttpStatus.NOT_FOUND, "선택한 이력서를 찾을 수 없습니다."),
JOB_POST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 ID의 구인 공고를 찾을 수 없습니다."),
APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 ID의 지원서를 찾을 수 없습니다."),
JOB_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 이력서를 찾을 수 없습니다."),

RESUME_NOT_OWNED_BY_APPLICANT(HttpStatus.FORBIDDEN, "해당 이력서는 지원자 본인의 것이 아닙니다."),
CANNOT_APPLY_OWN_POST(HttpStatus.FORBIDDEN, "본인이 작성한 공고에는 지원할 수 없습니다."),
JOB_POST_CLOSED(HttpStatus.BAD_REQUEST, "해당 공고의 마감일이 지났습니다."),
DUPLICATE_APPLICATION(HttpStatus.CONFLICT, "이미 해당 공고에 지원했습니다."),

INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.");
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
APPLICANT_DID_REQUIRED(HttpStatus.BAD_REQUEST, "지원자의 DID가 등록 또는 검증되지 않았습니다."),
INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "현재 상태에서 요청하신 상태 변경을 수행할 수 없습니다.");

private final HttpStatus status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// JobApplicationRepository.java
// src/main/java/com/chaineeproject/chainee/repository/JobApplicationRepository.java
package com.chaineeproject.chainee.repository;

import com.chaineeproject.chainee.entity.JobApplication;
Expand All @@ -10,26 +10,22 @@

public interface JobApplicationRepository extends JpaRepository<JobApplication, Long> {

// (기존) 파서 방식 — 필요 없으면 지워도 됨
/**
* [유지] 서비스에서 사용 중: 중복 지원 방지 체크
* JobApplicationService.applyToJob() 에서 사용
*/
boolean existsByPostIdAndApplicantId(Long postId, Long applicantId);
long countByPostId(Long postId);

// ✅ 언더스코어 없이 JPQL로 직접 정의
@Query("""
select (count(a) > 0)
from JobApplication a
where a.post.id = :postId and a.applicant.id = :applicantId
""")
boolean existsForPostAndApplicant(@Param("postId") Long postId,
@Param("applicantId") Long applicantId);

@Query("""
select count(a)
from JobApplication a
where a.post.id = :postId
""")
long countByPost(@Param("postId") Long postId);
/**
* [유지] 공고 지원자 수 조회(집계용)
* JobPostRepository.incrementApplicantCount(...)와 함께 사용 가능
*/
long countByPostId(Long postId);

/**
* [선택 유지] postId + applicantId로 지원서 단건 조회
* 필요 시 사용(예: 상태 확인/수정)
*/
@Query("""
select a
from JobApplication a
Expand All @@ -38,6 +34,10 @@ select count(a)
Optional<JobApplication> findByPostAndApplicant(@Param("postId") Long postId,
@Param("applicantId") Long applicantId);

/**
* [핵심] 지원 수락 API 용: 지원서 + 공고 + 작성자 + 지원자까지 전부 즉시 로딩
* EmployerApplicationService.acceptApplication(...) 에서 사용
*/
@Query("""
select a
from JobApplication a
Expand All @@ -46,5 +46,27 @@ Optional<JobApplication> findByPostAndApplicant(@Param("postId") Long postId,
join fetch a.applicant applicant
where a.id = :id
""")
Optional<JobApplication> findWithPostAndUsersById(@Param("id") Long id);
Optional<JobApplication> findByIdWithPostAndUsers(@Param("id") Long id);

@Query("""
select a
from JobApplication a
join fetch a.applicant u
join a.post p
where p.id = :postId
order by a.createdAt desc, a.id desc
""")
java.util.List<com.chaineeproject.chainee.entity.JobApplication>
findAllByPostIdWithApplicant(@Param("postId") Long postId);

@Query("""
select a
from JobApplication a
join fetch a.post p
join fetch p.author author
join fetch a.applicant applicant
join fetch a.resume r
where a.id = :id
""")
Optional<JobApplication> findByIdWithPostApplicantAndResume(@Param("id") Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public Conversation startConversation(Long postId, Long applicantId, Long reques
}

// 지원 여부 검증
boolean hasApplied = jobAppRepo.existsForPostAndApplicant(postId, applicantId);
boolean hasApplied = jobAppRepo.existsByPostIdAndApplicantId(postId, applicantId);
if (!hasApplied) throw new IllegalStateException("APPLICANT_NOT_APPLIED");

// applicant 프록시 참조(TransientProperty 방지)
Expand Down
Loading