diff --git a/src/main/java/com/example/konnect_backend/domain/auth/service/DataMergeServiceImpl.java b/src/main/java/com/example/konnect_backend/domain/auth/service/DataMergeServiceImpl.java index e09a179..75b3c45 100644 --- a/src/main/java/com/example/konnect_backend/domain/auth/service/DataMergeServiceImpl.java +++ b/src/main/java/com/example/konnect_backend/domain/auth/service/DataMergeServiceImpl.java @@ -40,11 +40,12 @@ public void mergeGuestToUser(String deviceUuid, Long userId) { User targetUser = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - if (device.getLanguage() != null) { + // 게스트에서 선택한 언어를 최초 1회만 회원에게 승계 + if (targetUser.getLanguage() == null && device.getLanguage() != null) { targetUser.updateLanguage(device.getLanguage()); } - // 무조건 최신 유저로 업데이트 + // 현재 디바이스는 최신 로그인 유저에 연결 device.updateUser(targetUser); // 데이터 이전 diff --git a/src/main/java/com/example/konnect_backend/domain/user/controller/UserController.java b/src/main/java/com/example/konnect_backend/domain/user/controller/UserController.java index 749218b..1c8d537 100644 --- a/src/main/java/com/example/konnect_backend/domain/user/controller/UserController.java +++ b/src/main/java/com/example/konnect_backend/domain/user/controller/UserController.java @@ -1,9 +1,8 @@ // src/main/java/com/example/konnect_backend/domain/user/controller/UserController.java package com.example.konnect_backend.domain.user.controller; -import com.example.konnect_backend.domain.user.dto.ChildDto; -import com.example.konnect_backend.domain.user.dto.ChildUpdateDto; -import com.example.konnect_backend.domain.user.dto.UserInfoDto; +import com.example.konnect_backend.domain.user.dto.*; +import com.example.konnect_backend.domain.user.service.LanguagePreferenceService; import com.example.konnect_backend.domain.user.service.UserService; import com.example.konnect_backend.global.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -21,6 +20,7 @@ public class UserController { private final UserService userService; + private final LanguagePreferenceService languagePreferenceService; @PostMapping("/children") @Operation(summary = "자녀 추가", description = "현재 로그인한 사용자에게 자녀를 추가합니다.") @@ -53,4 +53,37 @@ public ApiResponse getUserInfo() { return ApiResponse.onSuccess(userService.getUserInfo()); } + @PatchMapping("/language") + @Operation( + summary = "사용자 언어 변경", + description = """ + 로그인 상태면 User.language를 변경합니다. + 비로그인 상태면 X-Device-Id를 기준으로 Device.language를 변경합니다. + 로그인 상태에서 X-Device-Id를 함께 보내면 Device.language도 함께 동기화합니다. + """ + ) + public ApiResponse updateLanguage( + @RequestHeader(value = "X-Device-Id", required = false) String deviceUuid, + @Valid @RequestBody UpdateLanguageRequest request + ) { + return ApiResponse.onSuccess( + languagePreferenceService.updateLanguage(deviceUuid, request.getLanguage()) + ); + } + + @GetMapping("/language") + @Operation( + summary = "사용자 언어 조회", + description = """ + 로그인 상태면 User.language를 조회합니다. + 비로그인 상태면 X-Device-Id를 기준으로 Device.language를 조회합니다. + """ + ) + public ApiResponse getLanguage( + @RequestHeader(value = "X-Device-Id", required = false) String deviceUuid + ) { + return ApiResponse.onSuccess( + languagePreferenceService.getLanguage(deviceUuid) + ); + } } \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/domain/user/dto/LanguageResponse.java b/src/main/java/com/example/konnect_backend/domain/user/dto/LanguageResponse.java new file mode 100644 index 0000000..92c94a4 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/user/dto/LanguageResponse.java @@ -0,0 +1,15 @@ +package com.example.konnect_backend.domain.user.dto; + +import com.example.konnect_backend.domain.user.entity.status.Language; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class LanguageResponse { + + private Language language; + private boolean loggedIn; +} \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/domain/user/dto/UpdateLanguageRequest.java b/src/main/java/com/example/konnect_backend/domain/user/dto/UpdateLanguageRequest.java new file mode 100644 index 0000000..3c7c35c --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/user/dto/UpdateLanguageRequest.java @@ -0,0 +1,14 @@ +package com.example.konnect_backend.domain.user.dto; + +import com.example.konnect_backend.domain.user.entity.status.Language; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UpdateLanguageRequest { + + @NotNull + private Language language; +} \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/domain/user/service/DeviceService.java b/src/main/java/com/example/konnect_backend/domain/user/service/DeviceService.java index eb02866..d14ee19 100644 --- a/src/main/java/com/example/konnect_backend/domain/user/service/DeviceService.java +++ b/src/main/java/com/example/konnect_backend/domain/user/service/DeviceService.java @@ -7,10 +7,11 @@ import com.example.konnect_backend.global.code.status.ErrorStatus; import com.example.konnect_backend.global.exception.GeneralException; import lombok.RequiredArgsConstructor; - import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor public class DeviceService { @@ -20,14 +21,11 @@ public class DeviceService { @Transactional public void registerDevice(String deviceUuid, Language language) { - if (deviceUuid == null || deviceUuid.isBlank()) { - throw new GeneralException(ErrorStatus.INVALID_DEVICE); - } + validateDeviceUuid(deviceUuid); deviceRepository.findById(deviceUuid) .map(device -> { - // 이미 존재하면 language 업데이트 - if ( language != null) { + if (language != null) { device.updateLanguage(language); } return device; @@ -36,24 +34,50 @@ public void registerDevice(String deviceUuid, Language language) { deviceRepository.save( Device.builder() .deviceUuid(deviceUuid) - .language(language) // 추가 + .language(language) + .createdAt(LocalDateTime.now()) + .lastUsedAt(LocalDateTime.now()) .build() ) ); } - @Transactional public Device findOrCreateDevice(String deviceUuid) { + validateDeviceUuid(deviceUuid); return deviceRepository.findById(deviceUuid) .orElseGet(() -> deviceRepository.save( Device.builder() .deviceUuid(deviceUuid) + .createdAt(LocalDateTime.now()) + .lastUsedAt(LocalDateTime.now()) .build() ) ); } + @Transactional + public void updateLanguage(String deviceUuid, Language language) { + validateDeviceUuid(deviceUuid); + + Device device = findOrCreateDevice(deviceUuid); + device.updateLanguage(language); + } + + @Transactional(readOnly = true) + public Language getLanguage(String deviceUuid) { + validateDeviceUuid(deviceUuid); + + return deviceRepository.findById(deviceUuid) + .map(Device::getLanguage) + .orElse(null); + } + + private void validateDeviceUuid(String deviceUuid) { + if (deviceUuid == null || deviceUuid.isBlank()) { + throw new GeneralException(ErrorStatus.INVALID_DEVICE); + } + } } \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/domain/user/service/LanguagePreferenceService.java b/src/main/java/com/example/konnect_backend/domain/user/service/LanguagePreferenceService.java new file mode 100644 index 0000000..66373e4 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/user/service/LanguagePreferenceService.java @@ -0,0 +1,78 @@ +package com.example.konnect_backend.domain.user.service; + +import com.example.konnect_backend.domain.user.dto.LanguageResponse; +import com.example.konnect_backend.domain.user.entity.status.Language; +import com.example.konnect_backend.global.code.status.ErrorStatus; +import com.example.konnect_backend.global.exception.GeneralException; +import com.example.konnect_backend.global.security.SecurityUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LanguagePreferenceService { + + private final UserService userService; + private final DeviceService deviceService; + + @Transactional + public LanguageResponse updateLanguage(String deviceUuid, Language language) { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + + if (language == null) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + + // 로그인 사용자 + if (userId != null) { + userService.updateLanguage(userId, language); + + // 선택적으로 디바이스 언어도 동기화 + if (deviceUuid != null && !deviceUuid.isBlank()) { + deviceService.updateLanguage(deviceUuid, language); + } + + return LanguageResponse.builder() + .language(language) + .loggedIn(true) + .build(); + } + + // 비로그인 사용자 + if (deviceUuid == null || deviceUuid.isBlank()) { + throw new GeneralException(ErrorStatus.INVALID_DEVICE); + } + + deviceService.updateLanguage(deviceUuid, language); + + return LanguageResponse.builder() + .language(language) + .loggedIn(false) + .build(); + } + + public LanguageResponse getLanguage(String deviceUuid) { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + + // 로그인 상태면 무조건 User.language 우선 + if (userId != null) { + Language language = userService.getLanguage(userId); + return LanguageResponse.builder() + .language(language) + .loggedIn(true) + .build(); + } + + if (deviceUuid == null || deviceUuid.isBlank()) { + throw new GeneralException(ErrorStatus.INVALID_DEVICE); + } + + Language language = deviceService.getLanguage(deviceUuid); + return LanguageResponse.builder() + .language(language) + .loggedIn(false) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/domain/user/service/UserService.java b/src/main/java/com/example/konnect_backend/domain/user/service/UserService.java index 5cd544e..ff61e26 100644 --- a/src/main/java/com/example/konnect_backend/domain/user/service/UserService.java +++ b/src/main/java/com/example/konnect_backend/domain/user/service/UserService.java @@ -6,6 +6,7 @@ import com.example.konnect_backend.domain.user.dto.UserInfoDto; import com.example.konnect_backend.domain.user.entity.Child; import com.example.konnect_backend.domain.user.entity.User; +import com.example.konnect_backend.domain.user.entity.status.Language; import com.example.konnect_backend.domain.user.repository.ChildRepository; import com.example.konnect_backend.domain.user.repository.UserRepository; import com.example.konnect_backend.global.code.status.ErrorStatus; @@ -127,4 +128,20 @@ public UserInfoDto getUserInfo(){ .language(user.getLanguage()) .build(); } + + @Transactional + public void updateLanguage(Long userId, Language language) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + user.updateLanguage(language); + } + + @Transactional(readOnly = true) + public Language getLanguage(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + return user.getLanguage(); + } } \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/global/config/WebSecurityConfig.java b/src/main/java/com/example/konnect_backend/global/config/WebSecurityConfig.java index 5c1dccd..cff242b 100644 --- a/src/main/java/com/example/konnect_backend/global/config/WebSecurityConfig.java +++ b/src/main/java/com/example/konnect_backend/global/config/WebSecurityConfig.java @@ -57,8 +57,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-resources/**", "/webjars/**" ).permitAll() - .requestMatchers("/api/auth/**", "/api/schools/**", "/api/device/**", "/api/ai/**", "/api/usage/**", "/api/message/**").permitAll() - .requestMatchers("/api/admin/**").permitAll() // Todo 관리자만 허용해야 함, 테스트 위해 모두 허용 + .requestMatchers("/api/auth/**", "/api/schools/**", "/api/device/**", "/api/ai/**", "/api/usage/**", "/api/message/**", "/api/users/language").permitAll() + .requestMatchers("/api/admin/**").denyAll() // Todo 관리자만 허용 필요 .requestMatchers("/api/ws/**", "/ws/**").permitAll() .requestMatchers("/login/oauth2/**", "/oauth2/**").permitAll() .requestMatchers("/public/**").permitAll()