diff --git a/src/docs/asciidoc/pet.adoc b/src/docs/asciidoc/pet.adoc index 118d6aa..7fc6701 100644 --- a/src/docs/asciidoc/pet.adoc +++ b/src/docs/asciidoc/pet.adoc @@ -16,10 +16,6 @@ operation::pet-e2e-test/should_-pet-response_-when_-authenticated[snippets='http operation::pet-e2e-test/should_-create-pet_-when_-valid-request[snippets='http-request,request-headers,request-fields,http-response,response-headers'] -=== 좋아요 증가 - -operation::pet-e2e-test/should_-pet-increase-favorite_-when_-valid-request[snippets='http-request,path-parameters,request-headers,http-response'] - === 수정 operation::pet-e2e-test/should_-update-pet_-when_-valid-request[snippets='http-request,path-parameters,request-headers,request-fields,http-response'] diff --git a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/GetAiPromptParameterController.java b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/GetAiPromptParameterController.java index 524c903..0667384 100644 --- a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/GetAiPromptParameterController.java +++ b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/GetAiPromptParameterController.java @@ -3,6 +3,7 @@ import com.rainbowletter.server.ai.application.port.in.GetAiPromptParameterUseCase; import com.rainbowletter.server.ai.application.port.in.dto.AiPromptParameterResponse; import com.rainbowletter.server.common.annotation.WebAdapter; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -14,11 +15,12 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/admins/ai") -@Tag(name = "ai") +@Tag(name = "ai", description = "AI 관련") class GetAiPromptParameterController { private final GetAiPromptParameterUseCase getAiPromptParameterUseCase; + @Operation(summary = "파라미터 조회") @GetMapping("/parameters") ResponseEntity getParameters() { final AiPromptParameterResponse response = getAiPromptParameterUseCase.getPromptParameters(); diff --git a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/GetAiSettingController.java b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/GetAiSettingController.java index aabfbc1..0e8d335 100644 --- a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/GetAiSettingController.java +++ b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/GetAiSettingController.java @@ -3,6 +3,7 @@ import com.rainbowletter.server.ai.application.port.in.GetAiSettingUseCase; import com.rainbowletter.server.ai.application.port.in.dto.AiSettingResponse; import com.rainbowletter.server.common.annotation.WebAdapter; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -14,15 +15,23 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/admins/ai") -@Tag(name = "ai") +@Tag(name = "ai", description = "AI 관련") class GetAiSettingController { private final GetAiSettingUseCase getAiSettingUseCase; + @Operation(summary = "일반 편지 프롬프트 옵션 조회") @GetMapping("/setting") ResponseEntity getSetting() { final AiSettingResponse response = getAiSettingUseCase.getSetting(); return ResponseEntity.ok(response); } + @Operation(summary = "선편지 프롬프트 옵션 조회") + @GetMapping("/setting/pet-initiated-letter") + ResponseEntity getPetInitiatedLetterSetting() { + final AiSettingResponse response = getAiSettingUseCase.getPetInitiatedLetterSetting(); + return ResponseEntity.ok(response); + } + } diff --git a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiConfigController.java b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiConfigController.java index b610294..81c05f1 100644 --- a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiConfigController.java +++ b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiConfigController.java @@ -4,6 +4,7 @@ import com.rainbowletter.server.ai.application.port.in.UpdateAiConfigCommand; import com.rainbowletter.server.ai.application.port.in.UpdateAiConfigUseCase; import com.rainbowletter.server.common.annotation.WebAdapter; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PutMapping; @@ -15,15 +16,23 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/admins/ai") -@Tag(name = "ai") +@Tag(name = "ai", description = "AI 관련") class UpdateAiConfigController { private final UpdateAiConfigUseCase updateAiConfigUseCase; + @Operation(summary = "일반 편지 프롬프트 타입/AB 테스트 설정") @PutMapping("/config") void updateConfig(@RequestBody final UpdateAiConfigRequest request) { final var command = new UpdateAiConfigCommand(request.useABTest(), request.selectPrompt()); updateAiConfigUseCase.updateConfig(command); } + @Operation(summary = "선편지 프롬프트 타입/AB 테스트 설정") + @PutMapping("/config/pet-initiated-letter") + void updatePetInitiatedLetterConfig(@RequestBody final UpdateAiConfigRequest request) { + final var command = new UpdateAiConfigCommand(request.useABTest(), request.selectPrompt()); + updateAiConfigUseCase.updatePetInitiatedLetterConfig(command); + } + } diff --git a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiOptionController.java b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiOptionController.java index 9adcf5f..eff3855 100644 --- a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiOptionController.java +++ b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiOptionController.java @@ -6,6 +6,7 @@ import com.rainbowletter.server.ai.application.port.in.UpdateAiOptionCommand; import com.rainbowletter.server.ai.application.port.in.UpdateAiOptionUseCase; import com.rainbowletter.server.common.annotation.WebAdapter; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PathVariable; @@ -18,11 +19,12 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/admins/ai") -@Tag(name = "ai") +@Tag(name = "ai", description = "AI 관련") class UpdateAiOptionController { private final UpdateAiOptionUseCase updateAiOptionUseCase; + @Operation(summary = "프롬프트 옵션 수정") @PutMapping("/options/{id}") void updatePrompt( @PathVariable("id") final Long id, diff --git a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiPromptController.java b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiPromptController.java index a21777d..6fef445 100644 --- a/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiPromptController.java +++ b/src/main/java/com/rainbowletter/server/ai/adapter/in/web/UpdateAiPromptController.java @@ -5,6 +5,7 @@ import com.rainbowletter.server.ai.application.port.in.UpdateAiPromptCommand; import com.rainbowletter.server.ai.application.port.in.UpdateAiPromptUseCase; import com.rainbowletter.server.common.annotation.WebAdapter; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PathVariable; @@ -17,11 +18,12 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/admins/ai") -@Tag(name = "ai") +@Tag(name = "ai", description = "AI 관련") class UpdateAiPromptController { private final UpdateAiPromptUseCase updateAiPromptUseCase; + @Operation(summary = "프롬프트 수정") @PutMapping("/prompts/{id}") void updatePrompt( @PathVariable("id") final Long id, diff --git a/src/main/java/com/rainbowletter/server/ai/adapter/out/persistence/AiSettingPersistenceAdapter.java b/src/main/java/com/rainbowletter/server/ai/adapter/out/persistence/AiSettingPersistenceAdapter.java index bd6c728..249d16e 100644 --- a/src/main/java/com/rainbowletter/server/ai/adapter/out/persistence/AiSettingPersistenceAdapter.java +++ b/src/main/java/com/rainbowletter/server/ai/adapter/out/persistence/AiSettingPersistenceAdapter.java @@ -9,9 +9,11 @@ import com.rainbowletter.server.ai.application.port.out.UpdateSettingStatePort; import com.rainbowletter.server.common.annotation.PersistenceAdapter; import com.rainbowletter.server.common.application.domain.exception.RainbowLetterException; -import java.util.List; import lombok.RequiredArgsConstructor; +import java.util.List; +import java.util.stream.Stream; + @PersistenceAdapter @RequiredArgsConstructor class AiSettingPersistenceAdapter implements LoadSettingPort, UpdateSettingStatePort { @@ -26,14 +28,22 @@ public AiSetting loadSetting() { final AiConfigJpaEntity aiConfig = aiConfigJpaRepository.findById(1L) .orElseThrow(() -> new RainbowLetterException("not.exists.ai.setting")); - final List aiPromptJpaEntities = aiPromptJpaRepository.findAll(); - if (aiPromptJpaEntities.isEmpty()) { - throw new RainbowLetterException("not.exists.ai.prompt"); - } + List prompts = Stream.of(1L, 2L) + .map(id -> loadPrompt(new AiPromptId(id))) + .toList(); + + return aiSettingMapper.mapToDomain(aiConfig, prompts); + } - final List prompts = aiPromptJpaEntities.stream() - .map(aiPromptJpaEntity -> loadPrompt(new AiPromptId(aiPromptJpaEntity.getId()))) + @Override + public AiSetting loadPetInitiatedLetterSetting() { + final AiConfigJpaEntity aiConfig = aiConfigJpaRepository.findById(2L) + .orElseThrow(() -> new RainbowLetterException("not.exists.ai.letter.setting")); + + List prompts = Stream.of(3L, 4L) + .map(id -> loadPrompt(new AiPromptId(id))) .toList(); + return aiSettingMapper.mapToDomain(aiConfig, prompts); } diff --git a/src/main/java/com/rainbowletter/server/ai/application/domain/model/Parameter.java b/src/main/java/com/rainbowletter/server/ai/application/domain/model/Parameter.java index 612214a..a88adee 100644 --- a/src/main/java/com/rainbowletter/server/ai/application/domain/model/Parameter.java +++ b/src/main/java/com/rainbowletter/server/ai/application/domain/model/Parameter.java @@ -4,6 +4,8 @@ import com.rainbowletter.server.letter.application.domain.model.Letter; import com.rainbowletter.server.pet.application.domain.model.Pet; import java.util.Arrays; +import java.util.List; + import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -13,42 +15,50 @@ public enum Parameter { PET_NAME(Pet.class) { @Override public String value(final Object instance) { - Parameter.validateInstanceType(instance, Pet.class); + validateInstanceType(instance, Pet.class); return ((Pet) instance).getName(); } }, PET_OWNER(Pet.class) { @Override public String value(final Object instance) { - Parameter.validateInstanceType(instance, Pet.class); + validateInstanceType(instance, Pet.class); return ((Pet) instance).getOwner(); } }, PET_SPECIES(Pet.class) { @Override public String value(final Object instance) { - Parameter.validateInstanceType(instance, Pet.class); + validateInstanceType(instance, Pet.class); return ((Pet) instance).getSpecies(); } }, + PET_CHARACTER(Pet.class) { + @Override + public String value(final Object instance) { + validateInstanceType(instance, Pet.class); + List personalities = ((Pet) instance).getPersonalities(); + return String.join(", ", personalities); + } + }, LETTER_CONTENT(Letter.class) { @Override public String value(final Object instance) { - Parameter.validateInstanceType(instance, Letter.class); + validateInstanceType(instance, Letter.class); return ((Letter) instance).getContent(); } }, LETTER_COUNT(LetterCount.class) { @Override public String value(final Object instance) { - Parameter.validateInstanceType(instance, LetterCount.class); + validateInstanceType(instance, LetterCount.class); return String.valueOf(instance); } }, FIRST_LETTER(FirstLetter.class) { @Override public String value(final Object instance) { - Parameter.validateInstanceType(instance, FirstLetter.class); + validateInstanceType(instance, FirstLetter.class); return String.valueOf(instance); } }; @@ -70,12 +80,8 @@ public static Parameter get(final String name) { public abstract String value(Object instance); - public record LetterCount(Long count) { + public record LetterCount(Long count) { } - } - - public record FirstLetter(Boolean isFirstLetter) { - - } + public record FirstLetter(Boolean isFirstLetter) { } } diff --git a/src/main/java/com/rainbowletter/server/ai/application/domain/service/GetAiSettingService.java b/src/main/java/com/rainbowletter/server/ai/application/domain/service/GetAiSettingService.java index 52a17b9..51eda32 100644 --- a/src/main/java/com/rainbowletter/server/ai/application/domain/service/GetAiSettingService.java +++ b/src/main/java/com/rainbowletter/server/ai/application/domain/service/GetAiSettingService.java @@ -21,4 +21,10 @@ public AiSettingResponse getSetting() { return AiSettingResponse.from(aiSetting); } + @Override + public AiSettingResponse getPetInitiatedLetterSetting() { + final AiSetting aiSetting = loadSettingPort.loadPetInitiatedLetterSetting(); + return AiSettingResponse.from(aiSetting); + } + } diff --git a/src/main/java/com/rainbowletter/server/ai/application/domain/service/UpdateAiConfigService.java b/src/main/java/com/rainbowletter/server/ai/application/domain/service/UpdateAiConfigService.java index 358c769..ecd16e0 100644 --- a/src/main/java/com/rainbowletter/server/ai/application/domain/service/UpdateAiConfigService.java +++ b/src/main/java/com/rainbowletter/server/ai/application/domain/service/UpdateAiConfigService.java @@ -24,4 +24,11 @@ public void updateConfig(final UpdateAiConfigCommand command) { updateSettingStatePort.updateConfig(aiSetting); } + @Override + public void updatePetInitiatedLetterConfig(UpdateAiConfigCommand command) { + final AiSetting aiSetting = loadSettingPort.loadPetInitiatedLetterSetting(); + aiSetting.update(command.getUseABTest(), command.getSelectPrompt()); + updateSettingStatePort.updateConfig(aiSetting); + } + } diff --git a/src/main/java/com/rainbowletter/server/ai/application/port/in/GetAiSettingUseCase.java b/src/main/java/com/rainbowletter/server/ai/application/port/in/GetAiSettingUseCase.java index e6960b2..a0a9e85 100644 --- a/src/main/java/com/rainbowletter/server/ai/application/port/in/GetAiSettingUseCase.java +++ b/src/main/java/com/rainbowletter/server/ai/application/port/in/GetAiSettingUseCase.java @@ -6,4 +6,6 @@ public interface GetAiSettingUseCase { AiSettingResponse getSetting(); + AiSettingResponse getPetInitiatedLetterSetting(); + } diff --git a/src/main/java/com/rainbowletter/server/ai/application/port/in/UpdateAiConfigUseCase.java b/src/main/java/com/rainbowletter/server/ai/application/port/in/UpdateAiConfigUseCase.java index 2cd1624..b5d278d 100644 --- a/src/main/java/com/rainbowletter/server/ai/application/port/in/UpdateAiConfigUseCase.java +++ b/src/main/java/com/rainbowletter/server/ai/application/port/in/UpdateAiConfigUseCase.java @@ -4,4 +4,6 @@ public interface UpdateAiConfigUseCase { void updateConfig(UpdateAiConfigCommand command); + void updatePetInitiatedLetterConfig(UpdateAiConfigCommand command); + } diff --git a/src/main/java/com/rainbowletter/server/ai/application/port/out/AiClientCommand.java b/src/main/java/com/rainbowletter/server/ai/application/port/out/AiClientCommand.java index b590804..2d80c4b 100644 --- a/src/main/java/com/rainbowletter/server/ai/application/port/out/AiClientCommand.java +++ b/src/main/java/com/rainbowletter/server/ai/application/port/out/AiClientCommand.java @@ -29,6 +29,10 @@ public AiClientCommand(final AiPrompt aiPrompt, final List parameterInst this.messages = buildMessages(aiPrompt, parameterInstances, recentLetters); } + public AiClientCommand(final AiPrompt aiPrompt, final List parameterInstances) { + this(aiPrompt, parameterInstances, List.of()); + } + private List buildMessages( final AiPrompt aiPrompt, final List parameterInstances, @@ -53,27 +57,21 @@ private List buildMessages( aiPrompt.getParameters(), parameterInstances ); - messages.add(new Message(USER, currentPrompt)); + messages.add(new Message(USER, currentPrompt)); return messages; } - private String combineUserParameters( - final String userPrompt, - final List parameters, - final List parameterInstances - ) { - final ArrayList formatValues = new ArrayList<>(); - for (final Parameter parameter : parameters) { - final String value = parameter.value( - parameterInstances.stream() - .filter(parameterInstance -> parameter.getClazz().isInstance(parameterInstance)) + private String combineUserParameters(String userPrompt, List parameters, List instances) { + List values = parameters.stream() + .map(parameter -> parameter.value( + instances.stream() + .filter(parameter.getClazz()::isInstance) .findAny() - .orElseThrow(() -> new RainbowLetterException("Ai 파라미터 인스턴스가 존재하지 않습니다.")) - ); - formatValues.add(value); - } - return String.format(userPrompt, formatValues.toArray()); + .orElseThrow(() -> new RainbowLetterException("Ai 파라미터 인스턴스가 존재하지 않습니다.")))) + .toList(); + + return String.format(userPrompt, values.toArray()); } @Value diff --git a/src/main/java/com/rainbowletter/server/ai/application/port/out/LoadSettingPort.java b/src/main/java/com/rainbowletter/server/ai/application/port/out/LoadSettingPort.java index 3d73840..51db99c 100644 --- a/src/main/java/com/rainbowletter/server/ai/application/port/out/LoadSettingPort.java +++ b/src/main/java/com/rainbowletter/server/ai/application/port/out/LoadSettingPort.java @@ -10,6 +10,8 @@ public interface LoadSettingPort { AiSetting loadSetting(); + AiSetting loadPetInitiatedLetterSetting(); + AiPrompt loadPrompt(AiPromptId id); AiOption loadOption(AiOptionId id); diff --git a/src/main/java/com/rainbowletter/server/common/config/security/SecurityConfig.java b/src/main/java/com/rainbowletter/server/common/config/security/SecurityConfig.java index 2016608..3b30f27 100644 --- a/src/main/java/com/rainbowletter/server/common/config/security/SecurityConfig.java +++ b/src/main/java/com/rainbowletter/server/common/config/security/SecurityConfig.java @@ -73,6 +73,7 @@ private void configureAuthorizationSecurity(final HttpSecurity http) throws Exce .requestMatchers(convertUriToPathMatcher(AnonymousAllowUri.values())).anonymous() .requestMatchers(convertUriToPathMatcher(AdminAllowUri.values())).hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/api/letters/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/pet-initiated-letters/**").permitAll() .requestMatchers(PERMIT_PATHS).permitAll() .anyRequest().authenticated() ); diff --git a/src/main/java/com/rainbowletter/server/common/config/security/uri/AccessAllowUri.java b/src/main/java/com/rainbowletter/server/common/config/security/uri/AccessAllowUri.java index 715b098..5ec8196 100644 --- a/src/main/java/com/rainbowletter/server/common/config/security/uri/AccessAllowUri.java +++ b/src/main/java/com/rainbowletter/server/common/config/security/uri/AccessAllowUri.java @@ -10,6 +10,7 @@ public enum AccessAllowUri implements AllowUri { GET_FAQS("/api/faqs"), GET_IMAGES("/api/images/resources/**"), SHARE_LETTER("/api/letters/share/**"), + SHARE_PET_INITIATED_LETTER("/api/pet-initiated-letters/share/**"), LOG_USER_AGENT("/api/data/**"), ; diff --git a/src/main/java/com/rainbowletter/server/image/application/domain/service/ImageUploadEventHandler.java b/src/main/java/com/rainbowletter/server/image/application/domain/service/ImageUploadEventHandler.java index 16a2e23..ac8a386 100644 --- a/src/main/java/com/rainbowletter/server/image/application/domain/service/ImageUploadEventHandler.java +++ b/src/main/java/com/rainbowletter/server/image/application/domain/service/ImageUploadEventHandler.java @@ -30,7 +30,7 @@ public void handleImageUpload(ImageUploadEvent event) { storageService.uploadFile(webpData, "image/webp", event.filePath()); } catch (Exception e) { log.error("[이벤트 처리 중 이미지 업로드 실패] {}", event.filePath(), e); - slackErrorReportService.sendErrorReportToSlack(event.filePath(), e); + slackErrorReportService.sendImageUploadErrorReportToSlack(event.filePath(), e); } } diff --git a/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterAdminDetailResponse.java b/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterAdminDetailResponse.java index e70fa2f..90d4b55 100644 --- a/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterAdminDetailResponse.java +++ b/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterAdminDetailResponse.java @@ -2,14 +2,14 @@ import com.rainbowletter.server.letter.application.port.in.dto.LetterAdminRecentResponse; import com.rainbowletter.server.letter.application.port.in.dto.LetterResponse; -import com.rainbowletter.server.pet.application.port.in.dto.PetExcludeFavoriteResponse; +import com.rainbowletter.server.pet.application.port.in.dto.PetDetailResponse; import com.rainbowletter.server.reply.application.port.in.dto.ReplyAdminResponse; import com.rainbowletter.server.user.application.port.in.dto.UserInformationResponse; import java.util.List; public record LetterAdminDetailResponse( LetterAdminDetailUserInformationResponse user, - PetExcludeFavoriteResponse pet, + PetDetailResponse pet, LetterResponse letter, ReplyAdminResponse reply, List recent @@ -18,7 +18,7 @@ public record LetterAdminDetailResponse( public static LetterAdminDetailResponse of( final UserInformationResponse userInformation, final Long letterCount, - final PetExcludeFavoriteResponse petResponse, + final PetDetailResponse petResponse, final LetterResponse letterResponse, final ReplyAdminResponse replyResponse, final List recentResponses diff --git a/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterCollectResponse.java b/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterCollectResponse.java index ba0e6ad..6845b73 100644 --- a/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterCollectResponse.java +++ b/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterCollectResponse.java @@ -1,14 +1,16 @@ package com.rainbowletter.server.letter.adapter.in.web.dto; import com.rainbowletter.server.letter.adapter.out.dto.PaginationInfo; -import com.rainbowletter.server.letter.application.port.in.dto.LetterSimpleWithSequenceResponse; +import com.rainbowletter.server.letter.application.port.in.dto.LetterSimpleResponse; import com.rainbowletter.server.pet.application.port.in.dto.PetSummary; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.PetInitiatedLetterSimpleResponse; import java.util.List; public record LetterCollectResponse( PetSummary petSummary, - List letters, + List letters, + List petLetters, PaginationInfo paginationInfo ) { } \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterDetailResponse.java b/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterDetailResponse.java index 66e32f7..4bcb6de 100644 --- a/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterDetailResponse.java +++ b/src/main/java/com/rainbowletter/server/letter/adapter/in/web/dto/LetterDetailResponse.java @@ -1,18 +1,18 @@ package com.rainbowletter.server.letter.adapter.in.web.dto; import com.rainbowletter.server.letter.application.port.in.dto.LetterResponse; -import com.rainbowletter.server.pet.application.port.in.dto.PetExcludeFavoriteResponse; +import com.rainbowletter.server.pet.application.port.in.dto.PetDetailResponse; import com.rainbowletter.server.reply.application.port.in.dto.ReplyResponse; import jakarta.annotation.Nullable; public record LetterDetailResponse( - PetExcludeFavoriteResponse pet, + PetDetailResponse pet, LetterResponse letter, ReplyResponse reply ) { public static LetterDetailResponse of( - final PetExcludeFavoriteResponse petResponse, + final PetDetailResponse petResponse, final LetterResponse letterResponse, @Nullable final ReplyResponse replyResponse ) { diff --git a/src/main/java/com/rainbowletter/server/letter/adapter/out/persistence/ReadLetterPersistenceAdapter.java b/src/main/java/com/rainbowletter/server/letter/adapter/out/persistence/ReadLetterPersistenceAdapter.java index 166f52d..1e2a207 100644 --- a/src/main/java/com/rainbowletter/server/letter/adapter/out/persistence/ReadLetterPersistenceAdapter.java +++ b/src/main/java/com/rainbowletter/server/letter/adapter/out/persistence/ReadLetterPersistenceAdapter.java @@ -300,34 +300,6 @@ public Integer getLastNumber(final String email, final PetId petId) { return result.isPresent() ? result.get().getNumber() : 0; } - @Override - public Long countByPetIdAndEndDate(Long petId, LocalDateTime endDate) { - return queryFactory.select(letterJpaEntity.count()) - .from(letterJpaEntity) - .where( - letterJpaEntity.petId.eq(petId), - endDate != null ? letterJpaEntity.createdAt.loe(endDate) : null - ) - .fetchOne(); - } - -// @Override -// public List loadRecentLettersAndRepliesByPetId(final PetId petId) { -// return queryFactory -// .select(Projections.constructor( -// LetterWithReply.class, -// letterJpaEntity.id, -// letterJpaEntity.content, -// replyJpaEntity.content -// )) -// .from(letterJpaEntity) -// .leftJoin(replyJpaEntity).on(letterJpaEntity.id.eq(replyJpaEntity.letterId)) -// .where(letterJpaEntity.petId.eq(petId.value())) -// .orderBy(letterJpaEntity.createdAt.desc()) -// .limit(3) -// .fetch(); -// } - @Override public List loadRecentLettersByPetId(final PetId petId, final Long currentLetterId, diff --git a/src/main/java/com/rainbowletter/server/letter/application/domain/model/Letter.java b/src/main/java/com/rainbowletter/server/letter/application/domain/model/Letter.java index 891d5fe..80c9eb3 100644 --- a/src/main/java/com/rainbowletter/server/letter/application/domain/model/Letter.java +++ b/src/main/java/com/rainbowletter/server/letter/application/domain/model/Letter.java @@ -3,13 +3,14 @@ import com.rainbowletter.server.common.application.domain.model.AggregateRoot; import com.rainbowletter.server.pet.application.domain.model.Pet.PetId; import com.rainbowletter.server.user.application.domain.model.User.UserId; -import java.time.LocalDateTime; -import java.util.UUID; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.util.StringUtils; +import java.time.LocalDateTime; +import java.util.UUID; + @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Letter extends AggregateRoot { @@ -100,8 +101,12 @@ public void delete() { registerEvent(new DeleteLetterEvent(this)); } + public void read() { + this.status = LetterStatus.READ; + } + public enum LetterStatus { - REQUEST, RESPONSE, + REQUEST, RESPONSE, READ } public record LetterId(Long value) { diff --git a/src/main/java/com/rainbowletter/server/letter/application/domain/service/GetLettersByPetIdService.java b/src/main/java/com/rainbowletter/server/letter/application/domain/service/GetLettersByPetIdService.java index c71e2fb..27a8c8f 100644 --- a/src/main/java/com/rainbowletter/server/letter/application/domain/service/GetLettersByPetIdService.java +++ b/src/main/java/com/rainbowletter/server/letter/application/domain/service/GetLettersByPetIdService.java @@ -4,61 +4,49 @@ import com.rainbowletter.server.letter.adapter.in.web.dto.RetrieveLetterRequest; import com.rainbowletter.server.letter.adapter.out.dto.PaginationInfo; import com.rainbowletter.server.letter.application.port.in.dto.LetterSimpleResponse; -import com.rainbowletter.server.letter.application.port.in.dto.LetterSimpleWithSequenceResponse; -import com.rainbowletter.server.letter.application.port.out.CountLetterPort; import com.rainbowletter.server.letter.application.port.out.LoadLetterPort; import com.rainbowletter.server.pet.application.port.in.dto.PetSummary; import com.rainbowletter.server.pet.application.port.out.LoadPetPort; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.PetInitiatedLetterSimpleResponse; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterPersistenceAdapter; import com.rainbowletter.server.user.application.domain.model.User; import com.rainbowletter.server.user.application.port.out.LoadUserPort; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.List; @Service @RequiredArgsConstructor public class GetLettersByPetIdService { - private final CountLetterPort countLetterPort; private final LoadLetterPort loadLetterPort; private final LoadPetPort loadPetPort; private final LoadUserPort loadUserPort; + private final PetInitiatedLetterPersistenceAdapter petInitiatedLetterPersistenceAdapter; public LetterCollectResponse findByPetId(Long petId, RetrieveLetterRequest query, String email) { User user = loadUserPort.loadUserByEmail(email); PetSummary petSummary = loadPetPort.findPetSummaryById(petId, user.getId().value()); + List petInitiatedLetters = + petInitiatedLetterPersistenceAdapter.findByPetId(petId, query); + List letters = loadLetterPort.findByPetId(petId, query); boolean hasNext = letters.size() > query.limit(); - - long total = countLetterPort.countByPetIdAndEndDate(petId, query.endDate()); - - List result = new ArrayList<>(); - long seq = total - (query.after() != null ? getOffsetFromAfter(letters, query.after()) : 0); - for (LetterSimpleResponse letter : hasNext ? letters.subList(0, query.limit()) : letters) { - result.add(LetterSimpleWithSequenceResponse.from(letter, seq--)); - } + List result = hasNext + ? letters.subList(0, query.limit()) + : letters; String next = letters.isEmpty() ? null : buildNextUrl(letters.get(Math.min(query.limit(), letters.size()) - 1).id(), query.limit()); - return new LetterCollectResponse(petSummary, result, new PaginationInfo(next)); - } - - private long getOffsetFromAfter(List letters, Long after) { - for (int i = 0; i < letters.size(); i++) { - if (letters.get(i).id().equals(after)) { - return i + 1; - } - } - return 0; + return new LetterCollectResponse(petSummary, result, petInitiatedLetters, new PaginationInfo(next)); } private String buildNextUrl(Long lastId, int limit) { - return "https://api.rainbowletter.co.kr?after=" + lastId + "&limit=" + limit; + return "?after=" + lastId + "&limit=" + limit; } } diff --git a/src/main/java/com/rainbowletter/server/letter/application/port/in/dto/LetterSimpleWithSequenceResponse.java b/src/main/java/com/rainbowletter/server/letter/application/port/in/dto/LetterSimpleWithSequenceResponse.java deleted file mode 100644 index a1e14b0..0000000 --- a/src/main/java/com/rainbowletter/server/letter/application/port/in/dto/LetterSimpleWithSequenceResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.rainbowletter.server.letter.application.port.in.dto; - -import com.rainbowletter.server.letter.application.domain.model.Letter.LetterStatus; - -import java.time.LocalDateTime; - -public record LetterSimpleWithSequenceResponse( - Long id, - Long sequence, - LocalDateTime createdAt, - String summary, - String content, - LetterStatus letterStatus, - String image -) { - public static LetterSimpleWithSequenceResponse from(LetterSimpleResponse base, long sequence) { - return new LetterSimpleWithSequenceResponse( - base.id(), sequence, base.createdAt(), base.summary(), base.content(), base.letterStatus(), base.image() - ); - } -} diff --git a/src/main/java/com/rainbowletter/server/letter/application/port/out/CountLetterPort.java b/src/main/java/com/rainbowletter/server/letter/application/port/out/CountLetterPort.java index 05b6cc8..dd1e7b1 100644 --- a/src/main/java/com/rainbowletter/server/letter/application/port/out/CountLetterPort.java +++ b/src/main/java/com/rainbowletter/server/letter/application/port/out/CountLetterPort.java @@ -3,8 +3,6 @@ import com.rainbowletter.server.pet.application.domain.model.Pet.PetId; import com.rainbowletter.server.user.application.domain.model.User.UserId; -import java.time.LocalDateTime; - public interface CountLetterPort { Long countByUserId(UserId userId); @@ -12,6 +10,4 @@ public interface CountLetterPort { Long countByPetId(PetId petId); Integer getLastNumber(String email, PetId petId); - - Long countByPetIdAndEndDate(Long petId, LocalDateTime endDate); } diff --git a/src/main/java/com/rainbowletter/server/notification/application/domain/model/alimtalk/AlimTalkTemplateCode.java b/src/main/java/com/rainbowletter/server/notification/application/domain/model/alimtalk/AlimTalkTemplateCode.java index 0256207..ce878b4 100644 --- a/src/main/java/com/rainbowletter/server/notification/application/domain/model/alimtalk/AlimTalkTemplateCode.java +++ b/src/main/java/com/rainbowletter/server/notification/application/domain/model/alimtalk/AlimTalkTemplateCode.java @@ -7,6 +7,7 @@ @RequiredArgsConstructor public enum AlimTalkTemplateCode { REPLY("TX_0503"), + PET_INITIATED_LETTER("UB_8222"), ; private final String code; diff --git a/src/main/java/com/rainbowletter/server/notification/application/domain/model/alimtalk/template/PetInitiatedLetterAlimTalkTemplate.java b/src/main/java/com/rainbowletter/server/notification/application/domain/model/alimtalk/template/PetInitiatedLetterAlimTalkTemplate.java new file mode 100644 index 0000000..29879b8 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/notification/application/domain/model/alimtalk/template/PetInitiatedLetterAlimTalkTemplate.java @@ -0,0 +1,63 @@ +package com.rainbowletter.server.notification.application.domain.model.alimtalk.template; + +import com.rainbowletter.server.notification.application.domain.model.alimtalk.AlimTalkButton; +import com.rainbowletter.server.notification.application.domain.model.alimtalk.AlimTalkTemplateCode; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +class PetInitiatedLetterAlimTalkTemplate extends AbstractAlimTalkTemplate { + + PetInitiatedLetterAlimTalkTemplate() { + super(AlimTalkTemplateCode.PET_INITIATED_LETTER); + } + + @Override + public String subject(final Object... args) { + return "[무지개편지] 선편지 도착 알림"; + } + + @Override + public String failSubject(final Object... args) { + return "[무지개편지]"; + } + + @Override + public String content(final Object... args) { + validateTemplateParameters(3, args); + return """ + [무지개편지] 편지가 도착했어요! + \s + 안녕하세요, + %s %s님! + %s에게서 먼저 편지가 왔어요 : ) + \s + 아이가 먼저 보낸 편지를 보러 가실까요? + \s + * 본 메시지는 고객님이 신청하신 '편지 받아보기'에 대한 안내 메세지입니다. + """.formatted(args); + } + + @Override + public String failContent(final Object... args) { + validateTemplateParameters(3, args); + return """ + [무지개편지] 편지가 도착했어요! + \s + 안녕하세요, + %s %s님! + %s에게서 먼저 편지가 왔어요 : ) + \s + 편지함에서 확인해주세요! + """.formatted(args); + } + + @Override + public List buttons(final Object... args) { + validateTemplateParameters(1, args); + final String shareLink = String.valueOf(args[0]); + final var button1 = AlimTalkButton.createWebLink("편지 보러 가기", shareLink); + return List.of(button1); + } +} diff --git a/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/AbstractSurveyMailTemplate.java b/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/AbstractSurveyMailTemplate.java new file mode 100644 index 0000000..13c915b --- /dev/null +++ b/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/AbstractSurveyMailTemplate.java @@ -0,0 +1,63 @@ +package com.rainbowletter.server.notification.application.domain.model.mail; + +import com.rainbowletter.server.common.config.ClientConfig; +import lombok.Getter; +import org.springframework.context.MessageSource; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.ISpringTemplateEngine; + +import java.util.Locale; + +@Getter +abstract class AbstractSurveyMailTemplate extends AbstractMailTemplate { + + private final ClientConfig clientConfig; + private final MessageSource messageSource; + private final ISpringTemplateEngine templateEngine; + + protected AbstractSurveyMailTemplate( + MailTemplateCode templateCode, + ClientConfig clientConfig, + MessageSource messageSource, + ISpringTemplateEngine templateEngine + ) { + super(templateCode); + this.clientConfig = clientConfig; + this.messageSource = messageSource; + this.templateEngine = templateEngine; + } + + @Override + public String getTitle(final String title) { + Locale locale = getLocale(title); + return messageSource.getMessage("receive.reply.email.title", new String[]{title}, locale); + } + + @Override + public String getContent(final String receiver, final Object... args) { + validateTemplateParameters(2, args); + + String url = clientConfig.getBaseUrl() + args[0]; + Locale locale = getLocale(args[1].toString()); + + Context context = new Context(); + context.setLocale(locale); + context.setVariable("url", url); + context.setVariable("surveyUrl", getSurveyUrl(locale)); + + return templateEngine.process(getTemplateName(), context); + } + + protected Locale getLocale(String str) { + return str.matches(".*[ㄱ-ㅎㅏ-ㅣ가-힣]+.*") ? Locale.KOREA : Locale.ENGLISH; + } + + private String getSurveyUrl(Locale locale) { + if ("ko".equals(locale.getLanguage())) { + return "https://forms.gle/jouKvaubQQaL57Tg6"; + } + return "https://docs.google.com/forms/d/e/1FAIpQLSfwkSYfmCue-9cE-OvEGETPwc7wr9QxE_sMNfGYeytzHsF6Mw/viewform?usp=dialog"; + } + + protected abstract String getTemplateName(); +} diff --git a/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/MailTemplateCode.java b/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/MailTemplateCode.java index 3e933b2..7ba3e18 100644 --- a/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/MailTemplateCode.java +++ b/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/MailTemplateCode.java @@ -3,4 +3,5 @@ public enum MailTemplateCode { REPLY, FIND_PASSWORD, + PET_INITIATED_LETTER } diff --git a/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/PetInitiatedLetterTemplate.java b/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/PetInitiatedLetterTemplate.java new file mode 100644 index 0000000..6074237 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/PetInitiatedLetterTemplate.java @@ -0,0 +1,26 @@ +package com.rainbowletter.server.notification.application.domain.model.mail; + +import com.rainbowletter.server.common.config.ClientConfig; +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Component; +import org.thymeleaf.spring6.ISpringTemplateEngine; + +import static com.rainbowletter.server.notification.application.domain.model.mail.MailTemplateCode.PET_INITIATED_LETTER; + +@Component +class PetInitiatedLetterTemplate extends AbstractSurveyMailTemplate { + + PetInitiatedLetterTemplate( + ClientConfig clientConfig, + MessageSource messageSource, + ISpringTemplateEngine templateEngine + ) { + super(PET_INITIATED_LETTER, clientConfig, messageSource, templateEngine); + } + + @Override + protected String getTemplateName() { + return "receive-pet-initiated-letter"; + } + +} diff --git a/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/ReplyReceiveTemplate.java b/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/ReplyReceiveTemplate.java index b26c54e..2b9dbad 100644 --- a/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/ReplyReceiveTemplate.java +++ b/src/main/java/com/rainbowletter/server/notification/application/domain/model/mail/ReplyReceiveTemplate.java @@ -1,65 +1,26 @@ package com.rainbowletter.server.notification.application.domain.model.mail; -import static com.rainbowletter.server.notification.application.domain.model.mail.MailTemplateCode.REPLY; - import com.rainbowletter.server.common.config.ClientConfig; -import java.util.Locale; -import java.util.regex.Pattern; import org.springframework.context.MessageSource; import org.springframework.stereotype.Component; -import org.thymeleaf.context.Context; import org.thymeleaf.spring6.ISpringTemplateEngine; -@Component -class ReplyReceiveTemplate extends AbstractMailTemplate { - - private static final String KOREAN_REGEX = ".*[ㄱ-ㅎㅏ-ㅣ가-힣]+.*"; - private static final Pattern KOREAN_PATTERN = Pattern.compile(KOREAN_REGEX); +import static com.rainbowletter.server.notification.application.domain.model.mail.MailTemplateCode.REPLY; - private final ClientConfig clientConfig; - private final MessageSource messageSource; - private final ISpringTemplateEngine templateEngine; +@Component +class ReplyReceiveTemplate extends AbstractSurveyMailTemplate { ReplyReceiveTemplate( final ClientConfig clientConfig, final MessageSource messageSource, final ISpringTemplateEngine templateEngine ) { - super(REPLY); - this.clientConfig = clientConfig; - this.messageSource = messageSource; - this.templateEngine = templateEngine; + super(REPLY, clientConfig, messageSource, templateEngine); } @Override - public String getTitle(final String title) { - final Locale locale = getLocale(title); - return messageSource.getMessage("receive.reply.email.title", new String[]{title}, locale); - } - - @Override - public String getContent(final String receiver, final Object... args) { - validateTemplateParameters(2, args); - final var url = clientConfig.getBaseUrl() + args[0]; - final Locale locale = getLocale(args[1].toString()); - final Context context = new Context(); - context.setLocale(locale); - context.setVariable("url", url); - if (locale.getLanguage().equals("ko")) { - context.setVariable("surveyUrl", "https://forms.gle/jouKvaubQQaL57Tg6"); - } else { - context.setVariable("surveyUrl", - "https://docs.google.com/forms/d/e/1FAIpQLSfwkSYfmCue-9cE-OvEGETPwc7wr9QxE_sMNfGYeytzHsF6Mw/viewform?usp=dialog"); - } - return templateEngine.process("receive-reply", context); - } - - private Locale getLocale(final String str) { - if (KOREAN_PATTERN.matcher(str).matches()) { - return Locale.KOREA; - } else { - return Locale.ENGLISH; - } + protected String getTemplateName() { + return "receive-reply"; } } diff --git a/src/main/java/com/rainbowletter/server/pet/adapter/in/web/IncreaseFavoriteController.java b/src/main/java/com/rainbowletter/server/pet/adapter/in/web/IncreaseFavoriteController.java deleted file mode 100644 index 8dba5f0..0000000 --- a/src/main/java/com/rainbowletter/server/pet/adapter/in/web/IncreaseFavoriteController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.rainbowletter.server.pet.adapter.in.web; - -import com.rainbowletter.server.common.annotation.WebAdapter; -import com.rainbowletter.server.common.util.SecurityUtils; -import com.rainbowletter.server.pet.application.domain.model.Pet.PetId; -import com.rainbowletter.server.pet.application.port.in.IncreaseFavoriteUseCase; -import com.rainbowletter.server.pet.application.port.in.IncreaseFavoriteUseCase.IncreaseFavoriteCommand; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@WebAdapter -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/pets") -@Tag(name = "pet") -class IncreaseFavoriteController { - - private final IncreaseFavoriteUseCase increaseFavoriteUseCase; - - @PostMapping("/favorite/{id}") - ResponseEntity increaseFavorite(@PathVariable("id") final Long id) { - final String email = SecurityUtils.getEmail(); - final var command = new IncreaseFavoriteCommand(email, new PetId(id)); - increaseFavoriteUseCase.increaseFavorite(command); - return ResponseEntity.ok().build(); - } - -} diff --git a/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/FavoriteJpaEntity.java b/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/FavoriteJpaEntity.java deleted file mode 100644 index 0b41a88..0000000 --- a/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/FavoriteJpaEntity.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.rainbowletter.server.pet.adapter.out.persistence; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; -import java.util.Objects; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "favorite") -@Getter -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -class FavoriteJpaEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @NotNull - private int total; - - @NotNull - private int dayIncreaseCount; - - @NotNull - private boolean canIncrease; - - @NotNull - private LocalDateTime lastIncreasedAt; - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof final FavoriteJpaEntity favoriteJpaEntity)) { - return false; - } - return Objects.equals(id, favoriteJpaEntity.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } - -} diff --git a/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetJpaEntity.java b/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetJpaEntity.java index 7dbc3fb..5a6ca4a 100644 --- a/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetJpaEntity.java +++ b/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetJpaEntity.java @@ -1,27 +1,19 @@ package com.rainbowletter.server.pet.adapter.out.persistence; -import static lombok.AccessLevel.PROTECTED; - import com.rainbowletter.server.common.adapter.out.persistence.BaseTimeJpaEntity; import com.rainbowletter.server.common.adapter.out.persistence.JpaStringToListConverter; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Convert; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; + +import static lombok.AccessLevel.PROTECTED; @Entity @Table(name = "pet") @@ -53,11 +45,6 @@ class PetJpaEntity extends BaseTimeJpaEntity { private LocalDate deathAnniversary; - - @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "favorite_id", referencedColumnName = "id", nullable = false) - private FavoriteJpaEntity favoriteJpaEntity; - @Override public boolean equals(final Object o) { if (this == o) { diff --git a/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetMapper.java b/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetMapper.java index 54e35c1..d6608aa 100644 --- a/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetMapper.java +++ b/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetMapper.java @@ -1,11 +1,10 @@ package com.rainbowletter.server.pet.adapter.out.persistence; import com.rainbowletter.server.common.annotation.PersistenceMapper; -import com.rainbowletter.server.pet.application.domain.model.Favorite; -import com.rainbowletter.server.pet.application.domain.model.Favorite.FavoriteId; import com.rainbowletter.server.pet.application.domain.model.Pet; import com.rainbowletter.server.pet.application.domain.model.Pet.PetId; import com.rainbowletter.server.user.application.domain.model.User.UserId; + import java.util.Objects; @PersistenceMapper @@ -15,7 +14,6 @@ Pet mapToPet(final PetJpaEntity jpaEntity) { return Pet.withId( new PetId(jpaEntity.getId()), new UserId(jpaEntity.getUserId()), - mapToFavorite(jpaEntity.getFavoriteJpaEntity()), jpaEntity.getName(), jpaEntity.getSpecies(), jpaEntity.getOwner(), @@ -36,28 +34,7 @@ PetJpaEntity mapToPetEntity(final Pet domain) { domain.getOwner(), domain.getImage(), domain.getPersonalities(), - domain.getDeathAnniversary(), - mapToFavoriteEntity(domain.getFavorite()) - ); - } - - Favorite mapToFavorite(final FavoriteJpaEntity jpaEntity) { - return Favorite.withId( - new FavoriteId(jpaEntity.getId()), - jpaEntity.getTotal(), - jpaEntity.getDayIncreaseCount(), - jpaEntity.isCanIncrease(), - jpaEntity.getLastIncreasedAt() - ); - } - - FavoriteJpaEntity mapToFavoriteEntity(final Favorite domain) { - return new FavoriteJpaEntity( - Objects.isNull(domain.getId()) ? null : domain.getId().value(), - domain.getTotal(), - domain.getDayIncreaseCount(), - domain.isCanIncrease(), - domain.getLastIncreasedAt() + domain.getDeathAnniversary() ); } diff --git a/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetPersistenceAdapter.java b/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetPersistenceAdapter.java index 7dc2105..a728999 100644 --- a/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetPersistenceAdapter.java +++ b/src/main/java/com/rainbowletter/server/pet/adapter/out/persistence/PetPersistenceAdapter.java @@ -24,7 +24,7 @@ @PersistenceAdapter @RequiredArgsConstructor class PetPersistenceAdapter implements CreatePetPort, LoadPetPort, LoadPetDashboardPort, - ResetFavoriteStatePort, UpdatePetStatePort, DeletePetPort { + UpdatePetStatePort, DeletePetPort { private final JPAQueryFactory queryFactory; private final PetMapper petMapper; @@ -40,14 +40,6 @@ public Pet createPet(final Pet pet) { return save(pet); } - @Override - public void resetFavorite() { - queryFactory.update(petJpaEntity) - .set(petJpaEntity.favoriteJpaEntity.canIncrease, true) - .set(petJpaEntity.favoriteJpaEntity.dayIncreaseCount, 0) - .execute(); - } - @Override public Pet loadPetByIdAndUserId(final PetId petId, final UserId userId) { return petJpaRepository.findByIdAndUserId(petId.value(), userId.value()) @@ -121,7 +113,6 @@ public List loadPetDashboard(final String email) { petJpaEntity.id, petJpaEntity.name, letterJpaEntity.count().as("letterCount"), - petJpaEntity.favoriteJpaEntity.total.as("favoriteCount"), petJpaEntity.image, petJpaEntity.deathAnniversary )) diff --git a/src/main/java/com/rainbowletter/server/pet/application/domain/model/Favorite.java b/src/main/java/com/rainbowletter/server/pet/application/domain/model/Favorite.java deleted file mode 100644 index c77eac5..0000000 --- a/src/main/java/com/rainbowletter/server/pet/application/domain/model/Favorite.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.rainbowletter.server.pet.application.domain.model; - -import com.rainbowletter.server.common.application.domain.exception.RainbowLetterException; -import java.time.LocalDateTime; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Favorite { - - private final FavoriteId id; - - private int total; - private int dayIncreaseCount; - private boolean canIncrease; - private LocalDateTime lastIncreasedAt; - - public static Favorite withId( - final FavoriteId id, - final int total, - final int dayIncreaseCount, - final boolean canIncrease, - final LocalDateTime lastIncreasedAt - ) { - return new Favorite(id, total, dayIncreaseCount, canIncrease, lastIncreasedAt); - } - - public static Favorite withoutId( - final int total, - final int dayIncreaseCount, - final boolean canIncrease, - final LocalDateTime lastIncreasedAt - ) { - return new Favorite(null, total, dayIncreaseCount, canIncrease, lastIncreasedAt); - } - - public void increase(final LocalDateTime lastIncreasedAt) { - validateIncrease(dayIncreaseCount); - this.total++; - this.dayIncreaseCount++; - this.canIncrease = canIncrease(dayIncreaseCount); - this.lastIncreasedAt = lastIncreasedAt; - } - - private void validateIncrease(final int dayIncreaseCount) { - if (!canIncrease(dayIncreaseCount)) { - throw new RainbowLetterException("max.pet.like.count"); - } - } - - private boolean canIncrease(final int dayIncreaseCount) { - return dayIncreaseCount < 3; - } - - public record FavoriteId(Long value) { - - @Override - public String toString() { - return value.toString(); - } - - } - -} diff --git a/src/main/java/com/rainbowletter/server/pet/application/domain/model/Pet.java b/src/main/java/com/rainbowletter/server/pet/application/domain/model/Pet.java index 51a6d08..da85e68 100644 --- a/src/main/java/com/rainbowletter/server/pet/application/domain/model/Pet.java +++ b/src/main/java/com/rainbowletter/server/pet/application/domain/model/Pet.java @@ -16,7 +16,6 @@ public class Pet extends AggregateRoot { private final PetId id; private final UserId userId; - private final Favorite favorite; private String name; private String species; @@ -36,14 +35,12 @@ public static Pet withoutId( final String image, final List personalities, final LocalDate deathAnniversary, - final LocalDateTime lastIncreasedAt, final LocalDateTime createdAt, final LocalDateTime updatedAt ) { return new Pet( null, userId, - Favorite.withoutId(0, 0, true, lastIncreasedAt), name, species, owner, @@ -59,7 +56,6 @@ public static Pet withoutId( public static Pet withId( final PetId id, final UserId userId, - final Favorite favorite, final String name, final String species, final String owner, @@ -72,7 +68,6 @@ public static Pet withId( return new Pet( id, userId, - favorite, name, species, owner, @@ -100,10 +95,6 @@ public void update( this.deathAnniversary = deathAnniversary; } - public void increaseFavorite(final LocalDateTime lastIncreasedAt) { - favorite.increase(lastIncreasedAt); - } - public boolean hasImage() { return StringUtils.hasText(image); } diff --git a/src/main/java/com/rainbowletter/server/pet/application/domain/service/CreatePetService.java b/src/main/java/com/rainbowletter/server/pet/application/domain/service/CreatePetService.java index 3f616e2..1390997 100644 --- a/src/main/java/com/rainbowletter/server/pet/application/domain/service/CreatePetService.java +++ b/src/main/java/com/rainbowletter/server/pet/application/domain/service/CreatePetService.java @@ -32,7 +32,6 @@ public Long createPet(final CreatePetCommand command) { command.getPersonalities(), command.getDeathAnniversary(), currentTime, - currentTime, currentTime ); return createPetPort.createPet(pet) diff --git a/src/main/java/com/rainbowletter/server/pet/application/domain/service/GetPetByLetterService.java b/src/main/java/com/rainbowletter/server/pet/application/domain/service/GetPetByLetterService.java index 5e62ad3..df91165 100644 --- a/src/main/java/com/rainbowletter/server/pet/application/domain/service/GetPetByLetterService.java +++ b/src/main/java/com/rainbowletter/server/pet/application/domain/service/GetPetByLetterService.java @@ -3,7 +3,7 @@ import com.rainbowletter.server.common.annotation.UseCase; import com.rainbowletter.server.pet.application.domain.model.Pet; import com.rainbowletter.server.pet.application.port.in.GetPetByLetterUseCase; -import com.rainbowletter.server.pet.application.port.in.dto.PetExcludeFavoriteResponse; +import com.rainbowletter.server.pet.application.port.in.dto.PetDetailResponse; import com.rainbowletter.server.pet.application.port.out.LoadPetPort; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; @@ -16,15 +16,15 @@ class GetPetByLetterService implements GetPetByLetterUseCase { private final LoadPetPort loadPetPort; @Override - public PetExcludeFavoriteResponse getPetByLetterId(final GetPetByLetterIdQuery query) { + public PetDetailResponse getPetByLetterId(final GetPetByLetterIdQuery query) { final Pet pet = loadPetPort.loadPetByLetterId(query.letterId()); - return PetExcludeFavoriteResponse.from(pet); + return PetDetailResponse.from(pet); } @Override - public PetExcludeFavoriteResponse getPetByShareLink(final GetPetByShareLinkQuery query) { + public PetDetailResponse getPetByShareLink(final GetPetByShareLinkQuery query) { final Pet pet = loadPetPort.loadPetByShareLink(query.shareLink()); - return PetExcludeFavoriteResponse.from(pet); + return PetDetailResponse.from(pet); } } diff --git a/src/main/java/com/rainbowletter/server/pet/application/domain/service/IncreaseFavoriteService.java b/src/main/java/com/rainbowletter/server/pet/application/domain/service/IncreaseFavoriteService.java deleted file mode 100644 index dc0a9a5..0000000 --- a/src/main/java/com/rainbowletter/server/pet/application/domain/service/IncreaseFavoriteService.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.rainbowletter.server.pet.application.domain.service; - -import com.rainbowletter.server.common.annotation.UseCase; -import com.rainbowletter.server.pet.application.domain.model.Pet; -import com.rainbowletter.server.pet.application.port.in.IncreaseFavoriteUseCase; -import com.rainbowletter.server.pet.application.port.out.LoadPetPort; -import com.rainbowletter.server.pet.application.port.out.UpdatePetStatePort; -import com.rainbowletter.server.user.application.domain.model.User; -import com.rainbowletter.server.user.application.port.out.LoadUserPort; -import java.time.LocalDateTime; -import lombok.RequiredArgsConstructor; -import org.springframework.transaction.annotation.Transactional; - -@UseCase -@RequiredArgsConstructor -@Transactional -class IncreaseFavoriteService implements IncreaseFavoriteUseCase { - - private final LoadUserPort loadUserPort; - private final LoadPetPort loadPetPort; - private final UpdatePetStatePort updatePetStatePort; - - @Override - public void increaseFavorite(final IncreaseFavoriteCommand command) { - final User user = loadUserPort.loadUserByEmail(command.email()); - final Pet pet = loadPetPort.loadPetByIdAndUserId(command.petId(), user.getId()); - pet.increaseFavorite(LocalDateTime.now()); - updatePetStatePort.updatePet(pet); - } - -} diff --git a/src/main/java/com/rainbowletter/server/pet/application/domain/service/ResetFavoriteScheduler.java b/src/main/java/com/rainbowletter/server/pet/application/domain/service/ResetFavoriteScheduler.java deleted file mode 100644 index 1ba3644..0000000 --- a/src/main/java/com/rainbowletter/server/pet/application/domain/service/ResetFavoriteScheduler.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.rainbowletter.server.pet.application.domain.service; - -import com.rainbowletter.server.pet.application.port.out.ResetFavoriteStatePort; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -class ResetFavoriteScheduler { - - private final ResetFavoriteStatePort resetFavoriteStatePort; - - @Async - @Transactional - @Scheduled(cron = "0 0 0 * * *") - public void resetFavorite() { - resetFavoriteStatePort.resetFavorite(); - } - -} diff --git a/src/main/java/com/rainbowletter/server/pet/application/port/in/GetPetByLetterUseCase.java b/src/main/java/com/rainbowletter/server/pet/application/port/in/GetPetByLetterUseCase.java index eedb23c..aa49368 100644 --- a/src/main/java/com/rainbowletter/server/pet/application/port/in/GetPetByLetterUseCase.java +++ b/src/main/java/com/rainbowletter/server/pet/application/port/in/GetPetByLetterUseCase.java @@ -1,14 +1,14 @@ package com.rainbowletter.server.pet.application.port.in; import com.rainbowletter.server.letter.application.domain.model.Letter.LetterId; -import com.rainbowletter.server.pet.application.port.in.dto.PetExcludeFavoriteResponse; +import com.rainbowletter.server.pet.application.port.in.dto.PetDetailResponse; import java.util.UUID; public interface GetPetByLetterUseCase { - PetExcludeFavoriteResponse getPetByLetterId(GetPetByLetterIdQuery query); + PetDetailResponse getPetByLetterId(GetPetByLetterIdQuery query); - PetExcludeFavoriteResponse getPetByShareLink(GetPetByShareLinkQuery query); + PetDetailResponse getPetByShareLink(GetPetByShareLinkQuery query); record GetPetByLetterIdQuery(LetterId letterId) { diff --git a/src/main/java/com/rainbowletter/server/pet/application/port/in/IncreaseFavoriteUseCase.java b/src/main/java/com/rainbowletter/server/pet/application/port/in/IncreaseFavoriteUseCase.java deleted file mode 100644 index f73d4fd..0000000 --- a/src/main/java/com/rainbowletter/server/pet/application/port/in/IncreaseFavoriteUseCase.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.rainbowletter.server.pet.application.port.in; - -import com.rainbowletter.server.pet.application.domain.model.Pet.PetId; - -public interface IncreaseFavoriteUseCase { - - void increaseFavorite(IncreaseFavoriteCommand command); - - record IncreaseFavoriteCommand(String email, PetId petId) { - - } - -} diff --git a/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetExcludeFavoriteResponse.java b/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetDetailResponse.java similarity index 83% rename from src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetExcludeFavoriteResponse.java rename to src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetDetailResponse.java index 9606cdd..9d2aab1 100644 --- a/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetExcludeFavoriteResponse.java +++ b/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetDetailResponse.java @@ -1,11 +1,12 @@ package com.rainbowletter.server.pet.application.port.in.dto; import com.rainbowletter.server.pet.application.domain.model.Pet; + import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -public record PetExcludeFavoriteResponse( +public record PetDetailResponse( Long id, Long userId, String name, @@ -18,8 +19,8 @@ public record PetExcludeFavoriteResponse( LocalDateTime updatedAt ) { - public static PetExcludeFavoriteResponse from(final Pet pet) { - return new PetExcludeFavoriteResponse( + public static PetDetailResponse from(final Pet pet) { + return new PetDetailResponse( pet.getId().value(), pet.getUserId().value(), pet.getName(), diff --git a/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetForAdminResponse.java b/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetForAdminResponse.java new file mode 100644 index 0000000..d6722d3 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetForAdminResponse.java @@ -0,0 +1,14 @@ +package com.rainbowletter.server.pet.application.port.in.dto; + +import java.time.LocalDate; +import java.util.List; + +public record PetForAdminResponse( + Long id, + String name, + String owner, + String species, + List personalities, + LocalDate deathAnniversary +) { +} diff --git a/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetResponse.java b/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetResponse.java index 358989c..7d3ceda 100644 --- a/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetResponse.java +++ b/src/main/java/com/rainbowletter/server/pet/application/port/in/dto/PetResponse.java @@ -1,7 +1,7 @@ package com.rainbowletter.server.pet.application.port.in.dto; -import com.rainbowletter.server.pet.application.domain.model.Favorite; import com.rainbowletter.server.pet.application.domain.model.Pet; + import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -15,7 +15,6 @@ public record PetResponse( List personalities, LocalDate deathAnniversary, String image, - FavoriteResponse favorite, LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -30,30 +29,9 @@ public static PetResponse from(final Pet pet) { pet.getPersonalities(), pet.getDeathAnniversary(), pet.getImage(), - FavoriteResponse.from(pet.getFavorite()), pet.getCreatedAt(), pet.getUpdatedAt() ); } - public record FavoriteResponse( - Long id, - int total, - int dayIncreaseCount, - boolean canIncrease, - LocalDateTime lastIncreasedAt - ) { - - public static FavoriteResponse from(final Favorite favorite) { - return new FavoriteResponse( - favorite.getId().value(), - favorite.getTotal(), - favorite.getDayIncreaseCount(), - favorite.isCanIncrease(), - favorite.getLastIncreasedAt() - ); - } - - } - } diff --git a/src/main/java/com/rainbowletter/server/pet/application/port/out/ResetFavoriteStatePort.java b/src/main/java/com/rainbowletter/server/pet/application/port/out/ResetFavoriteStatePort.java deleted file mode 100644 index 0e1bb62..0000000 --- a/src/main/java/com/rainbowletter/server/pet/application/port/out/ResetFavoriteStatePort.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.rainbowletter.server.pet.application.port.out; - -public interface ResetFavoriteStatePort { - - void resetFavorite(); - -} diff --git a/src/main/java/com/rainbowletter/server/pet/application/port/out/dto/PetDashboardResponse.java b/src/main/java/com/rainbowletter/server/pet/application/port/out/dto/PetDashboardResponse.java index a221e84..1e5a151 100644 --- a/src/main/java/com/rainbowletter/server/pet/application/port/out/dto/PetDashboardResponse.java +++ b/src/main/java/com/rainbowletter/server/pet/application/port/out/dto/PetDashboardResponse.java @@ -6,7 +6,6 @@ public record PetDashboardResponse( Long id, String name, Long letterCount, - int favoriteCount, String image, LocalDate deathAnniversary ) { diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/AdminPetInitiatedLetterController.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/AdminPetInitiatedLetterController.java new file mode 100644 index 0000000..cc84738 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/AdminPetInitiatedLetterController.java @@ -0,0 +1,72 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web; + +import com.rainbowletter.server.common.annotation.WebAdapter; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.PetInitiatedLetterForAdminResponse; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.PetInitiatedLetterResponse; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.PetInitiatedLetterUpdateRequest; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.RetrievePetInitiatedLettersRequest; +import com.rainbowletter.server.petinitiatedletter.application.domain.service.PetInitiatedLetterService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@WebAdapter +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admins/pet-initiated-letters") +@Tag(name = "pet-initiated-letter", description = "선편지") +public class AdminPetInitiatedLetterController { + + private final PetInitiatedLetterService petInitiatedLetterService; + + @Operation(summary = "선편지 목록 조회/검색") + @GetMapping + public ResponseEntity> getPetInitiatedLetters( + @Valid @ModelAttribute RetrievePetInitiatedLettersRequest request, + @ParameterObject Pageable pageable + ) { + Page response = petInitiatedLetterService.getPetInitiatedLetters(request, pageable); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation(summary = "관리자 - 선편지 상세 조회") + @GetMapping("/{letter-id}") + public ResponseEntity getPetInitiatedLetterDetailForAdmin( + @PathVariable("letter-id") Long letterId, + @RequestParam("user-id") Long userId, + @RequestParam("pet-id") Long petId + ) { + PetInitiatedLetterForAdminResponse response = + petInitiatedLetterService.getPetInitiatedLetterDetailForAdmin(letterId, userId, petId); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation(summary = "선편지 수정") + @PutMapping("/{letter-id}") + public void updatePetInitiatedLetter( + @PathVariable("letter-id") Long letterId, + @Valid @RequestBody PetInitiatedLetterUpdateRequest request + ) { + petInitiatedLetterService.updatePetInitiatedLetter(letterId, request); + } + + @Operation(summary = "선편지 GPT 재생성") + @PostMapping("/generate/{letter-id}") + public void generate(@PathVariable("letter-id") Long letterId) { + petInitiatedLetterService.regeneratePetInitiatedLetter(letterId); + } + + @Operation(summary = "선편지 발송") + @PostMapping("/submit/{letter-id}") + public void submit(@PathVariable("letter-id") Long letterId) { + petInitiatedLetterService.submitPetInitiatedLetter(letterId); + } + +} \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/PetInitiatedLetterController.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/PetInitiatedLetterController.java new file mode 100644 index 0000000..725ce4a --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/PetInitiatedLetterController.java @@ -0,0 +1,49 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web; + +import com.rainbowletter.server.common.annotation.WebAdapter; +import com.rainbowletter.server.common.util.SecurityUtils; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.PetInitiatedLetterSummary; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.PetInitiatedLetterWithPetNameResponse; +import com.rainbowletter.server.petinitiatedletter.application.domain.service.PetInitiatedLetterService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@WebAdapter +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/pet-initiated-letters") +@Tag(name = "pet-initiated-letter", description = "선편지") +public class PetInitiatedLetterController { + + private final PetInitiatedLetterService petInitiatedLetterService; + + @Operation(summary = "공유링크 - 선편지 상세 조회") + @GetMapping("/share/{shareLink}") + public ResponseEntity getLetterByShareLink( + @PathVariable("shareLink") String shareLink + ) { + PetInitiatedLetterWithPetNameResponse response = + petInitiatedLetterService.getLetterByShareLink(UUID.fromString(shareLink)); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation(summary = "사용자 - 선편지 상세 조회") + @GetMapping("/{letter-id}") + public ResponseEntity getPetInitiatedLetterDetail( + @PathVariable("letter-id") Long letterId + ) { + String email = SecurityUtils.getEmail(); + PetInitiatedLetterSummary response = petInitiatedLetterService.getPetInitiatedLetterDetail(email, letterId); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterDetailResponse.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterDetailResponse.java new file mode 100644 index 0000000..c542a01 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterDetailResponse.java @@ -0,0 +1,15 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus; + +import java.time.LocalDateTime; + +public record PetInitiatedLetterDetailResponse( + Long id, + PetInitiatedLetterStatus petInitiatedLetterStatus, + LocalDateTime submitTime, + LocalDateTime createdAt, + String promptA, + String promptB +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterForAdminResponse.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterForAdminResponse.java new file mode 100644 index 0000000..83c7c5b --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterForAdminResponse.java @@ -0,0 +1,15 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import com.rainbowletter.server.pet.application.port.in.dto.PetForAdminResponse; +import com.rainbowletter.server.user.application.port.in.dto.UserForAdminResponse; + +import java.util.List; + +public record PetInitiatedLetterForAdminResponse( + UserForAdminResponse userForAdminResponse, + Long letterCount, + PetForAdminResponse petForAdminResponse, + List petInitiatedLettersForAdminResponse, + PetInitiatedLetterDetailResponse petInitiatedLetterDetailResponse +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterResponse.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterResponse.java new file mode 100644 index 0000000..67ab032 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterResponse.java @@ -0,0 +1,19 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus; + +import java.time.LocalDateTime; + +public record PetInitiatedLetterResponse( + Long id, + Long petId, + LocalDateTime createdAt, + String summary, + String content, + PetInitiatedLetterStatus status, + String userEmail, + Long userId, + boolean isPetInitiatedLetterEnabled, + LocalDateTime submitTime +) { +} \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterSimpleResponse.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterSimpleResponse.java new file mode 100644 index 0000000..5ed75f3 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterSimpleResponse.java @@ -0,0 +1,12 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import java.time.LocalDateTime; + +public record PetInitiatedLetterSimpleResponse( + Long id, + LocalDateTime createdAt, + String summary, + String content, + boolean readStatus +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterSummary.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterSummary.java new file mode 100644 index 0000000..e5921d7 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterSummary.java @@ -0,0 +1,13 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import java.time.LocalDateTime; + +public record PetInitiatedLetterSummary( + Long letterId, + LocalDateTime createdAt, + String content, + Long petId, + String petName, + String petImage +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterUpdateRequest.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterUpdateRequest.java new file mode 100644 index 0000000..61c76c3 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterUpdateRequest.java @@ -0,0 +1,17 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import jakarta.validation.constraints.NotNull; + +import static com.rainbowletter.server.ai.application.domain.model.AiPrompt.PromptType; + +public record PetInitiatedLetterUpdateRequest( + @NotNull + PromptType promptType, + + @NotNull + String summary, + + @NotNull + String content +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterWithPetNameResponse.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterWithPetNameResponse.java new file mode 100644 index 0000000..3afa900 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLetterWithPetNameResponse.java @@ -0,0 +1,15 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import java.time.LocalDateTime; + +public record PetInitiatedLetterWithPetNameResponse( + Long letterId, + LocalDateTime createdAt, + LocalDateTime submitTime, + String summary, + String content, + Long petId, + String petName, + String petImage +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLettersForAdminResponse.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLettersForAdminResponse.java new file mode 100644 index 0000000..60683a3 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetInitiatedLettersForAdminResponse.java @@ -0,0 +1,14 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus; + +import java.time.LocalDateTime; + +public record PetInitiatedLettersForAdminResponse( + Long id, + String petName, + String summary, + PetInitiatedLetterStatus petInitiatedLetterStatus, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetLetterSettingResponse.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetLetterSettingResponse.java new file mode 100644 index 0000000..43c0daa --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/PetLetterSettingResponse.java @@ -0,0 +1,6 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +public record PetLetterSettingResponse( + boolean isEnabled +) { +} \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/RetrievePetInitiatedLettersRequest.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/RetrievePetInitiatedLettersRequest.java new file mode 100644 index 0000000..7f63dfb --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/in/web/dto/RetrievePetInitiatedLettersRequest.java @@ -0,0 +1,24 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto; + +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +@ParameterObject +public record RetrievePetInitiatedLettersRequest( + @Schema(description = "검색일 (작성일 yyyy-MM-dd)", example = "2025-02-01") + @DateTimeFormat(pattern = "yyyy-MM-dd") + @NotNull + LocalDate searchDate, + + @Schema(description = "발송 상태") + PetInitiatedLetterStatus status, + + @Schema(description = "이메일 검색") + String email +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/infrastructure/PetInitiatedLetterGenerator.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/infrastructure/PetInitiatedLetterGenerator.java new file mode 100644 index 0000000..20c6e1d --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/infrastructure/PetInitiatedLetterGenerator.java @@ -0,0 +1,68 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.out.infrastructure; + +import com.rainbowletter.server.ai.application.domain.model.AiPrompt; +import com.rainbowletter.server.ai.application.domain.model.AiPrompt.PromptType; +import com.rainbowletter.server.ai.application.domain.model.AiSetting; +import com.rainbowletter.server.ai.application.port.out.AiClientCommand; +import com.rainbowletter.server.ai.application.port.out.CallAiClientPort; +import com.rainbowletter.server.ai.application.port.out.LoadSettingPort; +import com.rainbowletter.server.pet.application.domain.model.Pet; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.GeneratedLetterContent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class PetInitiatedLetterGenerator { + + private final LoadSettingPort loadSettingPort; + private final CallAiClientPort callAiClientPort; + + public GeneratedLetterContent generate(Pet pet) { + final AiSetting aiSetting = loadSettingPort.loadPetInitiatedLetterSetting(); + + if (Boolean.TRUE.equals(aiSetting.getUseABTest())) { + final Map results = new EnumMap<>(PromptType.class); + + for (AiPrompt prompt : aiSetting.getPrompts()) { + String content = callAiClientPort.call(new AiClientCommand(prompt, List.of(pet))) + .getResult() + .getOutput() + .getContent(); + results.put(prompt.getType(), content); + } + + PromptType selected = aiSetting.getSelectPrompt(); + String contentA = results.get(PromptType.A); + String contentB = results.get(PromptType.B); + String selectedContent = selected == PromptType.A ? contentA : contentB; + + return new GeneratedLetterContent( + selectedContent.substring(0, 20), + selectedContent, + contentA, + contentB, + selected + ); + + } else { + AiPrompt selectedPrompt = aiSetting.getSelectedPrompt(); + String content = callAiClientPort.call(new AiClientCommand(selectedPrompt, List.of(pet))) + .getResult() + .getOutput() + .getContent(); + + return new GeneratedLetterContent( + content.substring(0, 20), + content, + selectedPrompt.getType() == PromptType.A ? content : "", + selectedPrompt.getType() == PromptType.B ? content : "", + selectedPrompt.getType() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/PetInitiatedLetterJpaRepository.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/PetInitiatedLetterJpaRepository.java new file mode 100644 index 0000000..a0a435d --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/PetInitiatedLetterJpaRepository.java @@ -0,0 +1,13 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.out.persistence; + +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetter; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface PetInitiatedLetterJpaRepository extends JpaRepository { + List findAllByStatusAndCreatedAtBetween( + PetInitiatedLetterStatus status, LocalDateTime startOfDay, LocalDateTime endOfDay); +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/PetInitiatedLetterPersistenceAdapter.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/PetInitiatedLetterPersistenceAdapter.java new file mode 100644 index 0000000..aecf332 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/PetInitiatedLetterPersistenceAdapter.java @@ -0,0 +1,266 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.out.persistence; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.rainbowletter.server.common.annotation.PersistenceAdapter; +import com.rainbowletter.server.common.application.domain.exception.RainbowLetterException; +import com.rainbowletter.server.letter.adapter.in.web.dto.RetrieveLetterRequest; +import com.rainbowletter.server.pet.application.port.in.dto.PetForAdminResponse; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.*; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.PetInitiatedLetterStats; +import com.rainbowletter.server.user.application.port.in.dto.UserForAdminResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static com.rainbowletter.server.pet.adapter.out.persistence.QPetJpaEntity.petJpaEntity; +import static com.rainbowletter.server.petinitiatedletter.application.domain.model.QPetInitiatedLetter.petInitiatedLetter; +import static com.rainbowletter.server.user.adapter.out.persistence.QUserJpaEntity.userJpaEntity; + +@PersistenceAdapter +@RequiredArgsConstructor +public class PetInitiatedLetterPersistenceAdapter { + + private final JPAQueryFactory queryFactory; + + public List findByPetId(Long petId, RetrieveLetterRequest query) { + return queryFactory.select(Projections.constructor( + PetInitiatedLetterSimpleResponse.class, + petInitiatedLetter.id, + petInitiatedLetter.createdAt, + petInitiatedLetter.summary, + petInitiatedLetter.content, + petInitiatedLetter.readStatus + )) + .from(petInitiatedLetter) + .where( + petInitiatedLetter.petId.eq(petId), + query.after() != null ? petInitiatedLetter.id.lt(query.after()) : null, + query.startDate() != null ? petInitiatedLetter.createdAt.goe(query.startDate()) : null, + query.endDate() != null ? petInitiatedLetter.createdAt.loe(query.endDate()) : null + ) + .orderBy(petInitiatedLetter.id.desc()) + .limit(query.limit() + 1L) + .fetch(); + } + + public Page getPetInitiatedLetters( + RetrievePetInitiatedLettersRequest query, + Pageable pageable + ) { + BooleanBuilder whereClause = buildWhereClause(query); + + List letterList = queryFactory.select(Projections.constructor( + PetInitiatedLetterResponse.class, + petInitiatedLetter.id, + petInitiatedLetter.petId, + petInitiatedLetter.createdAt, + petInitiatedLetter.summary, + petInitiatedLetter.content, + petInitiatedLetter.status, + userJpaEntity.email, + userJpaEntity.id, + userJpaEntity.petInitiatedLetterEnabled, + petInitiatedLetter.submitTime + )) + .from(petInitiatedLetter) + .join(userJpaEntity).on(userJpaEntity.id.eq(petInitiatedLetter.userId)) + .where(whereClause) + .orderBy(petInitiatedLetter.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(petInitiatedLetter.count()) + .from(petInitiatedLetter) + .join(userJpaEntity).on(userJpaEntity.id.eq(petInitiatedLetter.userId)) + .where(whereClause) + .fetchOne(); + + return new PageImpl<>(letterList, pageable, total != null ? total : 0L); + } + + private BooleanBuilder buildWhereClause(RetrievePetInitiatedLettersRequest query) { + BooleanBuilder builder = new BooleanBuilder(); + + builder.and(petInitiatedLetter.createdAt.between( + query.searchDate().atStartOfDay(), + query.searchDate().atTime(LocalTime.MAX) + )); + + if (query.status() != null) { + builder.and(petInitiatedLetter.status.eq(query.status())); + } + + if (query.email() != null && !query.email().isBlank()) { + builder.and(userJpaEntity.email.containsIgnoreCase(query.email())); + } + + return builder; + } + + public PetInitiatedLetterForAdminResponse getPetInitiatedLetterDetailForAdmin(Long letterId, Long userId, Long petId) { + PetInitiatedLetterForAdminResponse result = queryFactory.select(Projections.constructor( + PetInitiatedLetterForAdminResponse.class, + Projections.constructor( + UserForAdminResponse.class, + userJpaEntity.id, + userJpaEntity.email, + userJpaEntity.phoneNumber, + userJpaEntity.lastLoggedIn, + userJpaEntity.createdAt, + userJpaEntity.petInitiatedLetterEnabled + ), + Expressions.constant(0L), + Projections.constructor( + PetForAdminResponse.class, + petJpaEntity.id, + petJpaEntity.name, + petJpaEntity.owner, + petJpaEntity.species, + petJpaEntity.personalities, + petJpaEntity.deathAnniversary + ), + Expressions.constant(Collections.emptyList()), + Projections.constructor( + PetInitiatedLetterDetailResponse.class, + petInitiatedLetter.id, + petInitiatedLetter.status, + petInitiatedLetter.submitTime, + petInitiatedLetter.createdAt, + petInitiatedLetter.promptA, + petInitiatedLetter.promptB + ) + )) + .from(petInitiatedLetter) + .join(userJpaEntity).on(petInitiatedLetter.userId.eq(userJpaEntity.id)) + .join(petJpaEntity).on(petInitiatedLetter.petId.eq(petJpaEntity.id)) + .where( + petInitiatedLetter.id.eq(letterId), + userJpaEntity.id.eq(userId), + petJpaEntity.id.eq(petId) + ) + .fetchOne(); + + if (result == null) { + throw new RainbowLetterException( + "해당 선편지 정보를 찾을 수 없습니다.", + "UserId : " + userId + " PetId : " + petId + " PetInitiatedLetterId : " + letterId + ); + } + + return result; + } + + public List getPetInitiatedLetterListByUserId(Long userId) { + return queryFactory.select(Projections.constructor( + PetInitiatedLettersForAdminResponse.class, + petInitiatedLetter.id, + petJpaEntity.name, + petInitiatedLetter.summary, + petInitiatedLetter.status, + petInitiatedLetter.createdAt + )) + .from(petInitiatedLetter) + .join(petJpaEntity).on(petInitiatedLetter.petId.eq(petJpaEntity.id)) + .where( + petInitiatedLetter.userId.eq(userId), + petJpaEntity.userId.eq(userId) + ) + .orderBy(petInitiatedLetter.createdAt.desc()) + .fetch(); + } + + public PetInitiatedLetterWithPetNameResponse getLetterByShareLink(UUID shareLink) { + return queryFactory.select(Projections.constructor( + PetInitiatedLetterWithPetNameResponse.class, + petInitiatedLetter.id, + petInitiatedLetter.createdAt, + petInitiatedLetter.submitTime, + petInitiatedLetter.summary, + petInitiatedLetter.content, + petJpaEntity.id, + petJpaEntity.name, + petJpaEntity.image + )) + .from(petInitiatedLetter) + .join(petJpaEntity).on(petInitiatedLetter.petId.eq(petJpaEntity.id)) + .where( + petInitiatedLetter.shareLink.eq(shareLink) + ) + .fetchOne(); + } + + public PetInitiatedLetterSummary getPetInitiatedLetterDetail(Long userId, Long letterId) { + PetInitiatedLetterSummary result = queryFactory.select(Projections.constructor( + PetInitiatedLetterSummary.class, + petInitiatedLetter.id, + petInitiatedLetter.createdAt, + petInitiatedLetter.content, + petJpaEntity.id, + petJpaEntity.name, + petJpaEntity.image + )) + .from(petInitiatedLetter) + .join(petJpaEntity).on(petInitiatedLetter.petId.eq(petJpaEntity.id)) + .where( + petInitiatedLetter.id.eq(letterId), + petInitiatedLetter.userId.eq(userId) + ) + .fetchOne(); + + if (result == null) { + throw new RainbowLetterException("자신의 선편지만 조회할 수 있습니다."); + } + + return result; + } + + public PetInitiatedLetterStats getPetInitiatedLetterReportByCreatedAtBetween(LocalDateTime letterStartTime, LocalDateTime letterEndTime) { + PetInitiatedLetterStats result = queryFactory + .select( + Projections.constructor( + PetInitiatedLetterStats.class, + petInitiatedLetter.id.countDistinct(), + countByCondition(petInitiatedLetter.status.eq(PetInitiatedLetterStatus.SCHEDULED)), + countByCondition( + petInitiatedLetter.status.eq(PetInitiatedLetterStatus.READY_TO_SEND) + .and(petInitiatedLetter.content.isNotNull()) + ), + countByCondition( + petInitiatedLetter.status.eq(PetInitiatedLetterStatus.SENT) + .and(petInitiatedLetter.submitTime.isNotNull()) + ) + ) + ) + .from(petInitiatedLetter) + .where( + petInitiatedLetter.createdAt.goe(letterStartTime), + petInitiatedLetter.createdAt.lt(letterEndTime) + ) + .fetchOne(); + + return result != null ? result : new PetInitiatedLetterStats(0L, 0L, 0L, 0L); + } + + private NumberExpression countByCondition(BooleanExpression condition) { + return Expressions.cases() + .when(condition).then(1L) + .otherwise(0L) + .sum() + .coalesce(0L); + } +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/UserPetInitiatedLetterJpaRepository.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/UserPetInitiatedLetterJpaRepository.java new file mode 100644 index 0000000..1c59406 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/UserPetInitiatedLetterJpaRepository.java @@ -0,0 +1,10 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.out.persistence; + +import com.rainbowletter.server.petinitiatedletter.application.domain.model.UserPetInitiatedLetter; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserPetInitiatedLetterJpaRepository extends JpaRepository { + boolean existsByUserIdAndPetId(Long userId, Long petId); + void deleteByUserIdAndPetId(Long userId, Long petId); + void deleteByUserId(Long userId); +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/UserPetInitiatedLetterPersistenceAdapter.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/UserPetInitiatedLetterPersistenceAdapter.java new file mode 100644 index 0000000..ba8952e --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/adapter/out/persistence/UserPetInitiatedLetterPersistenceAdapter.java @@ -0,0 +1,51 @@ +package com.rainbowletter.server.petinitiatedletter.adapter.out.persistence; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.rainbowletter.server.common.annotation.PersistenceAdapter; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.UserPetPairDto; +import com.rainbowletter.server.user.adapter.in.web.dto.PetSelectionResponse; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import static com.rainbowletter.server.user.adapter.out.persistence.QUserJpaEntity.userJpaEntity; +import static com.rainbowletter.server.pet.adapter.out.persistence.QPetJpaEntity.petJpaEntity; +import static com.rainbowletter.server.petinitiatedletter.application.domain.model.QUserPetInitiatedLetter.userPetInitiatedLetter; + +@PersistenceAdapter +@RequiredArgsConstructor +public class UserPetInitiatedLetterPersistenceAdapter { + + private final JPAQueryFactory queryFactory; + + public List findByUserId(Long userId) { + return queryFactory.select(Projections.constructor( + PetSelectionResponse.class, + userPetInitiatedLetter.petId, + petJpaEntity.name + )) + .from(userPetInitiatedLetter) + .leftJoin(petJpaEntity).on(userPetInitiatedLetter.petId.eq(petJpaEntity.id)) + .where( + userPetInitiatedLetter.userId.eq(userId) + ) + .orderBy(userPetInitiatedLetter.createdAt.desc()) + .fetch(); + } + + public List findAllUserPetPairs() { + return queryFactory.select(Projections.constructor( + UserPetPairDto.class, + userPetInitiatedLetter.userId, + userPetInitiatedLetter.petId + )) + .from(userPetInitiatedLetter) + .join(userJpaEntity).on(userPetInitiatedLetter.userId.eq(userJpaEntity.id)) + .where( + userJpaEntity.petInitiatedLetterEnabled.eq(true) + ) + .fetch(); + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/GeneratePetInitiatedLetterEvent.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/GeneratePetInitiatedLetterEvent.java new file mode 100644 index 0000000..b06b03b --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/GeneratePetInitiatedLetterEvent.java @@ -0,0 +1,6 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.model; + +import java.util.List; + +public record GeneratePetInitiatedLetterEvent(List letterIds) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/PetInitiatedLetter.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/PetInitiatedLetter.java new file mode 100644 index 0000000..fd90fc6 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/PetInitiatedLetter.java @@ -0,0 +1,101 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.model; + +import com.rainbowletter.server.ai.application.domain.model.AiPrompt.PromptType; +import com.rainbowletter.server.common.adapter.out.persistence.BaseTimeJpaEntity; +import com.rainbowletter.server.common.application.domain.exception.RainbowLetterException; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.GeneratedLetterContent; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table(name = "pet_initiated_letter") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = PROTECTED) +public class PetInitiatedLetter extends BaseTimeJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + private Long userId; + + @NotNull + private Long petId; + + @NotNull + @JdbcTypeCode(SqlTypes.VARCHAR) + private UUID shareLink; + + @Column(length = 20) + private String summary; + + @Column(columnDefinition = "TEXT") + private String content; + + @Column(columnDefinition = "TEXT") + private String promptA; + + @Column(columnDefinition = "TEXT") + private String promptB; + + @Enumerated(EnumType.STRING) + private PromptType promptType; + + private LocalDateTime submitTime; + + @NotNull + private boolean readStatus; + + @NotNull + @Enumerated(EnumType.STRING) + private PetInitiatedLetterStatus status; + + public void update(final PromptType promptType, final String summary, final String content) { + this.promptType = promptType; + this.summary = summary; + this.content = content; + + if (promptType == PromptType.A) { + this.promptA = content; + } else if (promptType == PromptType.B) { + this.promptB = content; + } + } + + public void generate(GeneratedLetterContent generatedLetterContent) { + this.summary = generatedLetterContent.summary(); + this.content = generatedLetterContent.content(); + this.promptA = generatedLetterContent.promptA(); + this.promptB = generatedLetterContent.promptB(); + this.promptType = generatedLetterContent.selectedPrompt(); + this.status = PetInitiatedLetterStatus.READY_TO_SEND; + } + + public void submit(final LocalDateTime submitTime) { + validateSubmit(); + this.status = PetInitiatedLetterStatus.SENT; + this.submitTime = submitTime; + } + + private void validateSubmit() { + if (this.status == PetInitiatedLetterStatus.SENT) { + throw new RainbowLetterException("이미 발송된 편지입니다.", this.getId()); + } + } + + public void markAsFailed() { + this.status = PetInitiatedLetterStatus.SCHEDULED; + } +} \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/PetInitiatedLetterStatus.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/PetInitiatedLetterStatus.java new file mode 100644 index 0000000..e43dcc6 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/PetInitiatedLetterStatus.java @@ -0,0 +1,8 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.model; + +import lombok.Getter; + +@Getter +public enum PetInitiatedLetterStatus { + SCHEDULED, READY_TO_SEND, SENT +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/SubmitPetInitiatedLetterEvent.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/SubmitPetInitiatedLetterEvent.java new file mode 100644 index 0000000..daa27bf --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/SubmitPetInitiatedLetterEvent.java @@ -0,0 +1,4 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.model; + +public record SubmitPetInitiatedLetterEvent(PetInitiatedLetter petInitiatedLetter) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/UserPetInitiatedLetter.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/UserPetInitiatedLetter.java new file mode 100644 index 0000000..badcf39 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/model/UserPetInitiatedLetter.java @@ -0,0 +1,39 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.model; + + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.time.LocalDateTime; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table( + name = "user_pet_initiated_letter", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "pet_id"}) + } +) +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = PROTECTED) +public class UserPetInitiatedLetter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + private Long petId; + + @NotNull + private Long userId; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/DailyPetInitiatedLetterReportScheduler.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/DailyPetInitiatedLetterReportScheduler.java new file mode 100644 index 0000000..d90565d --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/DailyPetInitiatedLetterReportScheduler.java @@ -0,0 +1,41 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.service; + +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterPersistenceAdapter; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.PetInitiatedLetterReportResponse; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.PetInitiatedLetterStats; +import com.rainbowletter.server.slack.application.domain.service.SlackReviewReportService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class DailyPetInitiatedLetterReportScheduler { + + private final PetInitiatedLetterPersistenceAdapter petInitiatedLetterPersistenceAdapter; + private final SlackReviewReportService slackReviewReportService; + + @Scheduled(cron = "0 20 20 * * MON,WED,FRI") + public void sendDailyPetInitiatedLetterReport() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime startDate = now.withHour(19).withMinute(29).withSecond(0); + LocalDateTime endDate = now.withHour(20).withMinute(19).withSecond(59); + + PetInitiatedLetterStats letterStats = + petInitiatedLetterPersistenceAdapter.getPetInitiatedLetterReportByCreatedAtBetween(startDate, endDate); + + PetInitiatedLetterReportResponse report = new PetInitiatedLetterReportResponse( + letterStats.totalLetters(), + letterStats.scheduled(), + letterStats.readyToSend(), + letterStats.sent(), + LocalDate.now() + ); + + slackReviewReportService.sendDailyPetInitiatedLetterReportToSlack(report); + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/GeneratePetInitiatedLetterEventHandler.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/GeneratePetInitiatedLetterEventHandler.java new file mode 100644 index 0000000..436114e --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/GeneratePetInitiatedLetterEventHandler.java @@ -0,0 +1,56 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.service; + +import com.rainbowletter.server.common.application.domain.exception.RainbowLetterException; +import com.rainbowletter.server.pet.application.domain.model.Pet; +import com.rainbowletter.server.pet.application.port.out.LoadPetPort; +import com.rainbowletter.server.petinitiatedletter.adapter.out.infrastructure.PetInitiatedLetterGenerator; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterJpaRepository; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.GeneratePetInitiatedLetterEvent; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetter; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.GeneratedLetterContent; +import com.rainbowletter.server.slack.application.domain.service.SlackErrorReportService; +import com.rainbowletter.server.user.application.domain.model.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GeneratePetInitiatedLetterEventHandler { + + private final PetInitiatedLetterJpaRepository petInitiatedLetterJpaRepository; + private final LoadPetPort loadPetPort; + private final PetInitiatedLetterGenerator petInitiatedLetterGenerator; + private final SlackErrorReportService slackErrorReportService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleGeneratePetInitiatedLetters(GeneratePetInitiatedLetterEvent event) { + for (Long letterId : event.letterIds()) { + PetInitiatedLetter letter = petInitiatedLetterJpaRepository.findById(letterId) + .orElseThrow(() -> new RainbowLetterException("선편지를 찾을 수 없습니다: " + letterId)); + + try { + Pet pet = loadPetPort.loadPetByIdAndUserId( + new Pet.PetId(letter.getPetId()), new User.UserId(letter.getUserId()) + ); + + GeneratedLetterContent generatedLetterContent = petInitiatedLetterGenerator.generate(pet); + letter.generate(generatedLetterContent); + + } catch (Exception e) { + letter.markAsFailed(); + log.error("선편지 AI 생성 실패, letterId={}", letter.getId(), e); + slackErrorReportService.sendGeneratePetLetterErrorReportToSlack(letter.getId(), e); + } + } + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterRecordCreationScheduler.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterRecordCreationScheduler.java new file mode 100644 index 0000000..461bfd9 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterRecordCreationScheduler.java @@ -0,0 +1,59 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.service; + +import com.rainbowletter.server.ai.application.domain.model.AiSetting; +import com.rainbowletter.server.ai.application.port.out.LoadSettingPort; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterJpaRepository; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.UserPetInitiatedLetterPersistenceAdapter; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.GeneratePetInitiatedLetterEvent; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetter; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.UserPetPairDto; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class PetInitiatedLetterRecordCreationScheduler { + + private final UserPetInitiatedLetterPersistenceAdapter userPetInitiatedLetterPersistenceAdapter; + private final PetInitiatedLetterJpaRepository petInitiatedLetterJpaRepository; + private final ApplicationEventPublisher eventPublisher; + private final LoadSettingPort loadSettingPort; + + @Async + @Scheduled(cron = "0 30 19 * * *") + @Transactional + public void createPetInitiatedLetterRecords() { + List userPetPairs = userPetInitiatedLetterPersistenceAdapter.findAllUserPetPairs(); + + AiSetting aiSetting = loadSettingPort.loadPetInitiatedLetterSetting(); + + List letters = userPetPairs.stream() + .map(dto -> PetInitiatedLetter.builder() + .userId(dto.userId()) + .petId(dto.petId()) + .shareLink(UUID.randomUUID()) + .promptType(aiSetting.getSelectPrompt()) + .status(PetInitiatedLetterStatus.SCHEDULED) + .readStatus(false) + .build() + ) + .toList(); + + List letterList = petInitiatedLetterJpaRepository.saveAll(letters); + + eventPublisher.publishEvent( + new GeneratePetInitiatedLetterEvent( + letterList.stream().map(PetInitiatedLetter::getId).toList() + ) + ); + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterRetryScheduler.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterRetryScheduler.java new file mode 100644 index 0000000..8f62024 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterRetryScheduler.java @@ -0,0 +1,84 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.service; + +import com.rainbowletter.server.pet.application.domain.model.Pet; +import com.rainbowletter.server.pet.application.port.out.LoadPetPort; +import com.rainbowletter.server.petinitiatedletter.adapter.out.infrastructure.PetInitiatedLetterGenerator; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterJpaRepository; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetter; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.GeneratedLetterContent; +import com.rainbowletter.server.slack.application.domain.service.SlackErrorReportService; +import com.rainbowletter.server.user.application.domain.model.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus.READY_TO_SEND; +import static com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus.SCHEDULED; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PetInitiatedLetterRetryScheduler { + + private final PetInitiatedLetterJpaRepository petInitiatedLetterJpaRepository; + private final PetInitiatedLetterGenerator petInitiatedLetterGenerator; + private final LoadPetPort loadPetPort; + private final SlackErrorReportService slackErrorReportService; + private final PetInitiatedLetterSubmitter submitter; + + @Scheduled(cron = "0 5 20 * * *") + public void regeneratePetInitiatedLetters() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime start = now.withHour(19).withMinute(29).withSecond(0); + LocalDateTime end = now.withHour(20).withMinute(0).withSecond(0); + + List petInitiatedLetters = + petInitiatedLetterJpaRepository.findAllByStatusAndCreatedAtBetween(SCHEDULED, start, end); + + if (petInitiatedLetters.isEmpty()) { + log.info("AI 생성 재시도할 선편지가 없습니다."); + return; + } + + for (PetInitiatedLetter letter : petInitiatedLetters) { + try { + Pet pet = loadPetPort.loadPetByIdAndUserId( + new Pet.PetId(letter.getPetId()), new User.UserId(letter.getUserId()) + ); + + GeneratedLetterContent content = petInitiatedLetterGenerator.generate(pet); + letter.generate(content); + + } catch (Exception e) { + letter.markAsFailed(); + log.error("선편지 AI 생성 재시도 실패, letterId={}", letter.getId(), e); + slackErrorReportService.sendGeneratePetLetterErrorReportToSlack(letter.getId(), e); + } + + petInitiatedLetterJpaRepository.save(letter); + } + + } + + @Scheduled(cron = "0 10 20 * * *") + public void retrySendPetInitiatedLetters() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime start = now.withHour(19).withMinute(29).withSecond(0); + LocalDateTime end = now.withHour(20).withMinute(0).withSecond(0); + + List petInitiatedLetters = + petInitiatedLetterJpaRepository.findAllByStatusAndCreatedAtBetween(READY_TO_SEND, start, end); + + if (petInitiatedLetters.isEmpty()) { + log.info("발송 재시도할 선편지가 없습니다."); + return; + } + + petInitiatedLetters.forEach(submitter::submit); + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterService.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterService.java new file mode 100644 index 0000000..9401001 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterService.java @@ -0,0 +1,104 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.service; + +import com.rainbowletter.server.common.application.domain.exception.RainbowLetterException; +import com.rainbowletter.server.common.util.TimeHolder; +import com.rainbowletter.server.pet.application.domain.model.Pet; +import com.rainbowletter.server.pet.application.port.out.LoadPetPort; +import com.rainbowletter.server.petinitiatedletter.adapter.in.web.dto.*; +import com.rainbowletter.server.petinitiatedletter.adapter.out.infrastructure.PetInitiatedLetterGenerator; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterJpaRepository; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterPersistenceAdapter; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetter; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.SubmitPetInitiatedLetterEvent; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.GeneratedLetterContent; +import com.rainbowletter.server.user.application.domain.model.User; +import com.rainbowletter.server.user.application.port.out.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PetInitiatedLetterService { + + private final TimeHolder timeHolder; + private final PetInitiatedLetterJpaRepository petInitiatedLetterJpaRepository; + private final PetInitiatedLetterPersistenceAdapter petInitiatedLetterPersistenceAdapter; + private final PetInitiatedLetterGenerator petInitiatedLetterGenerator; + private final LoadPetPort loadPetPort; + private final LoadUserPort loadUserPort; + private final ApplicationEventPublisher eventPublisher; + + @Transactional(readOnly = true) + public Page getPetInitiatedLetters( + RetrievePetInitiatedLettersRequest request, + Pageable pageable + ) { + return petInitiatedLetterPersistenceAdapter.getPetInitiatedLetters(request, pageable); + } + + @Transactional(readOnly = true) + public PetInitiatedLetterForAdminResponse getPetInitiatedLetterDetailForAdmin(Long letterId, Long userId, Long petId) { + PetInitiatedLetterForAdminResponse partialResponse = + petInitiatedLetterPersistenceAdapter.getPetInitiatedLetterDetailForAdmin(letterId, userId, petId); + + List petInitiatedLetterList = + petInitiatedLetterPersistenceAdapter.getPetInitiatedLetterListByUserId(userId); + + return new PetInitiatedLetterForAdminResponse( + partialResponse.userForAdminResponse(), + (long) petInitiatedLetterList.size(), + partialResponse.petForAdminResponse(), + petInitiatedLetterList, + partialResponse.petInitiatedLetterDetailResponse() + ); + } + + @Transactional + public void updatePetInitiatedLetter(Long letterId, PetInitiatedLetterUpdateRequest request) { + PetInitiatedLetter letter = getPetInitiatedLetter(letterId); + + letter.update(request.promptType(), request.summary(), request.content()); + } + + @Transactional + public void regeneratePetInitiatedLetter(Long letterId) { + PetInitiatedLetter letter = getPetInitiatedLetter(letterId); + + Pet pet = loadPetPort.loadPetByIdAndUserId(new Pet.PetId(letter.getPetId()), new User.UserId(letter.getUserId())); + + GeneratedLetterContent generatedLetterContent = petInitiatedLetterGenerator.generate(pet); + + letter.generate(generatedLetterContent); + } + + @Transactional + public void submitPetInitiatedLetter(Long letterId) { + PetInitiatedLetter letter = getPetInitiatedLetter(letterId); + letter.submit(timeHolder.currentTime()); + eventPublisher.publishEvent(new SubmitPetInitiatedLetterEvent(letter)); + + } + + private PetInitiatedLetter getPetInitiatedLetter(Long letterId) { + return petInitiatedLetterJpaRepository.findById(letterId) + .orElseThrow(() -> new RainbowLetterException("해당 선편지를 찾을 수 없습니다.", "선편지 ID : " + letterId)); + } + + @Transactional(readOnly = true) + public PetInitiatedLetterWithPetNameResponse getLetterByShareLink(UUID shareLink) { + return petInitiatedLetterPersistenceAdapter.getLetterByShareLink(shareLink); + } + + @Transactional(readOnly = true) + public PetInitiatedLetterSummary getPetInitiatedLetterDetail(String email, Long letterId) { + User user = loadUserPort.loadUserByEmail(email); + return petInitiatedLetterPersistenceAdapter.getPetInitiatedLetterDetail(user.getId().value(), letterId); + } +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterSubmitScheduler.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterSubmitScheduler.java new file mode 100644 index 0000000..d37890b --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterSubmitScheduler.java @@ -0,0 +1,40 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.service; + +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterJpaRepository; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetterStatus.READY_TO_SEND; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PetInitiatedLetterSubmitScheduler { + + private final PetInitiatedLetterJpaRepository petInitiatedLetterJpaRepository; + private final PetInitiatedLetterSubmitter submitter; + + @Scheduled(cron = "0 0 20 * * *") + public void submitPetInitiatedLetter() { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1).minusNanos(1); + + List petInitiatedLetters = + petInitiatedLetterJpaRepository.findAllByStatusAndCreatedAtBetween(READY_TO_SEND, startOfDay, endOfDay); + + if (petInitiatedLetters.isEmpty()) { + log.info("오늘 발송할 선편지가 없습니다."); + return; + } + + petInitiatedLetters.forEach(submitter::submit); + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterSubmitter.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterSubmitter.java new file mode 100644 index 0000000..1b4ca2b --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/PetInitiatedLetterSubmitter.java @@ -0,0 +1,38 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.service; + +import com.rainbowletter.server.common.util.TimeHolder; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.PetInitiatedLetterJpaRepository; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetter; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.SubmitPetInitiatedLetterEvent; +import com.rainbowletter.server.slack.application.domain.service.SlackErrorReportService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PetInitiatedLetterSubmitter { + + private final PetInitiatedLetterJpaRepository petInitiatedLetterJpaRepository; + private final TimeHolder timeHolder; + private final ApplicationEventPublisher eventPublisher; + private final SlackErrorReportService slackErrorReportService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void submit(PetInitiatedLetter letter) { + try { + letter.submit(timeHolder.currentTime()); + petInitiatedLetterJpaRepository.save(letter); + eventPublisher.publishEvent(new SubmitPetInitiatedLetterEvent(letter)); + } catch (Exception e) { + log.error("선편지 발송 실패, letterId={}", letter.getId(), e); + slackErrorReportService.sendSubmitPetLetterErrorReportToSlack(letter.getId(), e); + throw e; // 현재 편지만 롤백 + } + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/SendNotificationToPetInitiatedLetterEventHandler.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/SendNotificationToPetInitiatedLetterEventHandler.java new file mode 100644 index 0000000..520abe1 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/domain/service/SendNotificationToPetInitiatedLetterEventHandler.java @@ -0,0 +1,121 @@ +package com.rainbowletter.server.petinitiatedletter.application.domain.service; + +import com.rainbowletter.server.common.config.ClientConfig; +import com.rainbowletter.server.notification.application.domain.model.alimtalk.AlimTalkButton; +import com.rainbowletter.server.notification.application.domain.model.mail.MailTemplateCode; +import com.rainbowletter.server.notification.application.port.in.*; +import com.rainbowletter.server.notification.application.port.in.GetAlimTalkTemplateUseCase.GetAlimTalkTemplateQuery; +import com.rainbowletter.server.notification.application.port.in.GetMailTemplateUseCase.GetMailTemplateQuery; +import com.rainbowletter.server.pet.application.port.in.dto.PetSummary; +import com.rainbowletter.server.pet.application.port.out.LoadPetPort; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.PetInitiatedLetter; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.SubmitPetInitiatedLetterEvent; +import com.rainbowletter.server.user.application.domain.model.User; +import com.rainbowletter.server.user.application.port.out.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.regex.Pattern; + +import static com.rainbowletter.server.notification.application.domain.model.alimtalk.AlimTalkTemplateCode.PET_INITIATED_LETTER; + +@Service +@RequiredArgsConstructor +public class SendNotificationToPetInitiatedLetterEventHandler { + private static final Pattern EMOJI_PATTERN = Pattern.compile("[\\p{So}\\p{Cn}]"); + + private final ClientConfig clientConfig; + + private final LoadUserPort loadUserPort; + private final LoadPetPort loadPetPort; + + private final SendMailUseCase sendMailUseCase; + private final GetMailTemplateUseCase getMailTemplateUseCase; + + private final SendAlimTalkUseCase sendAlimTalkUseCase; + private final GetAlimTalkTemplateUseCase getAlimTalkTemplateUseCase; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleSubmitPetInitiatedLetter(SubmitPetInitiatedLetterEvent event) { + PetInitiatedLetter letter = event.petInitiatedLetter(); + User user = loadUserPort.loadUserById(new User.UserId(letter.getUserId())); + PetSummary pet = loadPetPort.findPetSummaryById(letter.getPetId(), letter.getUserId()); + + sendAlimTalk(user, pet, letter); + sendMail(user, pet, letter); + } + + private void sendMail(User user, PetSummary pet, PetInitiatedLetter letter) { + GetMailTemplateQuery titleQuery = GetMailTemplateQuery.titleQuery(MailTemplateCode.PET_INITIATED_LETTER, pet.name()); + String title = getMailTemplateUseCase.getTitle(titleQuery); + + GetMailTemplateQuery contentQuery = GetMailTemplateQuery.contentQuery( + MailTemplateCode.PET_INITIATED_LETTER, + user.getEmail(), + List.of( + "/pre-share/" + letter.getShareLink() + "?utm_source=petinitiatedlettercheck", + pet.name() + ) + ); + String content = getMailTemplateUseCase.getContent(contentQuery); + + SendMailCommand command = new SendMailCommand(user.getEmail(), "SERVER", title, content); + sendMailUseCase.sendMail(command); + } + + private void sendAlimTalk(User user, PetSummary pet, PetInitiatedLetter letter) { + if (!StringUtils.hasText(user.getPhoneNumber())) { + return; + } + + GetAlimTalkTemplateQuery titleQuery = new GetAlimTalkTemplateQuery(PET_INITIATED_LETTER, List.of()); + String title = getAlimTalkTemplateUseCase.getSubject(titleQuery); + + String petName = EMOJI_PATTERN.matcher(pet.name()).replaceAll(""); + + GetAlimTalkTemplateQuery contentQuery = new GetAlimTalkTemplateQuery( + PET_INITIATED_LETTER, + List.of( + petName, + pet.owner(), + petName + ) + ); + String content = getAlimTalkTemplateUseCase.getContent(contentQuery); + + GetAlimTalkTemplateQuery failTitleQuery = new GetAlimTalkTemplateQuery(PET_INITIATED_LETTER, List.of()); + String failTitle = getAlimTalkTemplateUseCase.failSubject(failTitleQuery); + + GetAlimTalkTemplateQuery failContentQuery = new GetAlimTalkTemplateQuery( + PET_INITIATED_LETTER, + List.of(petName, pet.owner(), petName) + ); + String failContent = getAlimTalkTemplateUseCase.failContent(failContentQuery); + + GetAlimTalkTemplateQuery buttonQuery = new GetAlimTalkTemplateQuery( + PET_INITIATED_LETTER, + List.of(clientConfig.getBaseUrl() + "/pre-share/" + letter.getShareLink() + "?utm_source=petinitiatedlettercheck") + ); + List buttons = getAlimTalkTemplateUseCase.getButtons(buttonQuery); + + SendAlimTalkCommand command = new SendAlimTalkCommand( + user.getPhoneNumber(), + "SERVER", + PET_INITIATED_LETTER, + false, + title, + content, + failTitle, + failContent, + buttons + ); + sendAlimTalkUseCase.sendAlimTalk(command); + } + +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/GeneratedLetterContent.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/GeneratedLetterContent.java new file mode 100644 index 0000000..71defd1 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/GeneratedLetterContent.java @@ -0,0 +1,12 @@ +package com.rainbowletter.server.petinitiatedletter.application.port.in.dto; + +import com.rainbowletter.server.ai.application.domain.model.AiPrompt.PromptType; + +public record GeneratedLetterContent( + String summary, + String content, + String promptA, + String promptB, + PromptType selectedPrompt +) { +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/PetInitiatedLetterReportResponse.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/PetInitiatedLetterReportResponse.java new file mode 100644 index 0000000..3863589 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/PetInitiatedLetterReportResponse.java @@ -0,0 +1,28 @@ +package com.rainbowletter.server.petinitiatedletter.application.port.in.dto; + +import java.time.LocalDate; + +public record PetInitiatedLetterReportResponse( + Long totalLetters, + Long scheduled, // 생성예정 + Long readyToSend, // 발송대기 + Long sent, // 발송완료 + LocalDate date // 보고 날짜 +) { + public String scheduledPercentage() { + return calculatePercentage(scheduled); + } + + public String readyToSendPercentage() { + return calculatePercentage(readyToSend); + } + + public String sentPercentage() { + return calculatePercentage(sent); + } + + private String calculatePercentage(Long value) { + if (totalLetters == 0L) return "0.0%"; + return String.format("%.2f%%", value * 100.0 / totalLetters); + } +} diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/PetInitiatedLetterStats.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/PetInitiatedLetterStats.java new file mode 100644 index 0000000..bb3412b --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/PetInitiatedLetterStats.java @@ -0,0 +1,9 @@ +package com.rainbowletter.server.petinitiatedletter.application.port.in.dto; + +public record PetInitiatedLetterStats( + Long totalLetters, + Long scheduled, + Long readyToSend, + Long sent +) { +} \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/UserPetPairDto.java b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/UserPetPairDto.java new file mode 100644 index 0000000..8120def --- /dev/null +++ b/src/main/java/com/rainbowletter/server/petinitiatedletter/application/port/in/dto/UserPetPairDto.java @@ -0,0 +1,4 @@ +package com.rainbowletter.server.petinitiatedletter.application.port.in.dto; + +public record UserPetPairDto(Long userId, Long petId) { +} diff --git a/src/main/java/com/rainbowletter/server/reply/adapter/out/infrastructure/ReplyGenerator.java b/src/main/java/com/rainbowletter/server/reply/adapter/out/infrastructure/ReplyGenerator.java index bb2fd69..2102707 100644 --- a/src/main/java/com/rainbowletter/server/reply/adapter/out/infrastructure/ReplyGenerator.java +++ b/src/main/java/com/rainbowletter/server/reply/adapter/out/infrastructure/ReplyGenerator.java @@ -15,7 +15,6 @@ import com.rainbowletter.server.reply.application.port.out.GenerateReplyCommand; import com.rainbowletter.server.reply.application.port.out.GenerateReplyPort; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @@ -25,7 +24,6 @@ @Component @RequiredArgsConstructor -@Slf4j class ReplyGenerator implements GenerateReplyPort { private final LoadLetterPort loadLetterPort; @@ -83,9 +81,6 @@ public Reply generate(final GenerateReplyCommand command) { } private String getResponseContent(final AiPrompt aiPrompt, final GenerateReplyCommand command) { - String dynamicSystem = buildDynamicSystemPrompt(aiPrompt.getSystem(), command.getPet()); - AiPrompt modifiedPrompt = aiPrompt.withSystem(dynamicSystem); - List parameterInstances = List.of( command.getPet(), command.getLetter(), @@ -99,11 +94,7 @@ private String getResponseContent(final AiPrompt aiPrompt, final GenerateReplyCo command.getLetter().getCreatedAt() ); - System.out.println(recentLetters); - - final AiClientCommand aiClientCommand = new AiClientCommand(modifiedPrompt, parameterInstances, recentLetters); - - log.info(aiClientCommand.toString()); + final AiClientCommand aiClientCommand = new AiClientCommand(aiPrompt, parameterInstances, recentLetters); return callAiClientPort.call(aiClientCommand) .getResult() @@ -111,17 +102,4 @@ private String getResponseContent(final AiPrompt aiPrompt, final GenerateReplyCo .getContent(); } - private String buildDynamicSystemPrompt(String baseSystem, Pet pet) { - List personalities = pet.getPersonalities(); - - if (personalities == null || personalities.isEmpty()) { - return baseSystem; - } - - String joined = String.join(", ", personalities); - return baseSystem + "\n\n" + - pet.getName() + "은 " + joined + " 성격을 가지고 있어. 이런 성격이 드러나게끔 반영하여 답장을 써줘."; - } - - } diff --git a/src/main/java/com/rainbowletter/server/reply/application/domain/service/ReadReplyService.java b/src/main/java/com/rainbowletter/server/reply/application/domain/service/ReadReplyService.java index 9b2eca5..4dc4f67 100644 --- a/src/main/java/com/rainbowletter/server/reply/application/domain/service/ReadReplyService.java +++ b/src/main/java/com/rainbowletter/server/reply/application/domain/service/ReadReplyService.java @@ -1,6 +1,8 @@ package com.rainbowletter.server.reply.application.domain.service; import com.rainbowletter.server.common.annotation.UseCase; +import com.rainbowletter.server.letter.application.domain.model.Letter; +import com.rainbowletter.server.letter.application.port.out.LoadLetterPort; import com.rainbowletter.server.reply.application.domain.model.Reply; import com.rainbowletter.server.reply.application.domain.model.Reply.ReplyId; import com.rainbowletter.server.reply.application.port.in.ReadReplyUseCase; @@ -15,12 +17,15 @@ class ReadReplyService implements ReadReplyUseCase { private final LoadReplyPort loadReplyPort; + private final LoadLetterPort loadLetterPort; private final SaveReplyPort saveReplyPort; @Override public void readReply(final ReplyId replyId) { final Reply reply = loadReplyPort.loadReplyById(replyId); + final Letter letter = loadLetterPort.loadLetterById(reply.getLetterId()); reply.read(); + letter.read(); saveReplyPort.save(reply); } diff --git a/src/main/java/com/rainbowletter/server/slack/application/domain/service/LetterReportService.java b/src/main/java/com/rainbowletter/server/slack/application/domain/service/LetterReportService.java index ffb7541..f2eeb0d 100644 --- a/src/main/java/com/rainbowletter/server/slack/application/domain/service/LetterReportService.java +++ b/src/main/java/com/rainbowletter/server/slack/application/domain/service/LetterReportService.java @@ -15,7 +15,7 @@ public class LetterReportService { private final LoadLetterPort loadLetterPort; private final SlackReviewReportService slackReviewReportService; - public LetterReportResponse report(LocalDateTime startDate, LocalDateTime endDate) { + public void report(LocalDateTime startDate, LocalDateTime endDate) { LocalDateTime now = LocalDateTime.now(); LocalDateTime letterStartTime = startDate != null ? startDate : now.minusDays(1).withHour(10).withMinute(0).withSecond(0); LocalDateTime letterEndTime = endDate != null ? endDate : now.withHour(9).withMinute(59).withSecond(59); @@ -31,7 +31,6 @@ public LetterReportResponse report(LocalDateTime startDate, LocalDateTime endDat letterEndTime ); - slackReviewReportService.sendReportToSlack(report); - return report; + slackReviewReportService.sendDailyLetterReportToSlack(report); } } \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackErrorReportService.java b/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackErrorReportService.java index 3bf85c9..3b57017 100644 --- a/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackErrorReportService.java +++ b/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackErrorReportService.java @@ -15,9 +15,7 @@ public class SlackErrorReportService { private final SlackErrorClient slackErrorClient; private final SlackMessageFormatter slackMessageFormatter; - public void sendErrorReportToSlack(String filePath, Throwable exception) { - String message = slackMessageFormatter.formatImageUploadErrorReport(filePath, exception); - + private void sendErrorReportToSlack(String message) { try { slackErrorClient.sendSlackMessage(Map.of("text", message)); log.info("슬랙 에러 메시지 전송 성공"); @@ -26,4 +24,22 @@ public void sendErrorReportToSlack(String filePath, Throwable exception) { } } + public void sendImageUploadErrorReportToSlack(String filePath, Throwable exception) { + String message = slackMessageFormatter.formatImageUploadErrorReport(filePath, exception); + + sendErrorReportToSlack(message); + } + + public void sendGeneratePetLetterErrorReportToSlack(Long letterId, Throwable exception) { + String message = slackMessageFormatter.formatGeneratePetLetterErrorReport(letterId, exception); + + sendErrorReportToSlack(message); + } + + public void sendSubmitPetLetterErrorReportToSlack(Long letterId, Throwable exception) { + String message = slackMessageFormatter.formatSubmitPetLetterErrorReport(letterId, exception); + + sendErrorReportToSlack(message); + } + } \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackMessageFormatter.java b/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackMessageFormatter.java index 1c2874c..cc742eb 100644 --- a/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackMessageFormatter.java +++ b/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackMessageFormatter.java @@ -1,5 +1,6 @@ package com.rainbowletter.server.slack.application.domain.service; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.PetInitiatedLetterReportResponse; import com.rainbowletter.server.slack.application.port.in.dto.LetterReportResponse; import org.springframework.stereotype.Component; @@ -28,6 +29,23 @@ public String formatDailyLetterReport(LetterReportResponse report) { ); } + public String formatDailyPetInitiatedLetterReport(PetInitiatedLetterReportResponse report) { + return String.format(""" + 🔊 *Daily Pet-Initiated-Letter Report* + 1. 날짜: %s + 2. 총 선편지 개수: %d + 3. 생성예정: %d (%s) + 4. 발송대기: %d (%s) + 5. 발송완료: %d (%s) + """, + report.date(), + report.totalLetters(), + report.scheduled(), report.scheduledPercentage(), + report.readyToSend(), report.readyToSendPercentage(), + report.sent(), report.sentPercentage() + ); + } + public String formatImageUploadErrorReport(String filePath, Throwable exception) { String reason = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 에러"; @@ -38,4 +56,24 @@ public String formatImageUploadErrorReport(String filePath, Throwable exception) ); } + public String formatGeneratePetLetterErrorReport(Long letterId, Throwable exception) { + String reason = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 에러"; + + return String.join("\n", + "❌ *선편지 AI 생성 실패*", + "- 선편지 ID : `" + letterId + "`", + "- 사유: " + reason + ); + } + + public String formatSubmitPetLetterErrorReport(Long letterId, Throwable exception) { + String reason = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 에러"; + + return String.join("\n", + "❌ *선편지 발송 실패*", + "- 선편지 ID : `" + letterId + "`", + "- 사유: " + reason + ); + } + } \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackReviewReportService.java b/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackReviewReportService.java index bd5c265..0daf12a 100644 --- a/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackReviewReportService.java +++ b/src/main/java/com/rainbowletter/server/slack/application/domain/service/SlackReviewReportService.java @@ -1,10 +1,11 @@ package com.rainbowletter.server.slack.application.domain.service; +import com.rainbowletter.server.petinitiatedletter.application.port.in.dto.PetInitiatedLetterReportResponse; import com.rainbowletter.server.slack.adapter.out.SlackReviewClient; import com.rainbowletter.server.slack.application.port.in.dto.LetterReportResponse; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; import java.util.Map; @@ -16,15 +17,22 @@ public class SlackReviewReportService { private final SlackMessageFormatter slackMessageFormatter; private final SlackReviewClient slackReviewClient; - public void sendReportToSlack(LetterReportResponse report) { - String message = slackMessageFormatter.formatDailyLetterReport(report); - Map payload = Map.of("text", message); - + public void sendReportToSlack(String message) { try { - slackReviewClient.sendSlackMessage(payload); + slackReviewClient.sendSlackMessage(Map.of("text", message)); log.info("Slack 메시지 전송 성공"); } catch (Exception e) { log.error("Slack 메시지 전송 실패: {}", e.getMessage(), e); } } + + public void sendDailyLetterReportToSlack(LetterReportResponse report) { + String message = slackMessageFormatter.formatDailyLetterReport(report); + sendReportToSlack(message); + } + + public void sendDailyPetInitiatedLetterReportToSlack(PetInitiatedLetterReportResponse report) { + String message = slackMessageFormatter.formatDailyPetInitiatedLetterReport(report); + sendReportToSlack(message); + } } \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/user/adapter/in/web/UserPetLetterSettingController.java b/src/main/java/com/rainbowletter/server/user/adapter/in/web/UserPetLetterSettingController.java new file mode 100644 index 0000000..04f218d --- /dev/null +++ b/src/main/java/com/rainbowletter/server/user/adapter/in/web/UserPetLetterSettingController.java @@ -0,0 +1,59 @@ +package com.rainbowletter.server.user.adapter.in.web; + +import com.rainbowletter.server.common.annotation.WebAdapter; +import com.rainbowletter.server.common.util.SecurityUtils; +import com.rainbowletter.server.user.adapter.in.web.dto.PetSelectionRequest; +import com.rainbowletter.server.user.adapter.in.web.dto.PetSelectionResponse; +import com.rainbowletter.server.user.adapter.in.web.dto.UserPetLetterSettingRequest; +import com.rainbowletter.server.user.application.domain.service.UserPetLetterSettingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@WebAdapter +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/users/pet-initiated-letters") +@Tag(name = "user", description = "회원") +public class UserPetLetterSettingController { + + private final UserPetLetterSettingService userPetLetterSettingService; + + @Operation(summary = "선편지 ON/OFF 설정") + @PutMapping("/enabled") + public void updatePetLetterEnabled(@Valid @RequestBody UserPetLetterSettingRequest request) { + userPetLetterSettingService.updatePetLetterEnabled(SecurityUtils.getEmail(), request); + } + + @Operation(summary = "선편지 펫 등록") + @PostMapping("/pets") + public ResponseEntity> registerInitiatedLetterPet( + @Valid @RequestBody PetSelectionRequest request + ) { + List response = + userPetLetterSettingService.registerInitiatedLetterPet(SecurityUtils.getEmail(), request); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation(summary = "선편지 펫 삭제") + @DeleteMapping("/pets") + public ResponseEntity> removeInitiatedLetterPet(@Valid @RequestBody PetSelectionRequest request) { + List response = + userPetLetterSettingService.removeInitiatedLetterPet(SecurityUtils.getEmail(), request); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation(summary = "선편지 펫 리스트 조회") + @GetMapping("/pets") + public ResponseEntity> getInitiatedLetterPets() { + List response = userPetLetterSettingService.getInitiatedLetterPets(SecurityUtils.getEmail()); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + +} diff --git a/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/PetSelectionRequest.java b/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/PetSelectionRequest.java new file mode 100644 index 0000000..aa8ccee --- /dev/null +++ b/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/PetSelectionRequest.java @@ -0,0 +1,9 @@ +package com.rainbowletter.server.user.adapter.in.web.dto; + +import groovyjarjarantlr4.v4.runtime.misc.NotNull; + +public record PetSelectionRequest( + @NotNull + Long petId +) { +} diff --git a/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/PetSelectionResponse.java b/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/PetSelectionResponse.java new file mode 100644 index 0000000..efa6389 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/PetSelectionResponse.java @@ -0,0 +1,7 @@ +package com.rainbowletter.server.user.adapter.in.web.dto; + +public record PetSelectionResponse( + Long petId, + String petName +) { +} \ No newline at end of file diff --git a/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/UserPetLetterSettingRequest.java b/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/UserPetLetterSettingRequest.java new file mode 100644 index 0000000..5ddb4d1 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/user/adapter/in/web/dto/UserPetLetterSettingRequest.java @@ -0,0 +1,9 @@ +package com.rainbowletter.server.user.adapter.in.web.dto; + +import jakarta.validation.constraints.NotNull; + +public record UserPetLetterSettingRequest( + @NotNull + boolean enabled +) { +} diff --git a/src/main/java/com/rainbowletter/server/user/adapter/out/infrastructure/OAuthLoginHandlerImpl.java b/src/main/java/com/rainbowletter/server/user/adapter/out/infrastructure/OAuthLoginHandlerImpl.java index f333335..8bd5eb1 100644 --- a/src/main/java/com/rainbowletter/server/user/adapter/out/infrastructure/OAuthLoginHandlerImpl.java +++ b/src/main/java/com/rainbowletter/server/user/adapter/out/infrastructure/OAuthLoginHandlerImpl.java @@ -76,7 +76,8 @@ private User registerUser( timeHolder.currentTime(), timeHolder.currentTime(), timeHolder.currentTime(), - timeHolder.currentTime() + timeHolder.currentTime(), + false ); return registerUserPort.registerUser(user); } diff --git a/src/main/java/com/rainbowletter/server/user/adapter/out/persistence/UserJpaEntity.java b/src/main/java/com/rainbowletter/server/user/adapter/out/persistence/UserJpaEntity.java index ff51497..d60836e 100644 --- a/src/main/java/com/rainbowletter/server/user/adapter/out/persistence/UserJpaEntity.java +++ b/src/main/java/com/rainbowletter/server/user/adapter/out/persistence/UserJpaEntity.java @@ -62,6 +62,9 @@ class UserJpaEntity extends BaseTimeJpaEntity { @NotNull private LocalDateTime lastChangedPassword; + @NotNull + private boolean petInitiatedLetterEnabled = false; + @Override public boolean equals(final Object o) { if (this == o) { diff --git a/src/main/java/com/rainbowletter/server/user/adapter/out/persistence/UserMapper.java b/src/main/java/com/rainbowletter/server/user/adapter/out/persistence/UserMapper.java index 13e02f8..724e60e 100644 --- a/src/main/java/com/rainbowletter/server/user/adapter/out/persistence/UserMapper.java +++ b/src/main/java/com/rainbowletter/server/user/adapter/out/persistence/UserMapper.java @@ -21,7 +21,8 @@ User mapToDomain(final UserJpaEntity jpaEntity) { jpaEntity.getLastLoggedIn(), jpaEntity.getLastChangedPassword(), jpaEntity.getCreatedAt(), - jpaEntity.getUpdatedAt() + jpaEntity.getUpdatedAt(), + jpaEntity.isPetInitiatedLetterEnabled() ); } @@ -36,7 +37,8 @@ UserJpaEntity mapToJpaEntity(final User domain) { domain.getProvider(), domain.getProviderId(), domain.getLastLoggedIn(), - domain.getLastChangedPassword() + domain.getLastChangedPassword(), + domain.isPetInitiatedLetterEnabled() ); } diff --git a/src/main/java/com/rainbowletter/server/user/application/domain/model/User.java b/src/main/java/com/rainbowletter/server/user/application/domain/model/User.java index 284a583..d51b916 100644 --- a/src/main/java/com/rainbowletter/server/user/application/domain/model/User.java +++ b/src/main/java/com/rainbowletter/server/user/application/domain/model/User.java @@ -23,6 +23,7 @@ public class User extends AggregateRoot { private LocalDateTime lastChangedPassword; private LocalDateTime createdAt; private LocalDateTime updatedAt; + private boolean petInitiatedLetterEnabled; @SuppressWarnings("java:S107") public static User withId( @@ -37,7 +38,8 @@ public static User withId( final LocalDateTime lastLoggedIn, final LocalDateTime lastChangedPassword, final LocalDateTime createdAt, - final LocalDateTime updatedAt + final LocalDateTime updatedAt, + final boolean petInitiatedLetterEnabled ) { return new User( id, @@ -51,7 +53,8 @@ public static User withId( lastLoggedIn, lastChangedPassword, createdAt, - updatedAt + updatedAt, + petInitiatedLetterEnabled ); } @@ -67,7 +70,8 @@ public static User withoutId( final LocalDateTime lastLoggedIn, final LocalDateTime lastChangedPassword, final LocalDateTime createdAt, - final LocalDateTime updatedAt + final LocalDateTime updatedAt, + final boolean petInitiatedLetterEnabled ) { return new User( null, @@ -81,7 +85,8 @@ public static User withoutId( lastLoggedIn, lastChangedPassword, createdAt, - updatedAt + updatedAt, + petInitiatedLetterEnabled ); } @@ -116,6 +121,10 @@ public void updatePhoneNumber(final String phoneNumber) { this.phoneNumber = phoneNumber; } + public void updatePetInitiatedLetterEnabled(final boolean enabled) { + this.petInitiatedLetterEnabled = enabled; + } + public void leave() { this.status = UserStatus.LEAVE; } diff --git a/src/main/java/com/rainbowletter/server/user/application/domain/service/UserPetLetterSettingService.java b/src/main/java/com/rainbowletter/server/user/application/domain/service/UserPetLetterSettingService.java new file mode 100644 index 0000000..1d45848 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/user/application/domain/service/UserPetLetterSettingService.java @@ -0,0 +1,128 @@ +package com.rainbowletter.server.user.application.domain.service; + +import com.rainbowletter.server.common.application.domain.exception.RainbowLetterException; +import com.rainbowletter.server.pet.application.domain.model.Pet; +import com.rainbowletter.server.pet.application.port.out.LoadPetPort; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.UserPetInitiatedLetterJpaRepository; +import com.rainbowletter.server.petinitiatedletter.adapter.out.persistence.UserPetInitiatedLetterPersistenceAdapter; +import com.rainbowletter.server.petinitiatedletter.application.domain.model.UserPetInitiatedLetter; +import com.rainbowletter.server.user.adapter.in.web.dto.PetSelectionRequest; +import com.rainbowletter.server.user.adapter.in.web.dto.PetSelectionResponse; +import com.rainbowletter.server.user.adapter.in.web.dto.UserPetLetterSettingRequest; +import com.rainbowletter.server.user.application.domain.model.User; +import com.rainbowletter.server.user.application.port.out.LoadUserPort; +import com.rainbowletter.server.user.application.port.out.UpdateUserStatePort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserPetLetterSettingService { + + private final LoadUserPort loadUserPort; + private final LoadPetPort loadPetPort; + private final UpdateUserStatePort updateUserStatePort; + private final UserPetInitiatedLetterJpaRepository userPetInitiatedLetterJpaRepository; + private final UserPetInitiatedLetterPersistenceAdapter userPetInitiatedLetterPersistenceAdapter; + + @Transactional + public void updatePetLetterEnabled(String email, UserPetLetterSettingRequest request) { + validateNotInRestrictedTime(email, LocalDateTime.now()); + final User user = loadUserPort.loadUserByEmail(email); + user.updatePetInitiatedLetterEnabled(request.enabled()); + if (!request.enabled()) { + userPetInitiatedLetterJpaRepository.deleteByUserId(user.getId().value()); + } + updateUserStatePort.updateUser(user); + } + + @Transactional + public List registerInitiatedLetterPet(String email, PetSelectionRequest request) { + final LocalDateTime now = LocalDateTime.now(); + validateNotInRestrictedTime(email, now); + + User user = loadUserPort.loadUserByEmail(email); + validateUserPetInitiatedLetterEnabled(user); + + UserPetIds ids = getUserPetIds(user, request.petId()); + + if (userPetInitiatedLetterJpaRepository.existsByUserIdAndPetId(ids.userId(), ids.petId())) { + throw new RainbowLetterException("이미 등록된 아이입니다.", formatDetail(email, ids.petId())); + } + + UserPetInitiatedLetter entity = UserPetInitiatedLetter.builder() + .userId(ids.userId()) + .petId(ids.petId()) + .createdAt(LocalDateTime.now()) + .build(); + userPetInitiatedLetterJpaRepository.save(entity); + + return userPetInitiatedLetterPersistenceAdapter.findByUserId(ids.userId()); + } + + @Transactional + public List removeInitiatedLetterPet(String email, PetSelectionRequest request) { + validateNotInRestrictedTime(email, LocalDateTime.now()); + + User user = loadUserPort.loadUserByEmail(email); + validateUserPetInitiatedLetterEnabled(user); + + UserPetIds ids = getUserPetIds(user, request.petId()); + + if (!userPetInitiatedLetterJpaRepository.existsByUserIdAndPetId(ids.userId(), ids.petId())) { + throw new RainbowLetterException("등록되지 않은 펫입니다.", formatDetail(email, ids.petId())); + } + + userPetInitiatedLetterJpaRepository.deleteByUserIdAndPetId(ids.userId(), ids.petId()); + + return userPetInitiatedLetterPersistenceAdapter.findByUserId(ids.userId()); + } + + @Transactional(readOnly = true) + public List getInitiatedLetterPets(String email) { + User user = loadUserPort.loadUserByEmail(email); + validateUserPetInitiatedLetterEnabled(user); + + return userPetInitiatedLetterPersistenceAdapter.findByUserId(user.getId().value()); + } + + private UserPetIds getUserPetIds(User user, Long petId) { + Pet pet = loadPetPort.loadPetByIdAndUserId(new Pet.PetId(petId), user.getId()); + return new UserPetIds(user.getId().value(), pet.getId().value()); + } + + private void validateUserPetInitiatedLetterEnabled(User user) { + if (!user.isPetInitiatedLetterEnabled()) { + throw new RainbowLetterException("아이에게 먼저 편지 받기 기능이 켜져 있지 않습니다.", user.getEmail()); + } + } + + private void validateNotInRestrictedTime(String email, LocalDateTime now) { + if (isBlockedTime(now)) { + throw new RainbowLetterException("현재 시간에는 선편지 설정 변경이 불가능합니다. (월/수/금 19:30~20:00 제한)", email); + } + } + + private boolean isBlockedTime(LocalDateTime now) { + DayOfWeek day = now.getDayOfWeek(); + int hour = now.getHour(); + int minute = now.getMinute(); + + boolean isBlockedDay = day == DayOfWeek.MONDAY || day == DayOfWeek.WEDNESDAY || day == DayOfWeek.FRIDAY; + boolean isBlockedTime = (hour == 19 && minute >= 30) || (hour == 20 && minute == 0); + + return isBlockedDay && isBlockedTime; + } + + private String formatDetail(String email, Long petId) { + return String.format("Email: %s, PetId: %d", email, petId); + } + + private record UserPetIds(Long userId, Long petId) { + } +} diff --git a/src/main/java/com/rainbowletter/server/user/application/domain/service/UserRegisterService.java b/src/main/java/com/rainbowletter/server/user/application/domain/service/UserRegisterService.java index 1ccd426..7209492 100644 --- a/src/main/java/com/rainbowletter/server/user/application/domain/service/UserRegisterService.java +++ b/src/main/java/com/rainbowletter/server/user/application/domain/service/UserRegisterService.java @@ -42,7 +42,8 @@ public Long registerUser(final UserRegisterCommand command) { command.getLastLoggedIn(), timeHolder.currentTime(), command.getCreatedAt(), - command.getUpdatedAt() + command.getUpdatedAt(), + false ); return registerUserPort.registerUser(user) .getId() diff --git a/src/main/java/com/rainbowletter/server/user/application/port/in/dto/UserForAdminResponse.java b/src/main/java/com/rainbowletter/server/user/application/port/in/dto/UserForAdminResponse.java new file mode 100644 index 0000000..94ebbe9 --- /dev/null +++ b/src/main/java/com/rainbowletter/server/user/application/port/in/dto/UserForAdminResponse.java @@ -0,0 +1,13 @@ +package com.rainbowletter.server.user.application.port.in.dto; + +import java.time.LocalDateTime; + +public record UserForAdminResponse( + Long id, + String email, + String phoneNumber, + LocalDateTime lastLoggedIn, + LocalDateTime createdAt, + boolean isPetInitiatedLetterEnabled +) { +} diff --git a/src/main/java/com/rainbowletter/server/user/application/port/in/dto/UserInformationResponse.java b/src/main/java/com/rainbowletter/server/user/application/port/in/dto/UserInformationResponse.java index 2941b4b..95cde1f 100644 --- a/src/main/java/com/rainbowletter/server/user/application/port/in/dto/UserInformationResponse.java +++ b/src/main/java/com/rainbowletter/server/user/application/port/in/dto/UserInformationResponse.java @@ -3,6 +3,7 @@ import com.rainbowletter.server.user.application.domain.model.OAuthProvider; import com.rainbowletter.server.user.application.domain.model.User; import com.rainbowletter.server.user.application.domain.model.User.UserRole; + import java.time.LocalDateTime; public record UserInformationResponse( @@ -13,7 +14,8 @@ public record UserInformationResponse( OAuthProvider provider, LocalDateTime lastLoggedIn, LocalDateTime lastChangedPassword, - LocalDateTime createdAt + LocalDateTime createdAt, + boolean isPetInitiatedLetterEnabled ) { public static UserInformationResponse from(final User user) { @@ -25,7 +27,8 @@ public static UserInformationResponse from(final User user) { user.getProvider(), user.getLastLoggedIn(), user.getLastChangedPassword(), - user.getCreatedAt() + user.getCreatedAt(), + user.isPetInitiatedLetterEnabled() ); } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 59e24a1..a06f3fe 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -39,5 +39,7 @@ receive.reply.email.title=[무지개 편지] {0}에게 편지가 도착했어요 receive.reply.template.title=무지개 마을로 부터 답장이 도착했어요! receive.reply.template.content=지금 바로 편지를 보러갈까요? ''답장 보러가기'' 버튼을 클릭해주세요! receive.reply.template.content.button=답장 보러가기 -receive.reply.template.survey=무지개편지, 잘 이용하고 계신가요? 서비스 만족도 평가를 남겨주시면 더욱 나아지는 모습을 보일게요. receive.reply.template.survey.button=만족도 조사 바로가기 +receive.pet.initiated.letter.template.title=무지개 마을에서 편지가 도착했어요! +receive.pet.initiated.letter.template.content=지금 바로 편지를 보러갈까요? ''편지 보러가기'' 버튼을 클릭해 주세요! +receive.pet.initiated.letter.template.content.button=편지 보러가기 diff --git a/src/main/resources/i18n/messages_en.properties b/src/main/resources/i18n/messages_en.properties index 3a61b48..ca64254 100644 --- a/src/main/resources/i18n/messages_en.properties +++ b/src/main/resources/i18n/messages_en.properties @@ -40,5 +40,7 @@ receive.reply.email.title=[Rainbow Letter] A letter has arrived from {0} receive.reply.template.title=A reply has arrived from Rainbow Village! receive.reply.template.content=Shall we go check the letter now? receive.reply.template.content.button=Go to Reply -receive.reply.template.survey=If you leave a service satisfaction review, we''ll strive to improve even more! receive.reply.template.survey.button=Go to survey +receive.pet.initiated.letter.template.title=A letter has arrived from Rainbow Village! +receive.pet.initiated.letter.template.content=Shall we go check the letter now? +receive.pet.initiated.letter.template.content.button=Go to letter \ No newline at end of file diff --git a/src/main/resources/i18n/messages_ko.properties b/src/main/resources/i18n/messages_ko.properties index 59e24a1..a06f3fe 100644 --- a/src/main/resources/i18n/messages_ko.properties +++ b/src/main/resources/i18n/messages_ko.properties @@ -39,5 +39,7 @@ receive.reply.email.title=[무지개 편지] {0}에게 편지가 도착했어요 receive.reply.template.title=무지개 마을로 부터 답장이 도착했어요! receive.reply.template.content=지금 바로 편지를 보러갈까요? ''답장 보러가기'' 버튼을 클릭해주세요! receive.reply.template.content.button=답장 보러가기 -receive.reply.template.survey=무지개편지, 잘 이용하고 계신가요? 서비스 만족도 평가를 남겨주시면 더욱 나아지는 모습을 보일게요. receive.reply.template.survey.button=만족도 조사 바로가기 +receive.pet.initiated.letter.template.title=무지개 마을에서 편지가 도착했어요! +receive.pet.initiated.letter.template.content=지금 바로 편지를 보러갈까요? ''편지 보러가기'' 버튼을 클릭해 주세요! +receive.pet.initiated.letter.template.content.button=편지 보러가기 diff --git a/src/main/resources/templates/receive-pet-initiated-letter.html b/src/main/resources/templates/receive-pet-initiated-letter.html new file mode 100644 index 0000000..683a562 --- /dev/null +++ b/src/main/resources/templates/receive-pet-initiated-letter.html @@ -0,0 +1,52 @@ + + + + + + + + +
+ logo +
+

+ 무지개 마을에서 편지가 도착했어요! +

+

+ 지금 바로 편지를 보러 갈까요? '편지 보러가기' 버튼을 클릭해주세요! +

+
+ +
+

+ 무지개편지, 잘 이용하고 계신가요?
+ 서비스 만족도 평가를 남겨주시면 더욱 나아지는 모습을 보일게요. +

+
+ +
+ + diff --git a/src/main/resources/templates/receive-reply.html b/src/main/resources/templates/receive-reply.html index db3056e..d9e2692 100644 --- a/src/main/resources/templates/receive-reply.html +++ b/src/main/resources/templates/receive-reply.html @@ -32,8 +32,7 @@

-

+

무지개편지, 잘 이용하고 계신가요?
서비스 만족도 평가를 남겨주시면 더욱 나아지는 모습을 보일게요.

diff --git a/src/test/java/com/rainbowletter/server/medium/e2e/PetE2ETest.java b/src/test/java/com/rainbowletter/server/medium/e2e/PetE2ETest.java index 373d89f..803df64 100644 --- a/src/test/java/com/rainbowletter/server/medium/e2e/PetE2ETest.java +++ b/src/test/java/com/rainbowletter/server/medium/e2e/PetE2ETest.java @@ -1,20 +1,5 @@ package com.rainbowletter.server.medium.e2e; -import static com.rainbowletter.server.common.util.Constants.AUTHORIZATION_HEADER_KEY; -import static com.rainbowletter.server.common.util.Constants.AUTHORIZATION_HEADER_TYPE; -import static com.rainbowletter.server.medium.RestDocsUtils.getFilter; -import static com.rainbowletter.server.medium.RestDocsUtils.getSpecification; -import static com.rainbowletter.server.medium.snippet.CommonRequestSnippet.AUTHORIZATION_HEADER; -import static com.rainbowletter.server.medium.snippet.PetRequestSnippet.PET_CREATE_REQUEST; -import static com.rainbowletter.server.medium.snippet.PetRequestSnippet.PET_PATH_VARIABLE_ID; -import static com.rainbowletter.server.medium.snippet.PetRequestSnippet.PET_UPDATE_REQUEST; -import static com.rainbowletter.server.medium.snippet.PetResponseSnippet.PET_CREATE_RESPONSE_HEADER; -import static com.rainbowletter.server.medium.snippet.PetResponseSnippet.PET_DASHBOARD_RESPONSES; -import static com.rainbowletter.server.medium.snippet.PetResponseSnippet.PET_RESPONSE; -import static com.rainbowletter.server.medium.snippet.PetResponseSnippet.PET_RESPONSES; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.groups.Tuple.tuple; - import com.rainbowletter.server.medium.TestHelper; import com.rainbowletter.server.pet.adapter.in.web.dto.CreatePetRequest; import com.rainbowletter.server.pet.adapter.in.web.dto.UpdatePetRequest; @@ -24,13 +9,24 @@ import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; + import java.io.IOException; import java.time.LocalDate; import java.util.HashSet; import java.util.List; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; + +import static com.rainbowletter.server.common.util.Constants.AUTHORIZATION_HEADER_KEY; +import static com.rainbowletter.server.common.util.Constants.AUTHORIZATION_HEADER_TYPE; +import static com.rainbowletter.server.medium.RestDocsUtils.getFilter; +import static com.rainbowletter.server.medium.RestDocsUtils.getSpecification; +import static com.rainbowletter.server.medium.snippet.CommonRequestSnippet.AUTHORIZATION_HEADER; +import static com.rainbowletter.server.medium.snippet.PetRequestSnippet.*; +import static com.rainbowletter.server.medium.snippet.PetResponseSnippet.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; @Sql({"classpath:sql/user.sql", "classpath:sql/pet.sql"}) class PetE2ETest extends TestHelper { @@ -55,13 +51,6 @@ void Should_PetResponses_When_Authenticated() { "objectKey"), tuple(2L, 1L, "미키", "강아지", "엄마", List.of(), deathAnniversary, null) ); - assertThat(result.pets()) - .extracting("favorite") - .extracting("id", "total", "dayIncreaseCount", "canIncrease") - .contains( - tuple(1L, 0, 0, true), - tuple(2L, 0, 0, true) - ); } private ExtractableResponse findAllByEmail(final String token) { @@ -93,10 +82,6 @@ void Should_PetResponse_When_Authenticated() { assertThat(result.personalities()).contains("활발한", "잘삐짐"); assertThat(result.deathAnniversary()).isEqualTo(deathAnniversary); assertThat(result.image()).isEqualTo("objectKey"); - assertThat(result.favorite().id()).isEqualTo(1L); - assertThat(result.favorite().total()).isZero(); - assertThat(result.favorite().dayIncreaseCount()).isZero(); - assertThat(result.favorite().canIncrease()).isTrue(); } private ExtractableResponse findByIdAndEmail(final String token) { @@ -166,28 +151,6 @@ private ExtractableResponse create( .then().log().all().extract(); } - @Test - void Should_PetIncreaseFavorite_When_ValidRequest() { - // given - final String token = userAccessToken; - - // when - final ExtractableResponse response = increaseFavorite(token); - - // then - assertThat(response.statusCode()).isEqualTo(200); - } - - private ExtractableResponse increaseFavorite(final String token) { - return RestAssured - .given(getSpecification()).log().all() - .header(AUTHORIZATION_HEADER_KEY, AUTHORIZATION_HEADER_TYPE + " " + token) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .filter(getFilter().document(AUTHORIZATION_HEADER, PET_PATH_VARIABLE_ID)) - .when().post("/api/pets/favorite/{id}", 1) - .then().log().all().extract(); - } - @Test void Should_UpdatePet_When_ValidRequest() throws IOException { // given diff --git a/src/test/java/com/rainbowletter/server/medium/snippet/PetResponseSnippet.java b/src/test/java/com/rainbowletter/server/medium/snippet/PetResponseSnippet.java index dd1dcc2..c076f91 100644 --- a/src/test/java/com/rainbowletter/server/medium/snippet/PetResponseSnippet.java +++ b/src/test/java/com/rainbowletter/server/medium/snippet/PetResponseSnippet.java @@ -1,14 +1,14 @@ package com.rainbowletter.server.medium.snippet; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.restdocs.snippet.Snippet; + import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import org.springframework.http.HttpHeaders; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.restdocs.snippet.Snippet; - public class PetResponseSnippet { public static final Snippet PET_RESPONSES = responseFields( @@ -38,21 +38,6 @@ public class PetResponseSnippet { .type(JsonFieldType.STRING) .description("이미지의 objectKey") .optional(), - fieldWithPath("pets[].favorite.id") - .type(JsonFieldType.NUMBER) - .description("좋아요 ID"), - fieldWithPath("pets[].favorite.total") - .type(JsonFieldType.NUMBER) - .description("총 좋아요 수"), - fieldWithPath("pets[].favorite.dayIncreaseCount") - .type(JsonFieldType.NUMBER) - .description("하루동안 증가된 좋아요 수"), - fieldWithPath("pets[].favorite.canIncrease") - .type(JsonFieldType.BOOLEAN) - .description("오늘 하루 좋아요 가능 여부"), - fieldWithPath("pets[].favorite.lastIncreasedAt") - .type(JsonFieldType.STRING) - .description("마지막 좋아요 증가일"), fieldWithPath("pets[].createdAt") .type(JsonFieldType.STRING) .description("생성일"), @@ -88,21 +73,6 @@ public class PetResponseSnippet { .type(JsonFieldType.STRING) .description("이미지의 objectKey") .optional(), - fieldWithPath("favorite.id") - .type(JsonFieldType.NUMBER) - .description("좋아요 ID"), - fieldWithPath("favorite.total") - .type(JsonFieldType.NUMBER) - .description("총 좋아요 수"), - fieldWithPath("favorite.dayIncreaseCount") - .type(JsonFieldType.NUMBER) - .description("하루동안 증가된 좋아요 수"), - fieldWithPath("favorite.canIncrease") - .type(JsonFieldType.BOOLEAN) - .description("오늘 하루 좋아요 가능 여부"), - fieldWithPath("favorite.lastIncreasedAt") - .type(JsonFieldType.STRING) - .description("마지막 좋아요 증가일"), fieldWithPath("createdAt") .type(JsonFieldType.STRING) .description("생성일"), @@ -121,9 +91,6 @@ public class PetResponseSnippet { fieldWithPath("pets[].letterCount") .type(JsonFieldType.NUMBER) .description("보낸 편지 수"), - fieldWithPath("pets[].favoriteCount") - .type(JsonFieldType.NUMBER) - .description("총 좋아요 수"), fieldWithPath("pets[].image") .type(JsonFieldType.STRING) .description("이미지의 objectKey") diff --git a/src/test/resources/sql/pet.sql b/src/test/resources/sql/pet.sql index 8efa6a5..72e9560 100644 --- a/src/test/resources/sql/pet.sql +++ b/src/test/resources/sql/pet.sql @@ -1,15 +1,9 @@ SET FOREIGN_KEY_CHECKS = 0; -INSERT INTO favorite(id, total, day_increase_count, can_increase, last_increased_at) -VALUES (1, 0, 0, true, '2024-01-01 12:00:00.000000'), - (2, 0, 0, true, '2024-01-01 12:00:00.000000'); - -INSERT INTO pet(id, user_id, favorite_id, name, species, owner, personalities, - death_anniversary, - image, created_at, updated_at) -VALUES (1, 1, 1, '콩이', '고양이', '형아', '활발한,잘삐짐', '2023-01-01', +INSERT INTO pet(id, user_id, name, species, owner, personalities,death_anniversary,image, created_at, updated_at) +VALUES (1, 1, '콩이', '고양이', '형아', '활발한,잘삐짐', '2023-01-01', 'objectKey', '2024-01-01 12:00:00.000000', '2024-01-01 12:00:00.000000'), - (2, 1, 2, '미키', '강아지', '엄마', '', '2023-01-01', + (2, 1, '미키', '강아지', '엄마', '', '2023-01-01', null, '2024-01-01 12:00:00.000000', '2024-01-01 12:00:00.000000'); SET FOREIGN_KEY_CHECKS = 1;