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..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 @@ -17,4 +17,9 @@ public record SignupRequest( @NotBlank @Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE) String phoneNumber, - @NotNull Boolean consentAgreed) {} + @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/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..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 @@ -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; @@ -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()) 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..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,13 +65,10 @@ 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, language); + 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/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java index 89f791b..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(); } @@ -55,11 +56,13 @@ public AnalysisResponse analyze( LocalDate.now(DEFAULT_ZONE), DEFAULT_ZONE.getId(), toDateCandidateRequests(dateCandidates))); + log.info("[AiNewsletterClient] 요청 body: {}", requestBody); HttpRequest request = 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(); @@ -69,9 +72,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/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index ca25419..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 @@ -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,7 +51,8 @@ public AiAnalysisResult analyze( List items = analysisResponse.items(); List savedItems = - saveExtractedItems(newsletterId, newsletter.getUserId(), items); + saveExtractedItems(newsletterId, newsletter.getUserId(), items, language); + try { saveCalendarPreview(newsletterId, savedItems); } catch (RuntimeException e) { @@ -58,7 +60,8 @@ public AiAnalysisResult analyze( "[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,7 +79,7 @@ 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); @@ -93,28 +96,78 @@ private List saveExtractedItems( 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; 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 d751a69..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 @@ -1,6 +1,7 @@ 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; @@ -95,4 +96,20 @@ List findRecentByUserId( @Param("userId") Long userId, @Param("rangeStart") OffsetDateTime rangeStart, @Param("rangeEnd") OffsetDateTime rangeEnd); + + /** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리 */ + @Modifying + @Query( + """ + UPDATE Newsletter n + 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("newLanguage") String newLanguage); } 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..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 @@ -11,11 +11,9 @@ 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); + 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 b773376..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 @@ -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,6 +62,7 @@ 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; @@ -72,8 +75,7 @@ public class NewsletterServiceImpl implements NewsletterService { */ @Override @Transactional - public NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId, String userLanguage) { + public NewsletterUploadResponse upload(Long userId, MultipartFile file, Long childId) { // 파일 유효성 검사 validateFile(file); @@ -110,6 +112,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 +124,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, 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..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 @@ -1,23 +1,41 @@ 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 io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Objects; 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.*; /** 로그인 사용자 기준 내 정보 조회 API를 제공한다. */ +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/users") public class UserController { private final AuthenticatedUserResolver authenticatedUserResolver; + private final UserRepository userRepository; + private final NewsletterRepository newsletterRepository; + @Operation( + summary = "사용자 내 정보 조회", + description = + """ + 마이페이지에서 사용자의 정보를 볼 수 있습니다. 이름, 닉네임, 등록일을 반환하고 + 추후 이메일을 변경할 경우를 고려 이메일도 반환합니다. + 알림설정 여부와 사용자의 언어까지 조회합니다. + """) @GetMapping("/me") public ApiResponse getMyInfo( @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { @@ -29,6 +47,51 @@ public ApiResponse getMyInfo( user.getLoginId(), user.getEmail(), user.getName(), - user.getPhoneNumber())); + user.getLanguageCode(), + user.isNotificationEnabled(), + user.getCreatedAt())); + } + + /** 언어 설정 변경 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); + + 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); + + // 진행 중인 파이프라인 FAILED 처리 + int cancelledCount = + newsletterRepository.cancelInProgressByUserId( + user.getId(), + List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), + NewsletterStatus.FAILED, + request.languageCode()); + + 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/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..95a174d --- /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) {} 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..fa798c1 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java @@ -0,0 +1,7 @@ +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 5614a34..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 @@ -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,12 @@ public class User { @Column(nullable = false, length = 20) private UserStatus status; + @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; @@ -79,6 +86,8 @@ public User( String name, String phoneNumber, UserStatus status, + String languageCode, + Boolean notificationEnabled, OffsetDateTime emailVerifiedAt, OffsetDateTime consentAgreedAt, String consentVersion, @@ -90,6 +99,8 @@ public User( this.name = name; 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; @@ -102,6 +113,17 @@ public boolean isActive() { return status == UserStatus.ACTIVE; } + public void updateLanguage(String languageCode) { + if (languageCode == null || languageCode.isBlank()) { + throw new IllegalArgumentException("languageCode는 비어 있을 수 없습니다."); + } + this.languageCode = languageCode.trim().toUpperCase(); + } + + public void updateNotificationEnabled(boolean notificationEnabled) { + this.notificationEnabled = notificationEnabled; + } + @PrePersist protected void onCreate() { LocalDateTime now = LocalDateTime.now(); @@ -112,6 +134,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/code/ErrorCode.java b/src/main/java/com/gachi/be/global/code/ErrorCode.java index c063bbf..44445cf 100644 --- a/src/main/java/com/gachi/be/global/code/ErrorCode.java +++ b/src/main/java/com/gachi/be/global/code/ErrorCode.java @@ -222,6 +222,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", 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 9152e8f..b9e7277 100644 --- a/src/main/java/com/gachi/be/global/code/SuccessCode.java +++ b/src/main/java/com/gachi/be/global/code/SuccessCode.java @@ -37,7 +37,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; 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/main/resources/db/migration/V12__user_add_language_code.sql b/src/main/resources/db/migration/V12__user_add_language_code.sql new file mode 100644 index 0000000..e32c42a --- /dev/null +++ b/src/main/resources/db/migration/V12__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'; 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; 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) 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;