From 1d0f578e3312084ec3a278e4ad2f7ff4be1baa9c Mon Sep 17 00:00:00 2001 From: minkyung Date: Thu, 21 May 2026 20:04:55 +0900 Subject: [PATCH 01/23] =?UTF-8?q?feat:=20migration=20v11=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/db/migration/V11__user_add_language_code.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V11__user_add_language_code.sql diff --git a/src/main/resources/db/migration/V11__user_add_language_code.sql b/src/main/resources/db/migration/V11__user_add_language_code.sql new file mode 100644 index 0000000..e32c42a --- /dev/null +++ b/src/main/resources/db/migration/V11__user_add_language_code.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS language_code VARCHAR(10) NOT NULL DEFAULT 'KO'; From 13d7dbf2a538b04601d8313f4c88a3bca5f0f877 Mon Sep 17 00:00:00 2001 From: minkyung Date: Thu, 21 May 2026 20:32:43 +0900 Subject: [PATCH 02/23] =?UTF-8?q?feat:=20user=EC=97=90=20update=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/AuthenticatedUserResolver.java | 2 +- .../domain/auth/service/impl/AuthServiceImpl.java | 2 +- .../java/com/gachi/be/domain/user/entity/User.java | 13 +++++++++++++ .../domain/user/entity/{ => enums}/UserStatus.java | 2 +- .../be/domain/user/repository/UserRepository.java | 2 +- .../be/global/security/JwtAuthenticationFilter.java | 2 +- .../controller/AuthRateLimitIntegrationTest.java | 2 +- .../controller/ChildControllerIntegrationTest.java | 2 +- 8 files changed, 20 insertions(+), 7 deletions(-) rename src/main/java/com/gachi/be/domain/user/entity/{ => enums}/UserStatus.java (51%) diff --git a/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java b/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java index cea4fe0..c44f98a 100644 --- a/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java +++ b/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java @@ -1,7 +1,7 @@ package com.gachi.be.domain.auth.service; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.exception.BusinessException; diff --git a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java index 5fb22d5..9055cb5 100644 --- a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java @@ -22,7 +22,7 @@ import com.gachi.be.domain.auth.service.TokenHashService; import com.gachi.be.domain.auth.service.password.PasswordStrengthEvaluator; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.exception.AppException; diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 5614a34..00285fb 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -1,5 +1,6 @@ package com.gachi.be.domain.user.entity; +import com.gachi.be.domain.user.entity.enums.UserStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -47,6 +48,9 @@ public class User { @Column(nullable = false, length = 20) private UserStatus status; + @Column(name = "language_code", nullable = false, length = 10) + private String languageCode; + @Column(name = "deleted_at") private OffsetDateTime deletedAt; @@ -79,6 +83,7 @@ public User( String name, String phoneNumber, UserStatus status, + String languageCode, OffsetDateTime emailVerifiedAt, OffsetDateTime consentAgreedAt, String consentVersion, @@ -90,6 +95,7 @@ public User( this.name = name; this.phoneNumber = phoneNumber; this.status = status; + this.languageCode = languageCode != null ? languageCode : "KO"; this.emailVerifiedAt = emailVerifiedAt; this.consentAgreedAt = consentAgreedAt; this.consentVersion = consentVersion; @@ -102,6 +108,10 @@ public boolean isActive() { return status == UserStatus.ACTIVE; } + public void updateLanguage(String languageCode) { + this.languageCode = languageCode; + } + @PrePersist protected void onCreate() { LocalDateTime now = LocalDateTime.now(); @@ -112,6 +122,9 @@ protected void onCreate() { if (status == null) { status = UserStatus.ACTIVE; } + if (languageCode == null) { + languageCode = "KO"; + } } @PreUpdate diff --git a/src/main/java/com/gachi/be/domain/user/entity/UserStatus.java b/src/main/java/com/gachi/be/domain/user/entity/enums/UserStatus.java similarity index 51% rename from src/main/java/com/gachi/be/domain/user/entity/UserStatus.java rename to src/main/java/com/gachi/be/domain/user/entity/enums/UserStatus.java index ab36c8b..5d753c1 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/UserStatus.java +++ b/src/main/java/com/gachi/be/domain/user/entity/enums/UserStatus.java @@ -1,4 +1,4 @@ -package com.gachi.be.domain.user.entity; +package com.gachi.be.domain.user.entity.enums; public enum UserStatus { ACTIVE, diff --git a/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java b/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java index 1b5eb7b..29de4b6 100644 --- a/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java +++ b/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java @@ -1,7 +1,7 @@ package com.gachi.be.domain.user.repository; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/gachi/be/global/security/JwtAuthenticationFilter.java b/src/main/java/com/gachi/be/global/security/JwtAuthenticationFilter.java index c5a777e..68374c6 100644 --- a/src/main/java/com/gachi/be/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/gachi/be/global/security/JwtAuthenticationFilter.java @@ -2,7 +2,7 @@ import com.gachi.be.domain.auth.service.JwtTokenProvider; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.exception.BusinessException; diff --git a/src/test/java/com/gachi/be/domain/auth/api/controller/AuthRateLimitIntegrationTest.java b/src/test/java/com/gachi/be/domain/auth/api/controller/AuthRateLimitIntegrationTest.java index 2b43b6c..b90e613 100644 --- a/src/test/java/com/gachi/be/domain/auth/api/controller/AuthRateLimitIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/auth/api/controller/AuthRateLimitIntegrationTest.java @@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import java.time.OffsetDateTime; import java.util.Map; diff --git a/src/test/java/com/gachi/be/domain/child/api/controller/ChildControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/child/api/controller/ChildControllerIntegrationTest.java index 748e0fc..0b67ed0 100644 --- a/src/test/java/com/gachi/be/domain/child/api/controller/ChildControllerIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/child/api/controller/ChildControllerIntegrationTest.java @@ -10,7 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.gachi.be.domain.auth.service.JwtTokenProvider; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import java.time.OffsetDateTime; import java.util.Map; From cdc7f61591ce08ae6f1bc18682cdec9855540c1e Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:34:43 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat:=20repository=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/NewsletterRepository.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java index c99c0cc..87be618 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java +++ b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java @@ -4,6 +4,8 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; + +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -74,4 +76,20 @@ List findRecentByUserId( @Param("userId") Long userId, @Param("rangeStart") OffsetDateTime rangeStart, @Param("rangeEnd") OffsetDateTime rangeEnd); + + + /** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리*/ + @Modifying + @Query( + """ + UPDATE Newsletter n + SET n.status = :failedStatus + WHERE n.userId = :userId + AND n.status IN :targetStatuses + """) + int cancelInProgressByUserId( + @Param("userId") Long userId, + @Param("targetStatuses") List targetStatuses, + @Param("failedStatus") NewsletterStatus failedStatus); + } From 24aabaeb7c82abe472a79ceeeeba30fc83628a8c Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:36:15 +0900 Subject: [PATCH 04/23] =?UTF-8?q?feat:=20request=20dto=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/request/ChangeLanguageRequest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java new file mode 100644 index 0000000..fa83e1a --- /dev/null +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java @@ -0,0 +1,12 @@ +package com.gachi.be.domain.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/**언어 설정 변경 요청 DTO.*/ +public record ChangeLanguageRequest( + @NotBlank(message = "언어 코드는 필수입니다.") + @Pattern( + regexp = "^(KO|US|ZH|VI)$", + message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + String languageCode) {} From 0b486e9d100f209047f15d43ebf8c5a6b198463a Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:40:01 +0900 Subject: [PATCH 05/23] =?UTF-8?q?feat:=20controller=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20successcode=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/api/controller/UserController.java | 49 +++++++++++++++++-- .../com/gachi/be/global/code/SuccessCode.java | 3 +- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 42b2a6a..b4b5d93 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -1,22 +1,31 @@ package com.gachi.be.domain.user.api.controller; import com.gachi.be.domain.auth.service.AuthenticatedUserResolver; +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; +import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import com.gachi.be.domain.user.dto.request.ChangeLanguageRequest; import com.gachi.be.domain.user.dto.response.UserMeResponse; import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.api.ApiResponse; import com.gachi.be.global.code.SuccessCode; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; /** 로그인 사용자 기준 내 정보 조회 API를 제공한다. */ +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/users") public class UserController { private final AuthenticatedUserResolver authenticatedUserResolver; + private final UserRepository userRepository; + private final NewsletterRepository newsletterRepository; @GetMapping("/me") public ApiResponse getMyInfo( @@ -31,4 +40,36 @@ public ApiResponse getMyInfo( user.getName(), user.getPhoneNumber())); } + + /** 언어 설정 변경 API */ + @PatchMapping("/me/language") + @Transactional + public ApiResponse changeLanguage( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @RequestBody @Valid ChangeLanguageRequest request) { + + User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); + + String previousLanguage = user.getLanguageCode(); + String newLanguage = request.languageCode(); + + user.updateLanguage(newLanguage); + userRepository.save(user); + + // 진행 중인 파이프라인 FAILED 처리 + int cancelledCount = + newsletterRepository.cancelInProgressByUserId( + user.getId(), + List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), + NewsletterStatus.FAILED); + + log.info( + "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", + user.getId(), + previousLanguage, + newLanguage, + cancelledCount); + + return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); + } } diff --git a/src/main/java/com/gachi/be/global/code/SuccessCode.java b/src/main/java/com/gachi/be/global/code/SuccessCode.java index bf72634..9ed744a 100644 --- a/src/main/java/com/gachi/be/global/code/SuccessCode.java +++ b/src/main/java/com/gachi/be/global/code/SuccessCode.java @@ -36,7 +36,8 @@ public enum SuccessCode { CALENDAR_DAILY_SUCCESS(HttpStatus.OK, "CAL2005", "날짜별 캘린더 조회에 성공하였습니다."), CHECKLIST_TODAY_SUCCESS(HttpStatus.OK, "CL2001", "오늘 마감 체크리스트 조회에 성공하였습니다."), CHECKLIST_COMPLETE_SUCCESS(HttpStatus.OK, "CL2002", "체크리스트 완료 처리에 성공하였습니다."), - CHECKLIST_DELETED(HttpStatus.OK, "CL2003", "체크리스트 삭제에 성공하였습니다."); + CHECKLIST_DELETED(HttpStatus.OK, "CL2003", "체크리스트 삭제에 성공하였습니다."), + USER_LANGUAGE_UPDATED(HttpStatus.OK, "USER2001", "언어 설정이 변경되었습니다."); private final HttpStatus httpStatus; private final String code; From 9273a8d687e02fe238b7bb522517a59dadb03811 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:41:07 +0900 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20errorcode=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/gachi/be/global/code/ErrorCode.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/gachi/be/global/code/ErrorCode.java b/src/main/java/com/gachi/be/global/code/ErrorCode.java index 7c384bb..4c36b17 100644 --- a/src/main/java/com/gachi/be/global/code/ErrorCode.java +++ b/src/main/java/com/gachi/be/global/code/ErrorCode.java @@ -216,6 +216,13 @@ public enum ErrorCode { "Redis에 newsletter:preview:{id} 키 없음 또는 TTL 만료", ErrorLogLevel.WARN), + USER_LANGUAGE_CODE_INVALID( + HttpStatus.BAD_REQUEST, + "USER4001", + "지원하지 않는 언어 코드입니다. (KO, US, ZH, VI 중 하나여야 합니다.)", + "언어 코드 유효성 검사 실패", + ErrorLogLevel.WARN), + EXTERNAL_API_ERROR( HttpStatus.BAD_GATEWAY, "EXT5021", From 410b5a866de1cfbd7c950fb91e51429eaa276160 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:43:06 +0900 Subject: [PATCH 07/23] =?UTF-8?q?feat:=20auth=20dto=EC=97=90=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=96=B8=EC=96=B4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gachi/be/domain/auth/dto/request/SignupRequest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index f66f39c..2a2ac8a 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -17,4 +17,8 @@ public record SignupRequest( @NotBlank @Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE) String phoneNumber, - @NotNull Boolean consentAgreed) {} + @NotNull Boolean consentAgreed, + @Pattern( + regexp = "^(KO|US|ZH|VI)$", + message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + String languageCode) {} From 91260a80ef03ddb682df67f4da838c83e3a7a420 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:45:11 +0900 Subject: [PATCH 08/23] =?UTF-8?q?feat:=20auth=20service=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=97=90=20=EC=9E=90=EB=85=80=20=EC=83=89=EC=83=81?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java index 9055cb5..21c9b9b 100644 --- a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java @@ -138,6 +138,7 @@ public SignupResponse signup(SignupRequest request) { .name(name) .phoneNumber(phoneNumber) .status(UserStatus.ACTIVE) + .languageCode(request.languageCode()) .emailVerifiedAt(now) .consentAgreedAt(now) .consentVersion(authProperties.getConsentVersion()) From 5c3cb36f5302641e1392e07aabe52d46fc66fd71 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 09:12:49 +0900 Subject: [PATCH 09/23] =?UTF-8?q?feat:=20service=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80-=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=96=B8?= =?UTF-8?q?=EC=96=B4=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EC=95=84=EC=99=80=EC=84=9C=20=EB=B2=88=EC=97=AD=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newsletter/service/NewsletterService.java | 3 +-- .../service/impl/NewsletterServiceImpl.java | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java index 7da5cac..dc16910 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java @@ -11,11 +11,10 @@ public interface NewsletterService { * @param userId 현재 로그인한 사용자 ID * @param file 업로드할 파일 (jpg/png/pdf, 최대 10MB) * @param childId 연결할 자녀 ID (미선택 시 null) - * @param userLanguage 사용자 언어 코드 (KO/US/ZH/VI) * @return newsletterId + status(PENDING) */ NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId, String userLanguage); + Long userId, MultipartFile file, Long childId); /** * 가정통신문의 현재 분석 상태와 진행률을 조회. diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java index b773376..d7fb697 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java @@ -17,6 +17,8 @@ import com.gachi.be.domain.newsletter.pipeline.NewsletterPipelineService; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import com.gachi.be.domain.newsletter.service.NewsletterService; +import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.file.service.S3FileService; import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.exception.BusinessException; @@ -60,20 +62,21 @@ public class NewsletterServiceImpl implements NewsletterService { private final CalendarEventRepository calendarEventRepository; private final ChecklistRepository checklistRepository; private final NewsletterPipelineService newsletterPipelineService; + private final UserRepository userRepository; private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final int PAGE_SIZE = 20; /** * 가정통신문 파일을 S3에 업로드하고 newsletter 레코드를 PENDING 상태로 생성한다. * - *

처리 순서: 파일 유효성 검사 (형식: jpg/png/pdf, 크기: 최대 10MB) SHA-256 해시 계산 (중복 방지용) 중복 파일 확인 S3 업로드 → + * 처리 순서: 파일 유효성 검사 (형식: jpg/png/pdf, 크기: 최대 10MB) SHA-256 해시 계산 (중복 방지용) 중복 파일 확인 S3 업로드 → * file_key 획득 childId가 있으면 children 테이블에서 자녀 정보 조회 (스냅샷용) newsletter 레코드 DB 저장 (status=PENDING 으로 * 변경) AI 분석 파이프라인 비동기 트리거 -> Asyncㅏ로 별도 스레드에서 실행하게 함. */ @Override @Transactional public NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId, String userLanguage) { + Long userId, MultipartFile file, Long childId) { // 파일 유효성 검사 validateFile(file); @@ -110,6 +113,10 @@ public NewsletterUploadResponse upload( // 자녀 미선택 시: (user_id + file_hash) 조합으로 확인 checkDuplicate(userId, childName, fileHash); + // 프론트 언어처리로만 이루어지지 않고 설정한 언어를 자동으로 사용하게 변경 + String userLanguage = resolveUserLanguage(userId); + log.debug("[Newsletter] 사용자 언어 설정 조회 완료. userId={}, language={}", userId, userLanguage); + // S3 업로드 - 가정통신문 전용 경로에 저장 + 디버깅 로그 추가해서 체크 String fileKey = s3FileService.uploadNewsletter(file).key(); log.debug("[Newsletter] S3 업로드 완료. userId={}, fileKey={}", userId, fileKey); @@ -118,6 +125,20 @@ public NewsletterUploadResponse upload( userId, childName, childGrade, childColor, fileKey, fileHash, userLanguage); } + // userId로 users 테이블에서 language_code를 조회하는 내부 메서드. + // 사용자를 찾지 못하면 기본값 'KO'를 반환한다 (방어 코드). + private String resolveUserLanguage(Long userId) { + return userRepository + .findById(userId) + .map(User::getLanguageCode) + .filter(lang -> lang != null && !lang.isBlank()) + .orElseGet( + () -> { + log.warn("[Newsletter] 사용자 언어 조회 실패. userId={}. 기본값 KO 사용.", userId); + return "KO"; + }); + } + @Transactional protected NewsletterUploadResponse saveAndTriggerPipeline( Long userId, @@ -451,7 +472,7 @@ private Set getRegisteredNewsletterIds(Long userId, List newsl /** * 파일 유효성 검사. * - *

TODO: 허용방식은 일단 이렇게만 지정해두고 테스트 해보면서 추가할 지 고려. 허용 형식: image/jpeg, image/png, application/pdf + * TODO: 허용방식은 일단 이렇게만 지정해두고 테스트 해보면서 추가할 지 고려. 허용 형식: image/jpeg, image/png, application/pdf * 최대 크기: 10MB */ private void validateFile(MultipartFile file) { From bc93db655815c6d001c872e013635e32b588ca66 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 09:13:32 +0900 Subject: [PATCH 10/23] =?UTF-8?q?feat:=20controller=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/newsletter/api/controller/NewsletterController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java index 893d179..a8cc5de 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java +++ b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java @@ -71,7 +71,7 @@ public ApiResponse upload( @Pattern(regexp = "KO|US|ZH|VI", message = "language는 KO/US/ZH/VI 중 하나여야 합니다.") String language) { - NewsletterUploadResponse response = newsletterService.upload(userId, file, childId, language); + NewsletterUploadResponse response = newsletterService.upload(userId, file, childId); return ApiResponse.success(SuccessCode.NEWSLETTER_UPLOAD_SUCCESS, response); } From 85ab4ae03faed9ef51b4d29e54314352ffe1519a Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 09:36:33 +0900 Subject: [PATCH 11/23] =?UTF-8?q?feat:=20controller=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95-parameter=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=EC=97=90=20=EB=B0=9B=EC=95=84=EC=98=A4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newsletter/api/controller/NewsletterController.java | 6 +----- ...dd_language_code.sql => V12__user_add_language_code.sql} | 0 2 files changed, 1 insertion(+), 5 deletions(-) rename src/main/resources/db/migration/{V11__user_add_language_code.sql => V12__user_add_language_code.sql} (100%) diff --git a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java index a8cc5de..a712fda 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java +++ b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java @@ -65,11 +65,7 @@ public ApiResponse upload( schema = @Schema(type = "string", format = "binary"))) @RequestPart("file") MultipartFile file, - @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) Long childId, - @Parameter(description = "언어 코드 (KO/US/ZH/VI). 기본값 KO") - @RequestParam(defaultValue = "KO") - @Pattern(regexp = "KO|US|ZH|VI", message = "language는 KO/US/ZH/VI 중 하나여야 합니다.") - String language) { + @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) Long childId) { NewsletterUploadResponse response = newsletterService.upload(userId, file, childId); return ApiResponse.success(SuccessCode.NEWSLETTER_UPLOAD_SUCCESS, response); diff --git a/src/main/resources/db/migration/V11__user_add_language_code.sql b/src/main/resources/db/migration/V12__user_add_language_code.sql similarity index 100% rename from src/main/resources/db/migration/V11__user_add_language_code.sql rename to src/main/resources/db/migration/V12__user_add_language_code.sql From 19d9f115f29ae14b3ff4c68a29d49389c55df09d Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 09:43:17 +0900 Subject: [PATCH 12/23] =?UTF-8?q?feat:=20description=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/domain/user/api/controller/UserController.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index b4b5d93..631e8fc 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -9,6 +9,7 @@ import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.api.ApiResponse; import com.gachi.be.global.code.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -42,6 +43,12 @@ public ApiResponse getMyInfo( } /** 언어 설정 변경 API */ + @Operation( + summary = "사용자 언어 설정 변경", + description = + """ + 마이페이지에서 내가 회원가입 시에 설정했떤 언어를 변경할 수 있습니다. 해당 언어를 변경한 뒤에 스캔된 문서들은 전부 해당 언어로 번역됩니다. + """) @PatchMapping("/me/language") @Transactional public ApiResponse changeLanguage( From 00f0644817ff849d4d9058c2ea9108839bd9a790 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 13:44:13 +0900 Subject: [PATCH 13/23] =?UTF-8?q?feat:=20migration=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V3__user_add_notification_enabled.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V3__user_add_notification_enabled.sql diff --git a/src/main/resources/db/migration/V3__user_add_notification_enabled.sql b/src/main/resources/db/migration/V3__user_add_notification_enabled.sql new file mode 100644 index 0000000..a2405b6 --- /dev/null +++ b/src/main/resources/db/migration/V3__user_add_notification_enabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS notification_enabled BOOLEAN NOT NULL DEFAULT TRUE; From 306a0ac1f4e355547bd510a1af4ddb7049c22070 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 13:54:11 +0900 Subject: [PATCH 14/23] =?UTF-8?q?feat:=20user=20entity=EC=97=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/gachi/be/domain/user/entity/User.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 00285fb..6ba951c 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -51,6 +51,9 @@ public class User { @Column(name = "language_code", nullable = false, length = 10) private String languageCode; + @Column(name = "notification_enabled", nullable = false) + private boolean notificationEnabled; + @Column(name = "deleted_at") private OffsetDateTime deletedAt; @@ -84,6 +87,7 @@ public User( String phoneNumber, UserStatus status, String languageCode, + Boolean notificationEnabled, OffsetDateTime emailVerifiedAt, OffsetDateTime consentAgreedAt, String consentVersion, @@ -96,6 +100,7 @@ public User( this.phoneNumber = phoneNumber; this.status = status; this.languageCode = languageCode != null ? languageCode : "KO"; + this.notificationEnabled = notificationEnabled != null ? notificationEnabled : true; this.emailVerifiedAt = emailVerifiedAt; this.consentAgreedAt = consentAgreedAt; this.consentVersion = consentVersion; @@ -112,6 +117,10 @@ public void updateLanguage(String languageCode) { this.languageCode = languageCode; } + public void updateNotificationEnabled(boolean notificationEnabled) { + this.notificationEnabled = notificationEnabled; + } + @PrePersist protected void onCreate() { LocalDateTime now = LocalDateTime.now(); From 678ca2fd133a0a81280183f9d79a2f4311ef4b40 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 14:39:28 +0900 Subject: [PATCH 15/23] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD=20dto=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/request/ChangeNotificationRequest.java | 9 +++++++++ .../be/domain/user/dto/response/UserMeResponse.java | 10 +++++++++- .../java/com/gachi/be/domain/user/entity/User.java | 6 +++--- 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java new file mode 100644 index 0000000..d032959 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java @@ -0,0 +1,9 @@ +package com.gachi.be.domain.user.dto.request; + +import jakarta.validation.constraints.NotNull; + +/**알림 설정 변경 요청 DTO.*/ +public record ChangeNotificationRequest( + @NotNull(message = "notificationEnabled는 필수입니다.") + boolean notificationEnabled +) {} diff --git a/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java b/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java index 49bdfab..245acb0 100644 --- a/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java +++ b/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java @@ -1,5 +1,13 @@ package com.gachi.be.domain.user.dto.response; +import java.time.LocalDateTime; + /** 내 정보 조회 응답 DTO. */ public record UserMeResponse( - Long userId, String loginId, String email, String name, String phoneNumber) {} + Long userId, + String loginId, + String email, + String name, + String languageCode, + Boolean notificationEnabled, + LocalDateTime createdAt) {} diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 6ba951c..d77fc72 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -117,9 +117,9 @@ public void updateLanguage(String languageCode) { this.languageCode = languageCode; } - public void updateNotificationEnabled(boolean notificationEnabled) { - this.notificationEnabled = notificationEnabled; - } + public void updateNotificationEnabled(boolean notificationEnabled) { + this.notificationEnabled = notificationEnabled; + } @PrePersist protected void onCreate() { From 8be2ce84aadbb5e6168db917b4252c4eed2796c3 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 14:40:59 +0900 Subject: [PATCH 16/23] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20controller=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gachi/be/domain/user/api/controller/UserController.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 631e8fc..8be27f6 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -39,7 +39,9 @@ public ApiResponse getMyInfo( user.getLoginId(), user.getEmail(), user.getName(), - user.getPhoneNumber())); + user.getLanguageCode(), + user.isNotificationEnabled(), + user.getCreatedAt())); } /** 언어 설정 변경 API */ From 3d0ebd33901a30a7dcecd2f4cc799d9120a5d460 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 14:43:43 +0900 Subject: [PATCH 17/23] =?UTF-8?q?feat:=20migration=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80-=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V13__user_add_notification_enabled.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V13__user_add_notification_enabled.sql diff --git a/src/main/resources/db/migration/V13__user_add_notification_enabled.sql b/src/main/resources/db/migration/V13__user_add_notification_enabled.sql new file mode 100644 index 0000000..a2405b6 --- /dev/null +++ b/src/main/resources/db/migration/V13__user_add_notification_enabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS notification_enabled BOOLEAN NOT NULL DEFAULT TRUE; From cb243b3bc7d2dea92c3afceb1affe1d3af472bb9 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 14:54:39 +0900 Subject: [PATCH 18/23] =?UTF-8?q?feat:=20spotlessapply=20=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=B0=8F=20description=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/request/SignupRequest.java | 6 +- .../api/controller/NewsletterController.java | 3 +- .../repository/NewsletterRepository.java | 21 +++--- .../newsletter/service/NewsletterService.java | 3 +- .../service/impl/NewsletterServiceImpl.java | 25 ++++--- .../user/api/controller/UserController.java | 69 ++++++++++--------- .../dto/request/ChangeLanguageRequest.java | 10 +-- .../request/ChangeNotificationRequest.java | 6 +- .../com/gachi/be/domain/user/entity/User.java | 10 +-- .../V3__user_add_notification_enabled.sql | 2 - 10 files changed, 76 insertions(+), 79 deletions(-) delete mode 100644 src/main/resources/db/migration/V3__user_add_notification_enabled.sql diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index 2a2ac8a..0c4dc52 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -18,7 +18,5 @@ public record SignupRequest( @Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE) String phoneNumber, @NotNull Boolean consentAgreed, - @Pattern( - regexp = "^(KO|US|ZH|VI)$", - message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") - String languageCode) {} + @Pattern(regexp = "^(KO|US|ZH|VI)$", message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + String languageCode) {} diff --git a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java index a712fda..7d3f98b 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java +++ b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java @@ -65,7 +65,8 @@ public ApiResponse upload( schema = @Schema(type = "string", format = "binary"))) @RequestPart("file") MultipartFile file, - @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) Long childId) { + @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) + Long childId) { NewsletterUploadResponse response = newsletterService.upload(userId, file, childId); return ApiResponse.success(SuccessCode.NEWSLETTER_UPLOAD_SUCCESS, response); diff --git a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java index 65106f9..04ea7f8 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java +++ b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java @@ -1,11 +1,10 @@ package com.gachi.be.domain.newsletter.repository; import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; - -import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -98,19 +97,17 @@ List findRecentByUserId( @Param("rangeStart") OffsetDateTime rangeStart, @Param("rangeEnd") OffsetDateTime rangeEnd); - - /** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리*/ - @Modifying - @Query( - """ + /** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리 */ + @Modifying + @Query( + """ UPDATE Newsletter n SET n.status = :failedStatus WHERE n.userId = :userId AND n.status IN :targetStatuses """) - int cancelInProgressByUserId( - @Param("userId") Long userId, - @Param("targetStatuses") List targetStatuses, - @Param("failedStatus") NewsletterStatus failedStatus); - + int cancelInProgressByUserId( + @Param("userId") Long userId, + @Param("targetStatuses") List targetStatuses, + @Param("failedStatus") NewsletterStatus failedStatus); } diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java index dc16910..92b6e6e 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java @@ -13,8 +13,7 @@ public interface NewsletterService { * @param childId 연결할 자녀 ID (미선택 시 null) * @return newsletterId + status(PENDING) */ - NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId); + NewsletterUploadResponse upload(Long userId, MultipartFile file, Long childId); /** * 가정통신문의 현재 분석 상태와 진행률을 조회. diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java index d7fb697..321447a 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java @@ -69,14 +69,13 @@ public class NewsletterServiceImpl implements NewsletterService { /** * 가정통신문 파일을 S3에 업로드하고 newsletter 레코드를 PENDING 상태로 생성한다. * - * 처리 순서: 파일 유효성 검사 (형식: jpg/png/pdf, 크기: 최대 10MB) SHA-256 해시 계산 (중복 방지용) 중복 파일 확인 S3 업로드 → + *

처리 순서: 파일 유효성 검사 (형식: jpg/png/pdf, 크기: 최대 10MB) SHA-256 해시 계산 (중복 방지용) 중복 파일 확인 S3 업로드 → * file_key 획득 childId가 있으면 children 테이블에서 자녀 정보 조회 (스냅샷용) newsletter 레코드 DB 저장 (status=PENDING 으로 * 변경) AI 분석 파이프라인 비동기 트리거 -> Asyncㅏ로 별도 스레드에서 실행하게 함. */ @Override @Transactional - public NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId) { + public NewsletterUploadResponse upload(Long userId, MultipartFile file, Long childId) { // 파일 유효성 검사 validateFile(file); @@ -128,15 +127,15 @@ public NewsletterUploadResponse upload( // userId로 users 테이블에서 language_code를 조회하는 내부 메서드. // 사용자를 찾지 못하면 기본값 'KO'를 반환한다 (방어 코드). private String resolveUserLanguage(Long userId) { - return userRepository - .findById(userId) - .map(User::getLanguageCode) - .filter(lang -> lang != null && !lang.isBlank()) - .orElseGet( - () -> { - log.warn("[Newsletter] 사용자 언어 조회 실패. userId={}. 기본값 KO 사용.", userId); - return "KO"; - }); + return userRepository + .findById(userId) + .map(User::getLanguageCode) + .filter(lang -> lang != null && !lang.isBlank()) + .orElseGet( + () -> { + log.warn("[Newsletter] 사용자 언어 조회 실패. userId={}. 기본값 KO 사용.", userId); + return "KO"; + }); } @Transactional @@ -472,7 +471,7 @@ private Set getRegisteredNewsletterIds(Long userId, List newsl /** * 파일 유효성 검사. * - * TODO: 허용방식은 일단 이렇게만 지정해두고 테스트 해보면서 추가할 지 고려. 허용 형식: image/jpeg, image/png, application/pdf + *

TODO: 허용방식은 일단 이렇게만 지정해두고 테스트 해보면서 추가할 지 고려. 허용 형식: image/jpeg, image/png, application/pdf * 최대 크기: 10MB */ private void validateFile(MultipartFile file) { diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 8be27f6..59d975f 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -11,13 +11,12 @@ import com.gachi.be.global.code.SuccessCode; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -import java.util.List; - /** 로그인 사용자 기준 내 정보 조회 API를 제공한다. */ @Slf4j @RestController @@ -28,6 +27,14 @@ public class UserController { private final UserRepository userRepository; private final NewsletterRepository newsletterRepository; + @Operation( + summary = "사용자 내 정보 조회", + description = + """ + 마이페이지에서 사용자의 정보를 볼 수 있습니다. 이름, 닉네임, 등록일을 반환하고 + 추후 이메일을 변경할 경우를 고려 이메일도 반환합니다. + 알림설정 여부와 사용자의 언어까지 조회합니다. + """) @GetMapping("/me") public ApiResponse getMyInfo( @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { @@ -44,41 +51,41 @@ public ApiResponse getMyInfo( user.getCreatedAt())); } - /** 언어 설정 변경 API */ - @Operation( - summary = "사용자 언어 설정 변경", - description = - """ + /** 언어 설정 변경 API */ + @Operation( + summary = "사용자 언어 설정 변경", + description = + """ 마이페이지에서 내가 회원가입 시에 설정했떤 언어를 변경할 수 있습니다. 해당 언어를 변경한 뒤에 스캔된 문서들은 전부 해당 언어로 번역됩니다. """) - @PatchMapping("/me/language") - @Transactional - public ApiResponse changeLanguage( - @RequestHeader(value = "Authorization", required = false) String authorizationHeader, - @RequestBody @Valid ChangeLanguageRequest request) { - - User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); + @PatchMapping("/me/language") + @Transactional + public ApiResponse changeLanguage( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @RequestBody @Valid ChangeLanguageRequest request) { - String previousLanguage = user.getLanguageCode(); - String newLanguage = request.languageCode(); + User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); - user.updateLanguage(newLanguage); - userRepository.save(user); + String previousLanguage = user.getLanguageCode(); + String newLanguage = request.languageCode(); - // 진행 중인 파이프라인 FAILED 처리 - int cancelledCount = - newsletterRepository.cancelInProgressByUserId( - user.getId(), - List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), - NewsletterStatus.FAILED); + user.updateLanguage(newLanguage); + userRepository.save(user); - log.info( - "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", + // 진행 중인 파이프라인 FAILED 처리 + int cancelledCount = + newsletterRepository.cancelInProgressByUserId( user.getId(), - previousLanguage, - newLanguage, - cancelledCount); + List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), + NewsletterStatus.FAILED); + + log.info( + "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", + user.getId(), + previousLanguage, + newLanguage, + cancelledCount); - return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); - } + return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); + } } diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java index fa83e1a..95a174d 100644 --- a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java @@ -3,10 +3,10 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; -/**언어 설정 변경 요청 DTO.*/ +/** 언어 설정 변경 요청 DTO. */ public record ChangeLanguageRequest( @NotBlank(message = "언어 코드는 필수입니다.") - @Pattern( - regexp = "^(KO|US|ZH|VI)$", - message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") - String languageCode) {} + @Pattern( + regexp = "^(KO|US|ZH|VI)$", + message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + String languageCode) {} diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java index d032959..fa798c1 100644 --- a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java @@ -2,8 +2,6 @@ import jakarta.validation.constraints.NotNull; -/**알림 설정 변경 요청 DTO.*/ +/** 알림 설정 변경 요청 DTO. */ public record ChangeNotificationRequest( - @NotNull(message = "notificationEnabled는 필수입니다.") - boolean notificationEnabled -) {} + @NotNull(message = "notificationEnabled는 필수입니다.") boolean notificationEnabled) {} diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index d77fc72..056714a 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -114,11 +114,11 @@ public boolean isActive() { } public void updateLanguage(String languageCode) { - this.languageCode = languageCode; + this.languageCode = languageCode; } public void updateNotificationEnabled(boolean notificationEnabled) { - this.notificationEnabled = notificationEnabled; + this.notificationEnabled = notificationEnabled; } @PrePersist @@ -131,9 +131,9 @@ protected void onCreate() { if (status == null) { status = UserStatus.ACTIVE; } - if (languageCode == null) { - languageCode = "KO"; - } + if (languageCode == null) { + languageCode = "KO"; + } } @PreUpdate diff --git a/src/main/resources/db/migration/V3__user_add_notification_enabled.sql b/src/main/resources/db/migration/V3__user_add_notification_enabled.sql deleted file mode 100644 index a2405b6..0000000 --- a/src/main/resources/db/migration/V3__user_add_notification_enabled.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users - ADD COLUMN IF NOT EXISTS notification_enabled BOOLEAN NOT NULL DEFAULT TRUE; From 807188eac118f3edbca18aa80d12136e7ddd61a6 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 22:35:29 +0900 Subject: [PATCH 19/23] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gachi/be/domain/auth/dto/request/SignupRequest.java | 1 + .../be/domain/newsletter/pipeline/AiNewsletterClient.java | 6 ++++-- .../newsletter/repository/NewsletterRepository.java | 6 ++++-- .../be/domain/user/api/controller/UserController.java | 8 +++++++- src/main/java/com/gachi/be/domain/user/entity/User.java | 5 ++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index 0c4dc52..6ac80b7 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -18,5 +18,6 @@ public record SignupRequest( @Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE) String phoneNumber, @NotNull Boolean consentAgreed, + @NotNull(message = "languageCode는 필수입니다.") @Pattern(regexp = "^(KO|US|ZH|VI)$", message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") String languageCode) {} diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java index 89f791b..a4dffd7 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java @@ -55,6 +55,7 @@ public AnalysisResponse analyze( LocalDate.now(DEFAULT_ZONE), DEFAULT_ZONE.getId(), toDateCandidateRequests(dateCandidates))); + log.info("[AiNewsletterClient] 요청 body: {}", requestBody); HttpRequest request = HttpRequest.newBuilder() @@ -69,9 +70,10 @@ public AnalysisResponse analyze( if (response.statusCode() < 200 || response.statusCode() >= 300) { log.error( - "[AiNewsletterClient] AI 서버 분석 실패. status={}, bodyLength={}", + "[AiNewsletterClient] AI 서버 분석 실패. status={}, body={}", response.statusCode(), - response.body() != null ? response.body().length() : 0); + response.body()); + //response.body() != null ? response.body().length() : 0); throw new ExternalApiException( ErrorCode.EXTERNAL_API_ERROR, "AI 서버 분석 실패. status=" + response.statusCode()); } diff --git a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java index 04ea7f8..8cc81ba 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java +++ b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java @@ -102,12 +102,14 @@ List findRecentByUserId( @Query( """ UPDATE Newsletter n - SET n.status = :failedStatus + SET n.status = :failedStatus, + n.language = :newLanguage WHERE n.userId = :userId AND n.status IN :targetStatuses """) int cancelInProgressByUserId( @Param("userId") Long userId, @Param("targetStatuses") List targetStatuses, - @Param("failedStatus") NewsletterStatus failedStatus); + @Param("failedStatus") NewsletterStatus failedStatus, + @Param("newLanguage") String newLanguage); } diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 59d975f..df7b490 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -12,6 +12,8 @@ import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import java.util.List; +import java.util.Objects; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -69,6 +71,10 @@ public ApiResponse changeLanguage( String previousLanguage = user.getLanguageCode(); String newLanguage = request.languageCode(); + if (Objects.equals(previousLanguage, newLanguage)) { + return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); + } + user.updateLanguage(newLanguage); userRepository.save(user); @@ -77,7 +83,7 @@ public ApiResponse changeLanguage( newsletterRepository.cancelInProgressByUserId( user.getId(), List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), - NewsletterStatus.FAILED); + NewsletterStatus.FAILED, request.languageCode()); log.info( "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 056714a..7830992 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -114,7 +114,10 @@ public boolean isActive() { } public void updateLanguage(String languageCode) { - this.languageCode = languageCode; + if (languageCode == null || languageCode.isBlank()) { + throw new IllegalArgumentException("languageCode는 비어 있을 수 없습니다."); + } + this.languageCode = languageCode.trim().toUpperCase(); } public void updateNotificationEnabled(boolean notificationEnabled) { From ca1b3cc880d2d37cf3425775d34c340e4586b929 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 23:25:02 +0900 Subject: [PATCH 20/23] =?UTF-8?q?fix:=20HttpClient=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/domain/auth/dto/request/SignupRequest.java | 4 +++- .../domain/newsletter/pipeline/AiNewsletterClient.java | 4 +++- .../be/domain/user/api/controller/UserController.java | 10 +++++----- .../java/com/gachi/be/domain/user/entity/User.java | 8 ++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index 6ac80b7..31de87c 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -19,5 +19,7 @@ public record SignupRequest( String phoneNumber, @NotNull Boolean consentAgreed, @NotNull(message = "languageCode는 필수입니다.") - @Pattern(regexp = "^(KO|US|ZH|VI)$", message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + @Pattern( + regexp = "^(KO|US|ZH|VI)$", + message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") String languageCode) {} diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java index a4dffd7..4ec5791 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java @@ -37,6 +37,7 @@ public AiNewsletterClient(AiServerProperties aiServerProperties, ObjectMapper ob this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(aiServerProperties.getConnectTimeoutSeconds())) + .version(HttpClient.Version.HTTP_1_1) .build(); } @@ -61,6 +62,7 @@ public AnalysisResponse analyze( HttpRequest.newBuilder() .uri(URI.create(normalizedBaseUrl() + ANALYZE_PATH)) .header("Content-Type", "application/json") + .header("Accept", "application/json") .timeout(Duration.ofSeconds(aiServerProperties.getReadTimeoutSeconds())) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); @@ -73,7 +75,7 @@ public AnalysisResponse analyze( "[AiNewsletterClient] AI 서버 분석 실패. status={}, body={}", response.statusCode(), response.body()); - //response.body() != null ? response.body().length() : 0); + // response.body() != null ? response.body().length() : 0); throw new ExternalApiException( ErrorCode.EXTERNAL_API_ERROR, "AI 서버 분석 실패. status=" + response.statusCode()); } diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index df7b490..1c1658d 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -13,7 +13,6 @@ import jakarta.validation.Valid; import java.util.List; import java.util.Objects; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -71,9 +70,9 @@ public ApiResponse changeLanguage( String previousLanguage = user.getLanguageCode(); String newLanguage = request.languageCode(); - if (Objects.equals(previousLanguage, newLanguage)) { - return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); - } + if (Objects.equals(previousLanguage, newLanguage)) { + return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); + } user.updateLanguage(newLanguage); userRepository.save(user); @@ -83,7 +82,8 @@ public ApiResponse changeLanguage( newsletterRepository.cancelInProgressByUserId( user.getId(), List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), - NewsletterStatus.FAILED, request.languageCode()); + NewsletterStatus.FAILED, + request.languageCode()); log.info( "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 7830992..3ee8483 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -114,10 +114,10 @@ public boolean isActive() { } public void updateLanguage(String languageCode) { - if (languageCode == null || languageCode.isBlank()) { - throw new IllegalArgumentException("languageCode는 비어 있을 수 없습니다."); - } - this.languageCode = languageCode.trim().toUpperCase(); + if (languageCode == null || languageCode.isBlank()) { + throw new IllegalArgumentException("languageCode는 비어 있을 수 없습니다."); + } + this.languageCode = languageCode.trim().toUpperCase(); } public void updateNotificationEnabled(boolean notificationEnabled) { From 922291b0fbeb171c14ec8fb403678f0d95a2b6d5 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 23:47:17 +0900 Subject: [PATCH 21/23] =?UTF-8?q?feat:=20AI=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterAiAnalyzer.java | 90 +++++++++++++++---- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index ca25419..e7646a7 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -31,6 +31,7 @@ public class NewsletterAiAnalyzer { private final ChecklistRepository checklistRepository; private final CalendarPreviewRedisService calendarPreviewRedisService; private final NewsletterRepository newsletterRepository; + private final PapagoTranslateClient papagoTranslateClient; public AiAnalysisResult analyze( Long newsletterId, String originalText, String translatedText, String language) { @@ -50,15 +51,17 @@ public AiAnalysisResult analyze( List items = analysisResponse.items(); List savedItems = - saveExtractedItems(newsletterId, newsletter.getUserId(), items); + saveExtractedItems(newsletterId, newsletter.getUserId(), items, language); + try { - saveCalendarPreview(newsletterId, savedItems); + saveCalendarPreview(newsletterId, savedItems); } catch (RuntimeException e) { - log.warn( - "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); + log.warn( + "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); } - String title = normalizeTitle(analysisResponse.title(), originalText); + String rawTitle = normalizeTitle(analysisResponse.title(), originalText); + String title = translateIfNeeded(rawTitle, language, "title", newsletterId); String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText); log.info( @@ -67,7 +70,7 @@ public AiAnalysisResult analyze( } private List saveExtractedItems( - Long newsletterId, Long userId, List items) { + Long newsletterId, Long userId, List items, String language) { if (items == null || items.isEmpty()) { log.warn("[AiAnalyzer] AI 서버 항목 추출 결과 없음. newsletterId={}", newsletterId); return List.of(); @@ -76,11 +79,13 @@ private List saveExtractedItems( List validItems = items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList(); List entities = - validItems.stream().map(item -> toChecklist(newsletterId, userId, item)).toList(); + validItems.stream() + .map(item -> toChecklist(newsletterId, userId, item, language)) + .toList(); if (entities.isEmpty()) { - log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); - return List.of(); + log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); + return List.of(); } List savedEntities = checklistRepository.saveAll(entities); @@ -88,33 +93,80 @@ private List saveExtractedItems( List savedItems = new ArrayList<>(); for (int i = 0; i < validItems.size(); i++) { - savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); + savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); } - return savedItems; - } + return savedItems;} - private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item) { + private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item, String language) { ChecklistType checklistType = "checklist".equalsIgnoreCase(item.type()) ? ChecklistType.CHECKLIST : ChecklistType.TODO; LocalDate targetDate = parseTargetDate(item.datetime()); - String targetDateLabel = - targetDate != null - ? targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일" - : null; + + String targetDateLabel = null; + if (targetDate != null) { + String koreanLabel = + targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일"; + targetDateLabel = + translateIfNeeded(koreanLabel, language, "targetDateLabel", newsletterId); + } + + // content: AI 서버가 한국어로 추출한 항목명을 파파고로 번역 + String content = + translateIfNeeded( + trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH), + language, + "content", + newsletterId); + + // detail: AI 서버가 한국어로 추출한 상세 설명을 파파고로 번역 + String detail = null; + if (item.evidenceText() != null && !item.evidenceText().isBlank()) { + String trimmedDetail = trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH); + detail = translateIfNeeded(trimmedDetail, language, "detail", newsletterId); + } return Checklist.builder() .newsletterId(newsletterId) .calendarEventId(null) .userId(userId) .type(checklistType) - .content(trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH)) - .detail(trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH)) + .content(content) + .detail(detail) .targetDate(checklistType == ChecklistType.TODO ? targetDate : null) .targetDateLabel(checklistType == ChecklistType.TODO ? targetDateLabel : null) .build(); } + /**KO가 아닌 경우 파파고로 번역. KO이거나 번역 실패 시 원문 그대로 반환.*/ + private String translateIfNeeded( + String text, String language, String fieldName, Long newsletterId) { + if (text == null || text.isBlank()) { + return text; + } + if ("KO".equals(language)) { + // KO는 번역 스킵, 원문 그대로 반환 + return text; + } + try { + String translated = papagoTranslateClient.translate(text, language); + if (translated != null && !translated.isBlank()) { + log.debug( + "[AiAnalyzer] {} 번역 완료. newsletterId={}, language={}", fieldName, newsletterId, language); + return translated; + } + } catch (Exception e) { + // 번역 실패 시 원문 유지 (파이프라인 전체를 중단하지 않음) + log.warn( + "[AiAnalyzer] {} 번역 실패. 원문 유지. newsletterId={}, language={}, error={}", + fieldName, + newsletterId, + language, + e.getMessage()); + } + return text; + } + private LocalDate parseTargetDate(String value) { if (value == null || value.isBlank()) { return null; From 84f5e12b3fe622079b707f2ea21709bd31f08b7d Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 23:50:26 +0900 Subject: [PATCH 22/23] =?UTF-8?q?feat:=20spotlessApply=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterAiAnalyzer.java | 83 ++++++++++--------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index e7646a7..7958e14 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -54,10 +54,10 @@ public AiAnalysisResult analyze( saveExtractedItems(newsletterId, newsletter.getUserId(), items, language); try { - saveCalendarPreview(newsletterId, savedItems); + saveCalendarPreview(newsletterId, savedItems); } catch (RuntimeException e) { - log.warn( - "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); + log.warn( + "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); } String rawTitle = normalizeTitle(analysisResponse.title(), originalText); @@ -79,13 +79,11 @@ private List saveExtractedItems( List validItems = items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList(); List entities = - validItems.stream() - .map(item -> toChecklist(newsletterId, userId, item, language)) - .toList(); + validItems.stream().map(item -> toChecklist(newsletterId, userId, item, language)).toList(); if (entities.isEmpty()) { - log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); - return List.of(); + log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); + return List.of(); } List savedEntities = checklistRepository.saveAll(entities); @@ -93,11 +91,13 @@ private List saveExtractedItems( List savedItems = new ArrayList<>(); for (int i = 0; i < validItems.size(); i++) { - savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); + savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); } - return savedItems;} + return savedItems; + } - private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item, String language) { + private Checklist toChecklist( + Long newsletterId, Long userId, ExtractedItem item, String language) { ChecklistType checklistType = "checklist".equalsIgnoreCase(item.type()) ? ChecklistType.CHECKLIST : ChecklistType.TODO; @@ -105,10 +105,8 @@ private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item String targetDateLabel = null; if (targetDate != null) { - String koreanLabel = - targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일"; - targetDateLabel = - translateIfNeeded(koreanLabel, language, "targetDateLabel", newsletterId); + String koreanLabel = targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일"; + targetDateLabel = translateIfNeeded(koreanLabel, language, "targetDateLabel", newsletterId); } // content: AI 서버가 한국어로 추출한 항목명을 파파고로 번역 @@ -122,8 +120,8 @@ private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item // detail: AI 서버가 한국어로 추출한 상세 설명을 파파고로 번역 String detail = null; if (item.evidenceText() != null && !item.evidenceText().isBlank()) { - String trimmedDetail = trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH); - detail = translateIfNeeded(trimmedDetail, language, "detail", newsletterId); + String trimmedDetail = trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH); + detail = translateIfNeeded(trimmedDetail, language, "detail", newsletterId); } return Checklist.builder() @@ -138,33 +136,36 @@ private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item .build(); } - /**KO가 아닌 경우 파파고로 번역. KO이거나 번역 실패 시 원문 그대로 반환.*/ + /** KO가 아닌 경우 파파고로 번역. KO이거나 번역 실패 시 원문 그대로 반환. */ private String translateIfNeeded( String text, String language, String fieldName, Long newsletterId) { - if (text == null || text.isBlank()) { - return text; - } - if ("KO".equals(language)) { - // KO는 번역 스킵, 원문 그대로 반환 - return text; - } - try { - String translated = papagoTranslateClient.translate(text, language); - if (translated != null && !translated.isBlank()) { - log.debug( - "[AiAnalyzer] {} 번역 완료. newsletterId={}, language={}", fieldName, newsletterId, language); - return translated; - } - } catch (Exception e) { - // 번역 실패 시 원문 유지 (파이프라인 전체를 중단하지 않음) - log.warn( - "[AiAnalyzer] {} 번역 실패. 원문 유지. newsletterId={}, language={}, error={}", - fieldName, - newsletterId, - language, - e.getMessage()); - } + if (text == null || text.isBlank()) { return text; + } + if ("KO".equals(language)) { + // KO는 번역 스킵, 원문 그대로 반환 + return text; + } + try { + String translated = papagoTranslateClient.translate(text, language); + if (translated != null && !translated.isBlank()) { + log.debug( + "[AiAnalyzer] {} 번역 완료. newsletterId={}, language={}", + fieldName, + newsletterId, + language); + return translated; + } + } catch (Exception e) { + // 번역 실패 시 원문 유지 (파이프라인 전체를 중단하지 않음) + log.warn( + "[AiAnalyzer] {} 번역 실패. 원문 유지. newsletterId={}, language={}, error={}", + fieldName, + newsletterId, + language, + e.getMessage()); + } + return text; } private LocalDate parseTargetDate(String value) { From 8abed563425ce9d7ef8acb0c236be2fc14394bce Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 23:58:28 +0900 Subject: [PATCH 23/23] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=97=90=20=EC=96=B8=EC=96=B4=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/api/controller/AuthControllerIntegrationTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/gachi/be/domain/auth/api/controller/AuthControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/auth/api/controller/AuthControllerIntegrationTest.java index 4913323..b2499ed 100644 --- a/src/test/java/com/gachi/be/domain/auth/api/controller/AuthControllerIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/auth/api/controller/AuthControllerIntegrationTest.java @@ -113,7 +113,8 @@ void signupLoginReissueAndRotation() throws Exception { "password":"password123", "passwordConfirm":"password123", "phoneNumber":"01012345678", - "consentAgreed":true + "consentAgreed":true, + "languageCode":"KO" } """) .andExpect(status().isCreated()) @@ -677,7 +678,8 @@ private String signupPayload( "password", password, "passwordConfirm", passwordConfirm, "phoneNumber", phoneNumber, - "consentAgreed", consentAgreed)); + "consentAgreed", consentAgreed, + "languageCode", "KO")); } private org.springframework.test.web.servlet.ResultActions login(String payload)