Skip to content

Commit 6f3a447

Browse files
authored
feat: 확장된 모집 공고 기준으로 지원 프로필 저장 (#543)
* Route profile saves through recruit-specific apply records Profile saving now uses recruitId as the authoritative recruitment context so one member can hold separate application records for concurrent active notices without the legacy active-apply lookup collapsing them together. Constraint: Keep the existing /apply/profile path and add only the recruitId query parameter for this slice Rejected: Continue resolving recruit by request jobFamily | it cannot distinguish concurrent notices with the same or different 모집 유형 Confidence: high Scope-risk: moderate Directive: Do not remove the jobFamily mismatch validation until profile payloads stop carrying jobFamily Tested: ./gradlew test --tests org.ject.support.domain.apply.controller.ApplyControllerTest --tests org.ject.support.domain.apply.service.ApplyServiceTest --tests org.ject.support.domain.apply.repository.ApplyRepositoryTest -x jacocoTestCoverageVerification --rerun-tasks Not-tested: Full test suite; partial test runs fail Jacoco bundle coverage when coverage verification is enabled Related: #542 * Preserve legacy profile entry while preferring recruit context Review feedback showed that profile save can still be reached without recruitId through non-standard frontend entry points. The endpoint now accepts a missing recruitId and falls back to the existing jobFamily-based recruit lookup, while recruitId flows keep the recruit record as the source of member jobFamily. Constraint: Keep /apply/profile compatible for frontend transition Rejected: Resolve missing recruitId by memberId only | a member can have multiple active applications, and first-time profile saves may not have any Apply row yet Rejected: Remove duplicate insert handling | optimistic locking covers existing-row updates, not concurrent first inserts before the Apply row exists Confidence: high Scope-risk: narrow Tested: ./gradlew test --tests org.ject.support.domain.apply.controller.ApplyControllerTest --tests org.ject.support.domain.apply.service.ApplyServiceTest --tests org.ject.support.domain.apply.repository.ApplyRepositoryTest -x jacocoTestCoverageVerification --rerun-tasks Not-tested: Full test suite Related: #542 * Clarify recruit fallback during profile save Review feedback showed the recruit lookup path looked like an unused jobFamily parameter and the repository existence check used an overly long derived method. Move the fallback decision to the call site and use a JPQL exists query to preserve the same behavior with clearer intent. Constraint: Keep existing /apply/profile clients working when recruitId is absent during frontend transition Rejected: Require recruitId immediately | would break current profile entry paths Rejected: Throw on duplicate apply creation | duplicate insert race means the desired apply already exists, so profile save should remain idempotent Confidence: high Scope-risk: narrow Tested: ./gradlew test --tests org.ject.support.domain.apply.controller.ApplyControllerTest --tests org.ject.support.domain.apply.service.ApplyServiceTest --tests org.ject.support.domain.apply.repository.ApplyRepositoryTest -x jacocoTestCoverageVerification --rerun-tasks * Require recruit context for profile saves Expanded recruitment means jobFamily no longer identifies a single active notice. Profile save now requires recruitId and resolves the recruitment only from that identifier, leaving request jobFamily as payload compatibility rather than lookup context. Constraint: Keep the existing /apply/profile URL while requiring the recruitId query parameter Rejected: Fallback to jobFamily lookup | concurrent SEMESTER/MAKERS/SUPPORTERS notices can share the same job family Confidence: high Scope-risk: narrow Tested: ./gradlew test --tests org.ject.support.domain.apply.controller.ApplyControllerTest --tests org.ject.support.domain.apply.service.ApplyServiceTest --tests org.ject.support.domain.apply.repository.ApplyRepositoryTest -x jacocoTestCoverageVerification --rerun-tasks
1 parent d87d55d commit 6f3a447

8 files changed

Lines changed: 245 additions & 19 deletions

File tree

src/main/java/org/ject/support/domain/apply/controller/ApplyApiSpec.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ ApplyStatusResponse checkApplyStatus(@AuthPrincipal Long memberId,
5252

5353
@Operation(
5454
summary = "프로필 작성(저장)",
55-
description = "지원자의 프로필을 작성(저장)합니다."
55+
description = "지원자의 특정 공고에 대한 프로필을 작성(저장)합니다."
5656
)
5757
void saveProfile(@AuthPrincipal Long memberId,
58+
@RequestParam Long recruitId,
5859
@RequestBody @Valid ApplyProfileRequest request);
5960
}

src/main/java/org/ject/support/domain/apply/controller/ApplyController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ public ApplyStatusResponse checkApplyStatus(@AuthPrincipal Long memberId,
6868
@PostMapping("/profile")
6969
@PreAuthorize("hasRole('ROLE_APPLY')")
7070
public void saveProfile(@AuthPrincipal Long memberId,
71+
@RequestParam Long recruitId,
7172
@RequestBody @Valid ApplyProfileRequest request
7273
) {
73-
applyUsecase.saveProfile(memberId, request);
74+
applyUsecase.saveProfile(memberId, recruitId, request);
7475
}
7576
}

src/main/java/org/ject/support/domain/apply/repository/ApplyRepository.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
import org.ject.support.domain.recruit.domain.Recruit;
66
import org.springframework.data.jpa.repository.JpaRepository;
77
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
89

910
import java.time.LocalDateTime;
1011
import java.util.List;
1112
import java.util.Optional;
12-
import org.springframework.data.repository.query.Param;
1313

1414
public interface ApplyRepository extends JpaRepository<Apply, Long> {
1515

@@ -29,6 +29,16 @@ Optional<Apply> findByMemberIdAndRecruitIdInActiveRecruit(@Param("memberId") Lon
2929
@Query("select count(a) > 0 from Apply a join a.recruit r where a.member.id = :memberId and r.startDate <= :now and r.endDate >= :now")
3030
boolean existsByMemberIdInActiveRecruit(@Param("memberId") Long memberId, @Param("now") LocalDateTime now);
3131

32+
@Query("""
33+
select exists (
34+
select 1 from Apply a join a.recruit r
35+
where a.member.id = :memberId and r.id = :recruitId and r.startDate <= :now and r.endDate >= :now
36+
)
37+
""")
38+
boolean existsByMemberIdAndRecruitIdInActiveRecruit(@Param("memberId") Long memberId,
39+
@Param("recruitId") Long recruitId,
40+
@Param("now") LocalDateTime now);
41+
3242
List<Apply> findByRecruitAndStatus(Recruit recruit, ApplyStatus status);
3343

3444
@Query("select a from Apply a join fetch a.member m where a.id = :applyId and a.status = :status")

src/main/java/org/ject/support/domain/apply/service/ApplyService.java

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import java.time.LocalDateTime;
3737
import java.util.List;
3838
import java.util.Map;
39+
import java.util.Optional;
3940

4041
import static org.ject.support.domain.apply.domain.ApplyStatus.JOINED;
4142
import static org.ject.support.domain.apply.domain.ApplyStatus.SUBMITTED;
@@ -192,17 +193,18 @@ public ApplyStatusResponse checkApplyStatus(Long memberId, Long recruitId) {
192193
@Override
193194
@PeriodAccessible(permitAllJob = true)
194195
@Transactional
195-
public void saveProfile(Long memberId, ApplyProfileRequest request) {
196+
public void saveProfile(Long memberId, Long recruitId, ApplyProfileRequest request) {
196197
var member = memberRepository.findById(memberId)
197198
.orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND_MEMBER));
199+
Recruit recruit = getActiveRecruit(recruitId);
198200

199-
createApplyIfNotExists(member, request.jobFamily());
201+
createApplyIfNotExists(member, recruit);
200202

201203
var memberEditorBuilder = member.toEditor();
202204
var memberEditor = memberEditorBuilder
203205
.name(request.name())
204206
.phoneNumber(request.phoneNumber())
205-
.jobFamily(request.jobFamily())
207+
.jobFamily(recruit.getJobFamily())
206208
.region(request.region())
207209
.careerDetails(request.careerDetails())
208210
.experiencePeriod(request.experiencePeriod())
@@ -212,14 +214,16 @@ public void saveProfile(Long memberId, ApplyProfileRequest request) {
212214
member.edit(memberEditor);
213215
}
214216

215-
private void createApplyIfNotExists(Member member, JobFamily jobFamily) {
216-
if (!applyRepository.existsByMemberIdInActiveRecruit(member.getId(), LocalDateTime.now())) {
217+
private void createApplyIfNotExists(Member member, Recruit recruit) {
218+
boolean exists = applyRepository.existsByMemberIdAndRecruitIdInActiveRecruit(
219+
member.getId(), recruit.getId(), LocalDateTime.now());
220+
if (!exists) {
217221
try {
218-
var recruit = getPeriodRecruit(jobFamily);
219222
var newApply = Apply.createApply(member, recruit);
220223
applyRepository.save(newApply);
221224
} catch (DataIntegrityViolationException e) {
222-
log.warn("지원서 생성 중 레이스 컨디션 발생. memberId: {}. 이미 지원서가 존재합니다.", member.getId());
225+
log.warn("지원서 생성 중 중복 생성 충돌 발생. memberId: {}, recruitId: {}. 이미 지원서가 존재합니다.",
226+
member.getId(), recruit.getId());
223227
}
224228
}
225229
}
@@ -238,6 +242,11 @@ private Recruit getPeriodRecruit(final JobFamily jobFamily) {
238242
.orElseThrow(() -> new RecruitException(RecruitErrorCode.NOT_FOUND_RECRUIT));
239243
}
240244

245+
private Recruit getActiveRecruit(final Long recruitId) {
246+
return Optional.ofNullable(recruitRepository.findActiveRecruitById(recruitId, LocalDateTime.now()))
247+
.orElseThrow(() -> new RecruitException(RecruitErrorCode.NOT_FOUND_RECRUIT));
248+
}
249+
241250
private List<Portfolio> getNewPortfolios(List<ApplyPortfolioDto> portfolios) {
242251
return portfolios.stream()
243252
.map(ApplyPortfolioDto::toEntity)

src/main/java/org/ject/support/domain/apply/service/ApplyUsecase.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ void submitApplication(Long memberId,
2626

2727
ApplyStatusResponse checkApplyStatus(Long memberId, Long recruitId);
2828

29-
void saveProfile(Long memberId, ApplyProfileRequest request);
29+
void saveProfile(Long memberId, Long recruitId, ApplyProfileRequest request);
3030
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package org.ject.support.domain.apply.controller;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import org.ject.support.base.UnitTestSupport;
5+
import org.ject.support.common.response.ResponseWrapper;
6+
import org.ject.support.common.security.AuthenticatedMemberIdResolver;
7+
import org.ject.support.common.security.CustomUserDetails;
8+
import org.ject.support.domain.apply.dto.ApplyProfileRequest;
9+
import org.ject.support.domain.apply.service.ApplyUsecase;
10+
import org.ject.support.domain.member.CareerDetails;
11+
import org.ject.support.domain.member.ExperiencePeriod;
12+
import org.ject.support.domain.member.InterestedDomain;
13+
import org.ject.support.domain.member.JobFamily;
14+
import org.ject.support.domain.member.Region;
15+
import org.ject.support.domain.member.Role;
16+
import org.junit.jupiter.api.AfterEach;
17+
import org.junit.jupiter.api.BeforeEach;
18+
import org.junit.jupiter.api.Test;
19+
import org.mockito.InjectMocks;
20+
import org.mockito.Mock;
21+
import org.springframework.http.MediaType;
22+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
23+
import org.springframework.security.core.context.SecurityContextHolder;
24+
import org.springframework.test.web.servlet.MockMvc;
25+
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
26+
27+
import java.util.List;
28+
29+
import static org.mockito.ArgumentMatchers.any;
30+
import static org.mockito.ArgumentMatchers.eq;
31+
import static org.mockito.Mockito.verify;
32+
import static org.mockito.Mockito.verifyNoInteractions;
33+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
34+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
35+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
36+
37+
class ApplyControllerTest extends UnitTestSupport {
38+
39+
@InjectMocks
40+
ApplyController applyController;
41+
42+
@Mock
43+
ApplyUsecase applyUsecase;
44+
45+
MockMvc mockMvc;
46+
ObjectMapper objectMapper;
47+
48+
@BeforeEach
49+
void setUp() {
50+
objectMapper = new ObjectMapper();
51+
mockMvc = MockMvcBuilders.standaloneSetup(applyController)
52+
.setControllerAdvice(new ResponseWrapper())
53+
.setCustomArgumentResolvers(new AuthenticatedMemberIdResolver())
54+
.build();
55+
}
56+
57+
@AfterEach
58+
void tearDown() {
59+
SecurityContextHolder.clearContext();
60+
}
61+
62+
@Test
63+
void 프로필을_모집_공고_기준으로_저장한다() throws Exception {
64+
// given
65+
long memberId = 1L;
66+
long recruitId = 10L;
67+
setAuthentication(memberId);
68+
ApplyProfileRequest request = new ApplyProfileRequest(
69+
"김젝트",
70+
"010-1234-5678",
71+
JobFamily.FE,
72+
Region.SEOUL,
73+
CareerDetails.STUDENT,
74+
ExperiencePeriod.NONE,
75+
List.of(InterestedDomain.GAME.getDescription())
76+
);
77+
78+
// when, then
79+
mockMvc.perform(post("/apply/profile")
80+
.param("recruitId", String.valueOf(recruitId))
81+
.contentType(MediaType.APPLICATION_JSON)
82+
.content(objectMapper.writeValueAsString(request)))
83+
.andExpect(status().isOk())
84+
.andExpect(jsonPath("$.status").value("SUCCESS"));
85+
86+
verify(applyUsecase).saveProfile(eq(memberId), eq(recruitId), any(ApplyProfileRequest.class));
87+
}
88+
89+
@Test
90+
void 모집_공고_식별자가_없으면_프로필_저장에_실패한다() throws Exception {
91+
// given
92+
long memberId = 1L;
93+
setAuthentication(memberId);
94+
ApplyProfileRequest request = new ApplyProfileRequest(
95+
"김젝트",
96+
"010-1234-5678",
97+
JobFamily.FE,
98+
Region.SEOUL,
99+
CareerDetails.STUDENT,
100+
ExperiencePeriod.NONE,
101+
List.of(InterestedDomain.GAME.getDescription())
102+
);
103+
104+
// when, then
105+
mockMvc.perform(post("/apply/profile")
106+
.contentType(MediaType.APPLICATION_JSON)
107+
.content(objectMapper.writeValueAsString(request)))
108+
.andExpect(status().isBadRequest());
109+
110+
verifyNoInteractions(applyUsecase);
111+
}
112+
113+
private void setAuthentication(final Long memberId) {
114+
CustomUserDetails userDetails = new CustomUserDetails("apply@ject.kr", memberId, Role.APPLY);
115+
var authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
116+
SecurityContextHolder.getContext().setAuthentication(authentication);
117+
}
118+
}

src/test/java/org/ject/support/domain/apply/repository/ApplyRepositoryTest.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.List;
2323

2424
import static org.assertj.core.api.Assertions.assertThat;
25+
import static org.ject.support.domain.apply.domain.ApplyStatus.JOINED;
2526
import static org.ject.support.domain.apply.domain.ApplyStatus.SUBMITTED;
2627
import static org.ject.support.domain.apply.domain.ApplyStatus.TEMP_SAVED;
2728
import static org.ject.support.domain.member.JobFamily.BE;
@@ -106,6 +107,27 @@ void setUp() {
106107
assertThat(count).isEqualTo(1L);
107108
}
108109

110+
@Test
111+
void 회원과_모집_공고를_바탕으로_활성_지원정보_존재_여부를_조회한다() {
112+
// given
113+
Member applicant = createMember("email@test.com", Role.APPLY);
114+
memberRepository.save(applicant);
115+
116+
Apply feApply = getApply(applicant, feRecruit, JOINED);
117+
Apply beApply = getApply(applicant, beRecruit, JOINED);
118+
applyRepository.saveAll(List.of(feApply, beApply));
119+
120+
// when
121+
boolean exists = applyRepository.existsByMemberIdAndRecruitIdInActiveRecruit(
122+
applicant.getId(), beRecruit.getId(), LocalDateTime.now());
123+
boolean notExists = applyRepository.existsByMemberIdAndRecruitIdInActiveRecruit(
124+
applicant.getId(), pdRecruit.getId(), LocalDateTime.now());
125+
126+
// then
127+
assertThat(exists).isTrue();
128+
assertThat(notExists).isFalse();
129+
}
130+
109131
@Test
110132
void 지원ID와_지원서의_상태로_지원서를_상세_조회한다() {
111133
// given
@@ -154,4 +176,4 @@ private Member createMember(String email, Role role) {
154176
.status(MemberStatus.ACTIVE)
155177
.build();
156178
}
157-
}
179+
}

0 commit comments

Comments
 (0)