From d241a089518ae4ec0106f4548b4d320efe963e3c Mon Sep 17 00:00:00 2001 From: yong203 Date: Thu, 18 Jun 2026 01:38:27 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat:=20=EC=9E=90=EA=B8=B0?= =?UTF-8?q?=EC=86=8C=EA=B0=9C=EC=84=9C=20soft=20delete=20API=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 --- docs/api/README.md | 2 +- docs/api/cover-letters.md | 21 +++++++++ docs/status.md | 3 +- .../controller/CoverLetterController.java | 8 ++++ .../dto/DeleteCoverLetterResponse.java | 20 ++++++++ .../repository/CoverLetterRepository.java | 4 ++ .../service/CoverLetterService.java | 11 +++++ .../controller/CoverLetterControllerTest.java | 27 +++++++++++ .../repository/CoverLetterRepositoryTest.java | 20 ++++++++ .../service/CoverLetterServiceTest.java | 46 +++++++++++++++++++ 10 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/daon/rewrite/coverletter/dto/DeleteCoverLetterResponse.java diff --git a/docs/api/README.md b/docs/api/README.md index 246a08b..95ba1ab 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -61,7 +61,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Planned | REQ-004 | 등록 step2 저장 | | API-011 | PUT | `/cover-letters/{coverLetterId}/questions` | Planned | REQ-004 | 등록 step3 저장 | | API-012 | GET | `/cover-letters/{coverLetterId}` | Planned | REQ-003 | 자기소개서 상세 | -| API-013 | DELETE | `/cover-letters/{coverLetterId}` | Planned | REQ-003 | 자기소개서 soft delete | +| API-013 | DELETE | `/cover-letters/{coverLetterId}` | Implemented | REQ-003 | 자기소개서 soft delete | | API-014 | POST | `/cover-letters/{coverLetterId}/submit` | Planned | REQ-005 | 첨삭 Job 생성 | | API-015 | GET | `/llm-jobs/{jobId}` | Planned | REQ-005 | LLM Job 상태 조회 | | API-016 | GET | `/llm-jobs/{jobId}/stream` | Planned | REQ-005 | SSE 스트리밍 | diff --git a/docs/api/cover-letters.md b/docs/api/cover-letters.md index 8f3643b..0589155 100644 --- a/docs/api/cover-letters.md +++ b/docs/api/cover-letters.md @@ -451,6 +451,13 @@ Already Reviewed Response: DELETE /cover-letters/{coverLetterId} ``` +Validation: + +```text +현재 사용자 소유이고 아직 삭제되지 않은 자기소개서만 삭제할 수 있다. +존재하지 않는 자기소개서, 다른 사용자 소유 자기소개서, 이미 삭제된 자기소개서는 모두 NOT_FOUND를 반환한다. +``` + Response: ```json @@ -459,3 +466,17 @@ Response: "deletedAt": "2026-06-20T15:00:00" } ``` + +Not Found Response: + +```json +{ + "error": { + "code": "NOT_FOUND", + "message": "리소스를 찾을 수 없습니다.", + "details": [] + } +} +``` + +현재 구현에서는 `llm_jobs` persistence가 아직 없으므로 진행 중 LLM Job 취소 연결은 Job skeleton 구현 이후 반영한다. diff --git a/docs/status.md b/docs/status.md index 7100d1f..1b663b7 100644 --- a/docs/status.md +++ b/docs/status.md @@ -20,7 +20,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 |---|---|---|---|---|---|---|---| | REQ-001 | 공통 예외 응답 기반 | Verified | High | 공통 에러 응답 | [#2](https://github.com/Rewrite-Team/Rewrite-BE/issues/2), [PR #3](https://github.com/Rewrite-Team/Rewrite-BE/pull/3) | `GlobalExceptionHandlerTest`, `./gradlew test` | `BusinessException`, `ErrorCode`, `GlobalExceptionHandler`, `ErrorResponse` 구현됨 | | REQ-002 | 개발용 현재 사용자 Provider | Verified | High | 인증 필요 API 공통 | [#4](https://github.com/Rewrite-Team/Rewrite-BE/issues/4), [PR #6](https://github.com/Rewrite-Team/Rewrite-BE/pull/6) | `DevCurrentUserProviderTest`, `./gradlew test` | `CurrentUserProvider`, `DevCurrentUserProvider` 구현됨 | -| REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [#28](https://github.com/Rewrite-Team/Rewrite-BE/issues/28), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회와 API-008 자기소개서 초안 생성 계약 구현됨. API-012/API-013은 후속 slice 후보 기준으로 분리 진행 | +| REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [#28](https://github.com/Rewrite-Team/Rewrite-BE/issues/28), [#30](https://github.com/Rewrite-Team/Rewrite-BE/issues/30), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회, API-008 자기소개서 초안 생성, API-013 soft delete 계약 구현됨. 진행 중 Job cancel은 `llm_jobs` 구현 이후 연결 | | REQ-004 | 자기소개서 등록 step 저장 | Planned | High | API-009, API-010, API-011 | - | - | DB/JPA 전환 이후 진행 권장 | | REQ-005 | 자기소개서 제출과 LLM Job 생성 | Planned | High | API-014, API-015, API-016 | - | - | LLM provider 호출 전 skeleton 우선 | | REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | Planned | High | API-017, API-018, API-019, API-024 | - | - | 제출/Job skeleton 이후 진행 권장 | @@ -59,7 +59,6 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| | 자기소개서 상세 조회 | REQ-003 | API-012 | DB/JPA repository 기반 owner 검증, deletedAt 제외, 질문/최신 Job 요약 포함 skeleton, service/web/repository test | -| 자기소개서 soft delete | REQ-003 | API-013 | DB/JPA repository 기반 soft delete, 삭제 후 조회 제외, 하위 리소스 접근 정책 skeleton, service/web/repository test | | 등록 step1 저장 | REQ-004 | API-009 | basic info replace, trim, URL validation, DRAFT 상태 검증, service/web test | | 등록 step2 저장 | REQ-004 | API-010 | preferences replace, trim, empty validation, DRAFT 상태 검증, service/web test | | 등록 step3 저장 | REQ-004 | API-011 | questions replace, order 재부여, 글자 수 검증, service/web test | diff --git a/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java b/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java index 5352ab8..aa18f93 100644 --- a/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java +++ b/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java @@ -2,13 +2,16 @@ import com.daon.rewrite.coverletter.dto.CoverLetterListResponse; import com.daon.rewrite.coverletter.dto.CreateCoverLetterResponse; +import com.daon.rewrite.coverletter.dto.DeleteCoverLetterResponse; import com.daon.rewrite.coverletter.entity.CoverLetter; import com.daon.rewrite.coverletter.entity.CoverLetterStatus; import com.daon.rewrite.coverletter.service.CoverLetterService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -35,4 +38,9 @@ public CoverLetterListResponse findMyCoverLetters( Page result = coverLetterService.findMyCoverLetters(page, size, status); return CoverLetterListResponse.from(result, page, size); } + + @DeleteMapping("/cover-letters/{coverLetterId}") + public DeleteCoverLetterResponse deleteMyCoverLetter(@PathVariable String coverLetterId) { + return DeleteCoverLetterResponse.from(coverLetterService.deleteMyCoverLetter(coverLetterId)); + } } diff --git a/src/main/java/com/daon/rewrite/coverletter/dto/DeleteCoverLetterResponse.java b/src/main/java/com/daon/rewrite/coverletter/dto/DeleteCoverLetterResponse.java new file mode 100644 index 0000000..c77d268 --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/dto/DeleteCoverLetterResponse.java @@ -0,0 +1,20 @@ +package com.daon.rewrite.coverletter.dto; + +import com.daon.rewrite.coverletter.entity.CoverLetter; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +public record DeleteCoverLetterResponse( + boolean success, + LocalDateTime deletedAt +) { + private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul"); + + public static DeleteCoverLetterResponse from(CoverLetter coverLetter) { + return new DeleteCoverLetterResponse( + true, + LocalDateTime.ofInstant(coverLetter.getDeletedAt(), API_ZONE) + ); + } +} diff --git a/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java index d5d7e82..1f84e4b 100644 --- a/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java +++ b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface CoverLetterRepository extends JpaRepository { Page findByOwnerIdAndDeletedAtIsNull(String ownerId, Pageable pageable); @@ -15,4 +17,6 @@ Page findByOwnerIdAndStatusAndDeletedAtIsNull( CoverLetterStatus status, Pageable pageable ); + + Optional findByIdAndOwnerIdAndDeletedAtIsNull(String id, String ownerId); } diff --git a/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java b/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java index 4b16e71..8789274 100644 --- a/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java +++ b/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java @@ -65,6 +65,17 @@ public Page findMyCoverLetters(int page, int size, CoverLetterStatu ); } + @Transactional + public CoverLetter deleteMyCoverLetter(String coverLetterId) { + CurrentUser currentUser = currentUserProvider.currentUser(); + CoverLetter coverLetter = coverLetterRepository + .findByIdAndOwnerIdAndDeletedAtIsNull(coverLetterId, currentUser.id()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + + coverLetter.markDeleted(Instant.now(clock)); + return coverLetter; + } + private void validateListQuery(int page, int size) { if (page < 1 || size < 1 || size > MAX_LIST_SIZE) { throw new BusinessException(ErrorCode.VALIDATION_ERROR); diff --git a/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java b/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java index 38f9bc6..0e99a37 100644 --- a/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java @@ -18,6 +18,7 @@ import java.util.List; import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -116,4 +117,30 @@ void findMyCoverLettersRejectsInvalidStatus() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); } + + @Test + void deleteMyCoverLetterReturnsDeletedAt() throws Exception { + CoverLetter deleted = CoverLetter.draft( + "cl_delete", + "user_1", + Instant.parse("2026-06-20T05:00:00Z") + ); + deleted.markDeleted(Instant.parse("2026-06-20T06:00:00Z")); + given(coverLetterService.deleteMyCoverLetter("cl_delete")).willReturn(deleted); + + mockMvc.perform(delete("/cover-letters/{coverLetterId}", "cl_delete")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.deletedAt").value("2026-06-20T15:00:00")); + } + + @Test + void deleteMyCoverLetterReturnsNotFound() throws Exception { + given(coverLetterService.deleteMyCoverLetter("cl_missing")) + .willThrow(new BusinessException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(delete("/cover-letters/{coverLetterId}", "cl_missing")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); + } } diff --git a/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java b/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java index 18bf117..8efa7a8 100644 --- a/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java @@ -10,6 +10,7 @@ import java.time.Instant; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import org.springframework.data.domain.Page; @@ -114,6 +115,25 @@ void findActiveByOwnerPaginates() { .containsExactly("cl_1"); } + @Test + void findActiveByIdAndOwnerReturnsOnlyCurrentOwnerUndeletedCoverLetter() { + CoverLetter active = draft("cl_active", "user_1", "Active title", "2026-06-20T01:00:00Z"); + CoverLetter deleted = draft("cl_deleted", "user_1", "Deleted title", "2026-06-20T02:00:00Z"); + deleted.markDeleted(Instant.parse("2026-06-20T03:00:00Z")); + CoverLetter otherOwner = draft("cl_other", "user_2", "Other title", "2026-06-20T04:00:00Z"); + repository.saveAll(List.of(active, deleted, otherOwner)); + entityManager.flush(); + entityManager.clear(); + + Optional found = repository.findByIdAndOwnerIdAndDeletedAtIsNull("cl_active", "user_1"); + + assertThat(found).hasValueSatisfying(coverLetter -> + assertThat(coverLetter.getId()).isEqualTo("cl_active") + ); + assertThat(repository.findByIdAndOwnerIdAndDeletedAtIsNull("cl_deleted", "user_1")).isEmpty(); + assertThat(repository.findByIdAndOwnerIdAndDeletedAtIsNull("cl_other", "user_1")).isEmpty(); + } + private CoverLetter draft(String id, String ownerId, String title, String createdAt) { CoverLetter coverLetter = CoverLetter.draft(id, ownerId, Instant.parse(createdAt)); coverLetter.fillBasicInfo(title, "Rewrite Corp", "백엔드 개발자", null, Instant.parse(createdAt)); diff --git a/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java b/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java index f4f6e2b..0146ec1 100644 --- a/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java @@ -116,6 +116,52 @@ void findMyCoverLettersRejectsInvalidPageAndSize() { .isEqualTo(ErrorCode.VALIDATION_ERROR); } + @Test + void deleteMyCoverLetterMarksCurrentUserOwnedCoverLetterDeleted() { + Instant createdAt = Instant.parse("2026-06-20T01:00:00Z"); + Instant deletedAt = Instant.parse("2026-06-20T05:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(clock.instant()).willReturn(deletedAt); + repository.save(draft("cl_delete", "user_1", "Delete title", createdAt)); + + CoverLetter result = service.deleteMyCoverLetter("cl_delete"); + + assertThat(result.getDeletedAt()).isEqualTo(deletedAt); + assertThat(result.getUpdatedAt()).isEqualTo(deletedAt); + assertThat(repository.findById("cl_delete")).hasValueSatisfying(saved -> { + assertThat(saved.getDeletedAt()).isEqualTo(deletedAt); + assertThat(saved.getUpdatedAt()).isEqualTo(deletedAt); + }); + assertThat(service.findMyCoverLetters(1, 9, null).getContent()) + .extracting(CoverLetter::getId) + .doesNotContain("cl_delete"); + } + + @Test + void deleteMyCoverLetterThrowsNotFoundWhenCoverLetterIsMissingOtherOwnerOrAlreadyDeleted() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + CoverLetter deleted = draft("cl_deleted", "user_1", "Deleted title", now); + deleted.markDeleted(now.plusSeconds(60)); + repository.saveAll(List.of( + draft("cl_other", "user_2", "Other title", now), + deleted + )); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertThatThrownBy(() -> service.deleteMyCoverLetter("cl_missing")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + assertThatThrownBy(() -> service.deleteMyCoverLetter("cl_other")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + assertThatThrownBy(() -> service.deleteMyCoverLetter("cl_deleted")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + private CoverLetter draft(String id, String ownerId, String title, Instant now) { CoverLetter coverLetter = CoverLetter.draft(id, ownerId, now); coverLetter.fillBasicInfo(title, "Rewrite Corp", "백엔드 개발자", null, now);