From 375c502325c5ee95341e9625c5702c08a9ebcf80 Mon Sep 17 00:00:00 2001 From: yong203 Date: Fri, 19 Jun 2026 00:37:31 +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=20=EC=B1=84=EC=9A=A9=20=EC=9A=B0?= =?UTF-8?q?=EB=8C=80=EC=82=AC=ED=95=AD=20=EC=A0=80=EC=9E=A5=20API=20?= =?UTF-8?q?=EA=B5=AC=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 | 12 ++ docs/status.md | 3 +- .../controller/CoverLetterController.java | 14 +++ .../dto/SavePreferencesRequest.java | 6 + .../dto/SavePreferencesResponse.java | 25 ++++ .../coverletter/entity/CoverLetter.java | 5 + .../service/CoverLetterService.java | 36 ++++++ .../controller/CoverLetterControllerTest.java | 91 +++++++++++++++ .../service/CoverLetterServiceTest.java | 110 ++++++++++++++++++ 10 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/daon/rewrite/coverletter/dto/SavePreferencesRequest.java create mode 100644 src/main/java/com/daon/rewrite/coverletter/dto/SavePreferencesResponse.java diff --git a/docs/api/README.md b/docs/api/README.md index 143865e..47d7499 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -58,7 +58,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | API-007 | GET | `/cover-letters` | Implemented | REQ-003 | 내 자기소개서 목록 | | API-008 | POST | `/cover-letters` | Implemented | REQ-003 | 자기소개서 초안 생성 | | API-009 | PUT | `/cover-letters/{coverLetterId}/basic-info` | Implemented | REQ-004 | 등록 step1 저장 | -| API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Planned | REQ-004 | 등록 step2 저장 | +| API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Implemented | 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}` | Implemented | REQ-003 | 자기소개서 soft delete | diff --git a/docs/api/cover-letters.md b/docs/api/cover-letters.md index 020e4b7..7124150 100644 --- a/docs/api/cover-letters.md +++ b/docs/api/cover-letters.md @@ -199,6 +199,18 @@ preferences: 필수, trim 후 Unicode code point 기준 1~3000자 서버는 `preferences`의 앞뒤 공백을 제거한 뒤 길이를 검증하고, 공백이 제거된 값을 저장한다. trim 후 빈 문자열이면 `VALIDATION_ERROR`를 반환한다. +Conflict Response: + +```json +{ + "error": { + "code": "COVER_LETTER_NOT_DRAFT", + "message": "제출된 자기소개서의 원본 정보는 수정할 수 없습니다.", + "details": [] + } +} +``` + Response: ```json diff --git a/docs/status.md b/docs/status.md index 26e875c..ea60bf4 100644 --- a/docs/status.md +++ b/docs/status.md @@ -21,7 +21,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), [#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 저장 | In Progress | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장 계약 구현됨. API-010/API-011은 후속 slice 후보 기준으로 분리 진행 | +| REQ-004 | 자기소개서 등록 step 저장 | In Progress | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32), [#34](https://github.com/Rewrite-Team/Rewrite-BE/issues/34) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장, API-010 등록 step2 채용 우대사항 저장 계약 구현됨. API-011은 후속 slice 후보 기준으로 분리 진행 | | 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 이후 진행 권장 | | REQ-007 | DB/JPA 전환 | Implemented | High | API-008 내부 persistence, persistence 내부 변경 | [#22](https://github.com/Rewrite-Team/Rewrite-BE/issues/22), [#24](https://github.com/Rewrite-Team/Rewrite-BE/issues/24), [#25](https://github.com/Rewrite-Team/Rewrite-BE/issues/25), [PR #26](https://github.com/Rewrite-Team/Rewrite-BE/pull/26) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `./gradlew test`, `./gradlew check` | API-008 공개 계약은 유지하고 `cover_letters` persistence를 DB/JPA로 전환. H2 file 로컬 DB, H2 in-memory 테스트 DB 사용. Flyway는 별도 이슈로 분리 | @@ -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 | -| 등록 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 | ## Update Rules 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 c919fca..1fac8eb 100644 --- a/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java +++ b/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java @@ -5,6 +5,8 @@ import com.daon.rewrite.coverletter.dto.DeleteCoverLetterResponse; import com.daon.rewrite.coverletter.dto.SaveBasicInfoRequest; import com.daon.rewrite.coverletter.dto.SaveBasicInfoResponse; +import com.daon.rewrite.coverletter.dto.SavePreferencesRequest; +import com.daon.rewrite.coverletter.dto.SavePreferencesResponse; import com.daon.rewrite.coverletter.entity.CoverLetter; import com.daon.rewrite.coverletter.entity.CoverLetterStatus; import com.daon.rewrite.coverletter.service.CoverLetterService; @@ -62,4 +64,16 @@ public SaveBasicInfoResponse saveBasicInfo( ); return SaveBasicInfoResponse.from(result); } + + @PutMapping("/cover-letters/{coverLetterId}/preferences") + public SavePreferencesResponse savePreferences( + @PathVariable String coverLetterId, + @RequestBody SavePreferencesRequest request + ) { + CoverLetter result = coverLetterService.savePreferences( + coverLetterId, + request.preferences() + ); + return SavePreferencesResponse.from(result); + } } diff --git a/src/main/java/com/daon/rewrite/coverletter/dto/SavePreferencesRequest.java b/src/main/java/com/daon/rewrite/coverletter/dto/SavePreferencesRequest.java new file mode 100644 index 0000000..0826886 --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/dto/SavePreferencesRequest.java @@ -0,0 +1,6 @@ +package com.daon.rewrite.coverletter.dto; + +public record SavePreferencesRequest( + String preferences +) { +} diff --git a/src/main/java/com/daon/rewrite/coverletter/dto/SavePreferencesResponse.java b/src/main/java/com/daon/rewrite/coverletter/dto/SavePreferencesResponse.java new file mode 100644 index 0000000..5d827de --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/dto/SavePreferencesResponse.java @@ -0,0 +1,25 @@ +package com.daon.rewrite.coverletter.dto; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +public record SavePreferencesResponse( + String id, + String preferences, + CoverLetterStatus status, + LocalDateTime updatedAt +) { + private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul"); + + public static SavePreferencesResponse from(CoverLetter coverLetter) { + return new SavePreferencesResponse( + coverLetter.getId(), + coverLetter.getPreferences(), + coverLetter.getStatus(), + LocalDateTime.ofInstant(coverLetter.getUpdatedAt(), API_ZONE) + ); + } +} diff --git a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java index e20bc38..a032d99 100644 --- a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java +++ b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java @@ -80,6 +80,11 @@ public void fillBasicInfo(String title, String companyName, String positionTitle this.updatedAt = now; } + public void fillPreferences(String preferences, Instant now) { + this.preferences = preferences; + this.updatedAt = now; + } + public void markDeleted(Instant now) { this.deletedAt = now; this.updatedAt = now; 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 0854dc4..0284a42 100644 --- a/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java +++ b/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java @@ -34,6 +34,7 @@ public class CoverLetterService { private static final int MAX_COMPANY_NAME_LENGTH = 30; private static final int MAX_POSITION_TITLE_LENGTH = 30; private static final int MAX_JOB_POSTING_URL_LENGTH = 500; + private static final int MAX_PREFERENCES_LENGTH = 3000; private final CurrentUserProvider currentUserProvider; private final CoverLetterRepository coverLetterRepository; @@ -119,6 +120,23 @@ public CoverLetter saveBasicInfo( return coverLetter; } + @Transactional + public CoverLetter savePreferences(String coverLetterId, String preferences) { + CurrentUser currentUser = currentUserProvider.currentUser(); + CoverLetter coverLetter = coverLetterRepository + .findByIdAndOwnerIdAndDeletedAtIsNull(coverLetterId, currentUser.id()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + + if (coverLetter.getStatus() != CoverLetterStatus.DRAFT) { + throw new BusinessException(ErrorCode.COVER_LETTER_NOT_DRAFT); + } + + String normalizedPreferences = validateAndNormalizePreferences(preferences); + coverLetter.fillPreferences(normalizedPreferences, 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); @@ -189,6 +207,24 @@ private String normalizeRequiredText( return normalized; } + private String validateAndNormalizePreferences(String preferences) { + List details = new ArrayList<>(); + String normalizedPreferences = normalizeRequiredText( + "preferences", + preferences, + MAX_PREFERENCES_LENGTH, + "채용 우대사항은 필수입니다.", + "채용 우대사항은 최대 3000자까지 입력할 수 있습니다.", + details + ); + + if (!details.isEmpty()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, details); + } + + return normalizedPreferences; + } + private String normalizeOptionalJobPostingUrl( String value, List details 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 6dcf3fe..5cba80e 100644 --- a/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java @@ -271,4 +271,95 @@ void saveBasicInfoReturnsCoverLetterNotDraft() throws Exception { .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("COVER_LETTER_NOT_DRAFT")); } + + @Test + void savePreferencesReturnsUpdatedPreferences() throws Exception { + CoverLetter updated = CoverLetter.draft( + "cl_preferences", + "user_1", + Instant.parse("2026-06-20T05:00:00Z") + ); + updated.fillPreferences( + "Spring Boot 경험, 대용량 트래픽 처리 경험 우대", + Instant.parse("2026-06-20T05:08:00Z") + ); + given(coverLetterService.savePreferences( + "cl_preferences", + " Spring Boot 경험, 대용량 트래픽 처리 경험 우대 " + )).willReturn(updated); + + mockMvc.perform(put("/cover-letters/{coverLetterId}/preferences", "cl_preferences") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "preferences": " Spring Boot 경험, 대용량 트래픽 처리 경험 우대 " + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("cl_preferences")) + .andExpect(jsonPath("$.preferences").value("Spring Boot 경험, 대용량 트래픽 처리 경험 우대")) + .andExpect(jsonPath("$.status").value("DRAFT")) + .andExpect(jsonPath("$.updatedAt").value("2026-06-20T14:08:00")); + } + + @Test + void savePreferencesReturnsValidationDetails() throws Exception { + given(coverLetterService.savePreferences( + "cl_preferences", + " " + )).willThrow(new BusinessException( + ErrorCode.VALIDATION_ERROR, + List.of(new com.daon.rewrite.global.response.ErrorResponse.ErrorDetail( + "preferences", + "채용 우대사항은 필수입니다." + )) + )); + + mockMvc.perform(put("/cover-letters/{coverLetterId}/preferences", "cl_preferences") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "preferences": " " + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.error.details[0].field").value("preferences")); + } + + @Test + void savePreferencesReturnsNotFound() throws Exception { + given(coverLetterService.savePreferences( + "cl_missing", + "Spring Boot 경험" + )).willThrow(new BusinessException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(put("/cover-letters/{coverLetterId}/preferences", "cl_missing") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "preferences": "Spring Boot 경험" + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); + } + + @Test + void savePreferencesReturnsCoverLetterNotDraft() throws Exception { + given(coverLetterService.savePreferences( + "cl_reviewing", + "Spring Boot 경험" + )).willThrow(new BusinessException(ErrorCode.COVER_LETTER_NOT_DRAFT)); + + mockMvc.perform(put("/cover-letters/{coverLetterId}/preferences", "cl_reviewing") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "preferences": "Spring Boot 경험" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("COVER_LETTER_NOT_DRAFT")); + } } 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 1821022..b893227 100644 --- a/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java @@ -283,6 +283,116 @@ void saveBasicInfoThrowsValidationErrorWithDetails() { }); } + @Test + void savePreferencesStoresTrimmedValueAndUpdatesTimestamp() { + Instant createdAt = Instant.parse("2026-06-20T01:00:00Z"); + Instant updatedAt = Instant.parse("2026-06-20T05:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(clock.instant()).willReturn(updatedAt); + repository.save(CoverLetter.draft("cl_preferences", "user_1", createdAt)); + + CoverLetter result = service.savePreferences( + "cl_preferences", + " Spring Boot 경험, 대용량 트래픽 처리 경험 우대 " + ); + + assertThat(result.getPreferences()).isEqualTo("Spring Boot 경험, 대용량 트래픽 처리 경험 우대"); + assertThat(result.getUpdatedAt()).isEqualTo(updatedAt); + assertThat(repository.findById("cl_preferences")).hasValueSatisfying(saved -> { + assertThat(saved.getPreferences()).isEqualTo("Spring Boot 경험, 대용량 트래픽 처리 경험 우대"); + assertThat(saved.getUpdatedAt()).isEqualTo(updatedAt); + }); + } + + @Test + void savePreferencesThrowsNotFoundWhenCoverLetterIsMissingOtherOwnerOrAlreadyDeleted() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + CoverLetter deleted = CoverLetter.draft("cl_deleted", "user_1", now); + deleted.markDeleted(now.plusSeconds(60)); + repository.saveAll(List.of( + CoverLetter.draft("cl_other", "user_2", now), + deleted + )); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertThatThrownBy(() -> service.savePreferences("cl_missing", "Spring Boot 경험")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + assertThatThrownBy(() -> service.savePreferences("cl_other", "Spring Boot 경험")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + assertThatThrownBy(() -> service.savePreferences("cl_deleted", "Spring Boot 경험")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + + @Test + void savePreferencesThrowsCoverLetterNotDraftWhenStatusIsNotDraft() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.saveAll(List.of( + coverLetterWithStatus("cl_reviewing", CoverLetterStatus.REVIEWING, now), + coverLetterWithStatus("cl_reviewed", CoverLetterStatus.REVIEWED, now), + coverLetterWithStatus("cl_failed", CoverLetterStatus.REVIEW_FAILED, now) + )); + + assertThatThrownBy(() -> service.savePreferences("cl_reviewing", "Spring Boot 경험")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COVER_LETTER_NOT_DRAFT); + assertThatThrownBy(() -> service.savePreferences("cl_reviewed", "Spring Boot 경험")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COVER_LETTER_NOT_DRAFT); + assertThatThrownBy(() -> service.savePreferences("cl_failed", "Spring Boot 경험")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COVER_LETTER_NOT_DRAFT); + } + + @Test + void savePreferencesThrowsValidationErrorWithDetails() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.save(CoverLetter.draft("cl_preferences", "user_1", now)); + + assertThatThrownBy(() -> service.savePreferences( + "cl_preferences", + " " + )) + .isInstanceOf(BusinessException.class) + .satisfies(error -> { + BusinessException businessException = (BusinessException) error; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.VALIDATION_ERROR); + assertThat(businessException.getDetails()) + .extracting("field") + .containsExactly("preferences"); + }); + } + + @Test + void savePreferencesRejectsTooLongPreferences() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.save(CoverLetter.draft("cl_preferences", "user_1", now)); + + assertThatThrownBy(() -> service.savePreferences( + "cl_preferences", + "가".repeat(3001) + )) + .isInstanceOf(BusinessException.class) + .satisfies(error -> { + BusinessException businessException = (BusinessException) error; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.VALIDATION_ERROR); + assertThat(businessException.getDetails()) + .extracting("field") + .containsExactly("preferences"); + }); + } + 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);