diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java index a101d9be5..4fe26b889 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java @@ -9,6 +9,7 @@ import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_USER; import static in.koreatech.koin.global.code.ApiResponseCode.NO_CONTENT; import static in.koreatech.koin.global.code.ApiResponseCode.OK; +import static in.koreatech.koin.global.code.ApiResponseCode.REQUEST_TOO_FAST; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CLOSED; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_FORBIDDEN; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_DEADLINE_DATE; @@ -96,6 +97,7 @@ ResponseEntity getRecruitments( NOT_FOUND_USER, UNAUTHORIZED_USER, FORBIDDEN_USER_TYPE, + REQUEST_TOO_FAST, }) @Operation(summary = "모집글 작성", description = """ ### 모집글 작성 @@ -109,6 +111,7 @@ ResponseEntity getRecruitments( - 지원 마감일은 활동 시작일 이하, 활동 시작일은 활동 종료일 이하여야 합니다. - 모집글과 TEAM 채팅방을 같은 트랜잭션에서 생성하고 작성자를 최초 채팅방 멤버로 추가합니다. - 별도의 팀 채팅방 생성 API는 없습니다. + - 같은 사용자가 동일한 요청을 300ms 안에 반복하면 두 번째 요청은 `409 REQUEST_TOO_FAST`를 반환합니다. - 생성된 모집글 id만 반환합니다. """) @PostMapping diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentService.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentService.java index 4a1f62a37..bf4cab5bb 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentService.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentService.java @@ -43,6 +43,7 @@ import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.duplicate.DuplicateGuard; import in.koreatech.koin.global.exception.CustomException; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @@ -78,6 +79,10 @@ public class TeamRecruitmentService { * 모집글과 TEAM 채팅방, 작성자 멤버를 같은 트랜잭션에서 생성한다. */ @Transactional + @DuplicateGuard( + key = "'team-recruitment:create:' + #userId + ':' + #request.toString()", + timeoutSeconds = 300 + ) public IdResponse createRecruitment(Integer userId, CreateRecruitmentRequest request) { User author = userRepository.getById(userId); TeamRecruitment recruitment = request.toEntity(author); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java index 42773b2ff..fbc6dbc60 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java @@ -205,6 +205,73 @@ void createsGeneralRecruitment() throws Exception { .andExpect(status().isCreated()); } + @Test + @DisplayName("같은 사용자의 동일한 모집글 생성 요청이 연속되면 두 번째 요청은 409이고 side effect는 한 번만 발생한다") + void rejectsImmediateDuplicateRequestWithoutDuplicateSideEffects() throws Exception { + String body = generalBody(5); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("REQUEST_TOO_FAST")); + + entityManager.flush(); + entityManager.clear(); + assertThat(recruitmentGraphRowCounts()).containsExactly(1L, 0L, 1L, 1L); + } + + @Test + @DisplayName("같은 사용자의 서로 다른 모집글 생성 요청은 연속되어도 허용한다") + void allowsImmediateRequestWithDifferentPayload() throws Exception { + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(generalBody(4))) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(generalBody(5))) + .andExpect(status().isCreated()); + + entityManager.flush(); + entityManager.clear(); + assertThat(recruitmentGraphRowCounts()).containsExactly(2L, 0L, 2L, 2L); + } + + @Test + @DisplayName("동일한 모집글 생성 요청도 300ms가 지나면 허용한다") + void allowsSameRequestAfterDuplicateGuardWindow() throws Exception { + String body = generalBody(5); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + Thread.sleep(350); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + entityManager.flush(); + entityManager.clear(); + assertThat(recruitmentGraphRowCounts()).containsExactly(2L, 0L, 2L, 2L); + } + @Test @DisplayName("미인증 요청은 401 이다") void unauthenticated() throws Exception {