diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bca24322..dbac20df 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,7 @@ ## #️⃣ Issue Number - +- resolved # ## 📝 요약(Summary) diff --git a/src/main/java/ssu/eatssu/domain/auth/entity/SystemAppleAuthenticator.java b/src/main/java/ssu/eatssu/domain/auth/entity/SystemAppleAuthenticator.java index b560e71c..00e49f70 100644 --- a/src/main/java/ssu/eatssu/domain/auth/entity/SystemAppleAuthenticator.java +++ b/src/main/java/ssu/eatssu/domain/auth/entity/SystemAppleAuthenticator.java @@ -13,6 +13,7 @@ import org.springframework.web.util.UriComponentsBuilder; import ssu.eatssu.domain.auth.dto.AppleKeys; import ssu.eatssu.domain.auth.dto.OAuthInfo; +import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.global.handler.response.BaseException; import java.math.BigInteger; @@ -32,6 +33,7 @@ public class SystemAppleAuthenticator implements AppleAuthenticator { private final RestTemplate restTemplate; + private final UserRepository userRepository; public OAuthInfo getOAuthInfoByIdentityToken(String identityToken) { PublicKey publicKey = generatePublicKey(identityToken); @@ -42,12 +44,16 @@ public OAuthInfo getOAuthInfoByIdentityToken(String identityToken) { * 애플 로그인 - PublicKey 를 통해 유저 정보(providerId, email) 조회 */ private OAuthInfo getOAuthInfoByPublicKey(String identityToken, PublicKey publicKey) { - // identityToken 에서 publicKey 서명을 통해 Claims 를 추출한다. - Claims claims = Jwts.parserBuilder() - .setSigningKey(publicKey) - .build() - .parseClaimsJws(identityToken) - .getBody(); + Claims claims; + try { + claims = Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(identityToken) + .getBody(); + } catch (ExpiredJwtException exception) { + throw new BaseException(INVALID_IDENTITY_TOKEN); + } Object emailObj = claims.get("email"); Object providerIdObj = claims.get("sub"); @@ -55,17 +61,17 @@ private OAuthInfo getOAuthInfoByPublicKey(String identityToken, PublicKey public if (providerIdObj == null) { throw new BaseException(NOT_FOUND_PROVIDER_ID); } + + String providerId = providerIdObj.toString(); + + // email 없는 경우 → Apple 재로그인 케이스 (Apple 스펙상 최초 로그인 시에만 email 포함) if (emailObj == null) { - throw new BaseException(NOT_FOUND_EMAIL); + return userRepository.findByProviderId(providerId) + .map(user -> new OAuthInfo(user.getEmail(), providerId)) + .orElseThrow(() -> new BaseException(NOT_FOUND_APPLE_EMAIL_NEW_USER)); } - try { - String email = emailObj.toString(); - String providerId = providerIdObj.toString(); - return new OAuthInfo(email, providerId); - } catch (ExpiredJwtException exception) { - throw new BaseException(INVALID_IDENTITY_TOKEN); - } + return new OAuthInfo(emailObj.toString(), providerId); } private PublicKey generatePublicKey(String identityToken) { diff --git a/src/main/java/ssu/eatssu/domain/partnership/dto/PartnershipInfo.java b/src/main/java/ssu/eatssu/domain/partnership/dto/PartnershipInfo.java index b50fe3e1..3250dee9 100644 --- a/src/main/java/ssu/eatssu/domain/partnership/dto/PartnershipInfo.java +++ b/src/main/java/ssu/eatssu/domain/partnership/dto/PartnershipInfo.java @@ -4,6 +4,7 @@ import lombok.Getter; import ssu.eatssu.domain.partnership.entity.Partnership; import ssu.eatssu.domain.partnership.entity.PartnershipRestaurant; +import ssu.eatssu.domain.partnership.entity.PeriodType; import java.time.LocalDate; @@ -18,6 +19,7 @@ public class PartnershipInfo { private String description; private LocalDate startDate; private LocalDate endDate; + private PeriodType periodType; public static PartnershipInfo fromEntity(Partnership partnership, PartnershipRestaurant restaurant, @@ -27,6 +29,7 @@ public static PartnershipInfo fromEntity(Partnership partnership, .description(partnership.getDescription()) .startDate(partnership.getStartDate()) .endDate(partnership.getEndDate()) + .periodType(partnership.getPeriodType()) .collegeName(partnership.getPartnershipCollege() == null && partnership.getPartnershipDepartment() == null ? "총학생회" : (partnership.getPartnershipCollege() != null ? partnership.getPartnershipCollege() diff --git a/src/main/java/ssu/eatssu/domain/partnership/dto/PartnershipResponse.java b/src/main/java/ssu/eatssu/domain/partnership/dto/PartnershipResponse.java index 1380c807..942d81d5 100644 --- a/src/main/java/ssu/eatssu/domain/partnership/dto/PartnershipResponse.java +++ b/src/main/java/ssu/eatssu/domain/partnership/dto/PartnershipResponse.java @@ -5,6 +5,7 @@ import lombok.Getter; import ssu.eatssu.domain.partnership.entity.PartnershipRestaurant; import ssu.eatssu.domain.partnership.entity.RestaurantType; +import ssu.eatssu.domain.user.entity.Language; import java.util.List; import java.util.stream.Collectors; @@ -19,7 +20,7 @@ public class PartnershipResponse { private RestaurantType restaurantType; private List partnershipInfos; - public static PartnershipResponse fromEntity(PartnershipRestaurant restaurant, Long userId) { + public static PartnershipResponse fromEntity(PartnershipRestaurant restaurant, Long userId, Language language) { boolean isLiked = restaurant.getLikes().stream() .anyMatch(like -> like.getUser().getId().equals(userId)); @@ -30,7 +31,7 @@ public static PartnershipResponse fromEntity(PartnershipRestaurant restaurant, L .collect(Collectors.toList()); return PartnershipResponse.builder() - .storeName(restaurant.getStoreName()) + .storeName(restaurant.getStoreNameByLanguage(language)) .longitude(restaurant.getLongitude()) .latitude(restaurant.getLatitude()) .restaurantType(restaurant.getRestaurantType()) @@ -38,4 +39,3 @@ public static PartnershipResponse fromEntity(PartnershipRestaurant restaurant, L .build(); } } - diff --git a/src/main/java/ssu/eatssu/domain/partnership/entity/Partnership.java b/src/main/java/ssu/eatssu/domain/partnership/entity/Partnership.java index 3f32b6a1..59126956 100644 --- a/src/main/java/ssu/eatssu/domain/partnership/entity/Partnership.java +++ b/src/main/java/ssu/eatssu/domain/partnership/entity/Partnership.java @@ -2,6 +2,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -39,6 +41,12 @@ public class Partnership { @Column(name = "end_date", nullable = false) private LocalDate endDate; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "period_type", nullable = false) + private PeriodType periodType = PeriodType.NORMAL; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "partnership_college_id") private College partnershipCollege; diff --git a/src/main/java/ssu/eatssu/domain/partnership/entity/PartnershipRestaurant.java b/src/main/java/ssu/eatssu/domain/partnership/entity/PartnershipRestaurant.java index 732837e8..20e08134 100644 --- a/src/main/java/ssu/eatssu/domain/partnership/entity/PartnershipRestaurant.java +++ b/src/main/java/ssu/eatssu/domain/partnership/entity/PartnershipRestaurant.java @@ -13,6 +13,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.BatchSize; +import ssu.eatssu.domain.user.entity.Language; +import ssu.eatssu.global.i18n.Localizable; import java.util.ArrayList; import java.util.List; @@ -20,7 +22,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PartnershipRestaurant { +public class PartnershipRestaurant implements Localizable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "partnershipRestaurant_id") @@ -34,12 +36,26 @@ public class PartnershipRestaurant { @Column(name = "latitude", nullable = false) private Double latitude; // 위도 == y축 - @Column(name = "store_name", nullable = false) - private String storeName; + @Column(name = "store_name_ko", nullable = false) + private String storeNameKo; + @Column(name = "store_name_en") + private String storeNameEn; + @Column(name = "store_name_ja") + private String storeNameJa; + @Column(name = "store_name_vi") + private String storeNameVi; @OneToMany(mappedBy = "partnershipRestaurant") @BatchSize(size = 20) private List likes = new ArrayList<>(); @OneToMany(mappedBy = "partnershipRestaurant", cascade = CascadeType.ALL, orphanRemoval = true) private List partnerships = new ArrayList<>(); + + public String getStoreName() { + return storeNameKo; + } + + public String getStoreNameByLanguage(Language language) { + return getLocalizedValue(language, storeNameKo, storeNameEn, storeNameJa, storeNameVi); + } } diff --git a/src/main/java/ssu/eatssu/domain/partnership/entity/PeriodType.java b/src/main/java/ssu/eatssu/domain/partnership/entity/PeriodType.java new file mode 100644 index 00000000..d5125484 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/partnership/entity/PeriodType.java @@ -0,0 +1,6 @@ +package ssu.eatssu.domain.partnership.entity; + +public enum PeriodType { + NORMAL, + FESTIVAL +} diff --git a/src/main/java/ssu/eatssu/domain/partnership/service/PartnershipService.java b/src/main/java/ssu/eatssu/domain/partnership/service/PartnershipService.java index 63e5dd98..1b264b50 100644 --- a/src/main/java/ssu/eatssu/domain/partnership/service/PartnershipService.java +++ b/src/main/java/ssu/eatssu/domain/partnership/service/PartnershipService.java @@ -18,6 +18,7 @@ import ssu.eatssu.domain.user.department.entity.Department; import ssu.eatssu.domain.user.department.persistence.CollegeRepository; import ssu.eatssu.domain.user.department.persistence.DepartmentRepository; +import ssu.eatssu.domain.user.entity.Language; import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.global.handler.response.BaseException; @@ -52,19 +53,22 @@ public void createPartnership(CreatePartnershipRequest request) { NOT_FOUND_PARTNERSHIP_RESTAURANT)); Partnership partnership = request.toPartnershipEntity(partnershipRestaurant); - College college = collegeRepository.findByName(request.getCollege()) + College college = collegeRepository.findByNameKo(request.getCollege()) .orElseThrow(() -> new BaseException(NOT_FOUND_COLLEGE)); partnership.setPartnershipCollege(college); - Department department = departmentRepository.findByName(request.getDepartment()) + Department department = departmentRepository.findByNameKo(request.getDepartment()) .orElseThrow(() -> new BaseException(NOT_FOUND_DEPARTMENT)); partnership.setPartnershipDepartment(department); partnershipRepository.save(partnership); } public List getAllPartnerships(CustomUserDetails customUserDetails) { + Language language = findUserByUserDetails(customUserDetails).getLanguage(); + return partnerShipRestaurantRepository.findAllWithDetails().stream() .map(restaurant -> PartnershipResponse.fromEntity(restaurant, - customUserDetails.getId())) + customUserDetails.getId(), + language)) .collect(Collectors.toList()); } @@ -100,8 +104,7 @@ public void togglePartnershipLike(Long partnershipId, CustomUserDetails userDeta } public List getUserLikedPartnerships(CustomUserDetails customUserDetails) { - User user = userRepository.findById(customUserDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + User user = findUserByUserDetails(customUserDetails); List likes = partnershipLikeRepository.findAllByUserWithDetails(user); @@ -111,14 +114,14 @@ public List getUserLikedPartnerships(CustomUserDetails cust return restaurant.getPartnerships() .stream() .map(partnership -> PartnershipResponse.fromEntity(restaurant, - customUserDetails.getId())); + customUserDetails.getId(), + user.getLanguage())); }).collect(Collectors.toList()); } public List getUserDepartmentPartnerships(CustomUserDetails customUserDetails) { - User user = userRepository.findById(customUserDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + User user = findUserByUserDetails(customUserDetails); Department department = user.getDepartment(); if (department == null) { @@ -130,7 +133,13 @@ public List getUserDepartmentPartnerships(CustomUserDetails .findRestaurantsWithMyPartnerships(college, department) .stream() .map(partnershipRestaurant -> PartnershipResponse.fromEntity(partnershipRestaurant, - customUserDetails.getId())) + customUserDetails.getId(), + user.getLanguage())) .collect(Collectors.toList()); } + + private User findUserByUserDetails(CustomUserDetails userDetails) { + return userRepository.findById(userDetails.getId()) + .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + } } diff --git a/src/main/java/ssu/eatssu/domain/review/dto/CreateMenuReviewRequestV2.java b/src/main/java/ssu/eatssu/domain/review/dto/CreateMenuReviewRequestV2.java index 9041e548..a89149a0 100644 --- a/src/main/java/ssu/eatssu/domain/review/dto/CreateMenuReviewRequestV2.java +++ b/src/main/java/ssu/eatssu/domain/review/dto/CreateMenuReviewRequestV2.java @@ -1,8 +1,10 @@ package ssu.eatssu.domain.review.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Getter; import ssu.eatssu.domain.menu.entity.Menu; @@ -16,10 +18,13 @@ @AllArgsConstructor public class CreateMenuReviewRequestV2 { @Schema(description = "평점", example = "5") + @NotNull @Min(1) @Max(5) private Integer rating; + @NotNull + @Valid private MenuLikeRequest menuLike; @Schema(description = "한줄평", example = "이 메뉴 진짜 맛있어요!") diff --git a/src/main/java/ssu/eatssu/domain/review/dto/MenuLikeRequest.java b/src/main/java/ssu/eatssu/domain/review/dto/MenuLikeRequest.java index b35c19df..1d37f694 100644 --- a/src/main/java/ssu/eatssu/domain/review/dto/MenuLikeRequest.java +++ b/src/main/java/ssu/eatssu/domain/review/dto/MenuLikeRequest.java @@ -1,6 +1,7 @@ package ssu.eatssu.domain.review.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Getter; @@ -9,6 +10,7 @@ @AllArgsConstructor public class MenuLikeRequest { @Schema(description = "메뉴 식별자", example = "123") + @NotNull private Long menuId; @Schema(description = "좋아요 선택", example = "좋아요 : true (기본값은 false)") private Boolean isLike; diff --git a/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java b/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java index f4318608..25671410 100644 --- a/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java +++ b/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java @@ -51,6 +51,7 @@ import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_MENU; import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_REVIEW; import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_USER; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.FAILED_VALIDATION; import static ssu.eatssu.global.handler.response.BaseResponseStatus.REVIEW_PERMISSION_DENIED; @Slf4j @@ -78,12 +79,18 @@ public void createMealReview(CustomUserDetails userDetails, CreateMealReviewRequ Review review = request.toReviewEntity(user, meal); - request.getImageUrls().forEach(review::addReviewImage); + List imageUrls = Optional.ofNullable(request.getImageUrls()).orElse(Collections.emptyList()); + imageUrls.forEach(review::addReviewImage); - for (MenuLikeRequest menuLike : request.getMenuLikes()) { + List menuLikes = Optional.ofNullable(request.getMenuLikes()).orElse(Collections.emptyList()); + for (MenuLikeRequest menuLike : menuLikes) { + if (menuLike == null || menuLike.getMenuId() == null) { + throw new BaseException(FAILED_VALIDATION); + } Menu menu = menuRepository.findById(menuLike.getMenuId()) .orElseThrow(() -> new BaseException(NOT_FOUND_MENU)); - review.addReviewMenuLike(menu, menuLike.getIsLike()); + boolean isLike = Boolean.TRUE.equals(menuLike.getIsLike()); + review.addReviewMenuLike(menu, isLike); } reviewRepository.save(review); @@ -93,8 +100,8 @@ public void createMealReview(CustomUserDetails userDetails, CreateMealReviewRequ review.getId(), meal.getId(), user.getId(), - request.getImageUrls().size(), - request.getMenuLikes().size()) + imageUrls.size(), + menuLikes.size()) )); } @@ -106,23 +113,31 @@ public void createMenuReview(CustomUserDetails userDetails, CreateMenuReviewRequ User user = userRepository.findById(userDetails.getId()) .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); - Menu menu = menuRepository.findById(request.getMenuLike().getMenuId()) + MenuLikeRequest menuLike = request.getMenuLike(); + if (menuLike == null || menuLike.getMenuId() == null) { + throw new BaseException(FAILED_VALIDATION); + } + + Menu menu = menuRepository.findById(menuLike.getMenuId()) .orElseThrow(() -> new BaseException(NOT_FOUND_MENU)); Review review = request.toReviewEntity(user, menu); - review.addReviewMenuLike(menu, request.getMenuLike().getIsLike()); - request.getImageUrls().forEach(review::addReviewImage); + boolean isLike = Boolean.TRUE.equals(menuLike.getIsLike()); + review.addReviewMenuLike(menu, isLike); + + List imageUrls = Optional.ofNullable(request.getImageUrls()).orElse(Collections.emptyList()); + imageUrls.forEach(review::addReviewImage); reviewRepository.save(review); menu.addReview(review); eventPublisher.publishEvent(LogEvent.of( - String.format("MenuReview created: reviewId=%d, menuId=%d, userId=%d, isLike=%s, imageUrl=%s", + String.format("MenuReview created: reviewId=%d, menuId=%d, userId=%d, isLike=%s, images=%d", review.getId(), menu.getId(), user.getId(), - request.getMenuLike().getIsLike(), - request.getImageUrls().size()) + isLike, + imageUrls.size()) )); } @@ -385,12 +400,20 @@ public void updateReview(CustomUserDetails userDetails, Long reviewId, UpdateMea throw new BaseException(REVIEW_PERMISSION_DENIED); } - Map menuLikes = request.getMenuLikes().stream() - .collect(Collectors.toMap( - menuLike -> menuRepository.findById(menuLike.getMenuId()) - .orElseThrow(() -> new BaseException( - NOT_FOUND_MENU)), - MenuLikeRequest::getIsLike)); + List menuLikeRequests = Optional.ofNullable(request.getMenuLikes()).orElse(Collections.emptyList()); + + Map menuLikes = menuLikeRequests.stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap( + menuLike -> { + if (menuLike.getMenuId() == null) { + throw new BaseException(FAILED_VALIDATION); + } + return menuRepository.findById(menuLike.getMenuId()) + .orElseThrow(() -> new BaseException( + NOT_FOUND_MENU)); + }, + menuLike -> Boolean.TRUE.equals(menuLike.getIsLike()))); review.update(request.getContent(), request.getRating(), menuLikes); reviewRepository.save(review); diff --git a/src/main/java/ssu/eatssu/domain/user/department/entity/College.java b/src/main/java/ssu/eatssu/domain/user/department/entity/College.java index b8447a40..1c3d475e 100644 --- a/src/main/java/ssu/eatssu/domain/user/department/entity/College.java +++ b/src/main/java/ssu/eatssu/domain/user/department/entity/College.java @@ -11,6 +11,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import ssu.eatssu.domain.partnership.entity.Partnership; +import ssu.eatssu.domain.user.entity.Language; +import ssu.eatssu.global.i18n.Localizable; import java.util.ArrayList; import java.util.List; @@ -18,7 +20,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class College { +public class College implements Localizable { @OneToMany(mappedBy = "college", cascade = CascadeType.ALL, orphanRemoval = true) private final List departments = new ArrayList<>(); @OneToMany(mappedBy = "partnershipCollege", cascade = CascadeType.ALL, orphanRemoval = true) @@ -27,10 +29,24 @@ public class College { @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "college_id") private Long id; - @Column(nullable = false, unique = true) - private String name; + @Column(name = "name_ko", nullable = false, unique = true) + private String nameKo; + @Column(name = "name_en") + private String nameEn; + @Column(name = "name_ja") + private String nameJa; + @Column(name = "name_vi") + private String nameVi; public College(String name) { - this.name = name; + this.nameKo = name; + } + + public String getName() { + return nameKo; + } + + public String getNameByLanguage(Language language) { + return getLocalizedValue(language, nameKo, nameEn, nameJa, nameVi); } } diff --git a/src/main/java/ssu/eatssu/domain/user/department/entity/Department.java b/src/main/java/ssu/eatssu/domain/user/department/entity/Department.java index 51f02653..125e452f 100644 --- a/src/main/java/ssu/eatssu/domain/user/department/entity/Department.java +++ b/src/main/java/ssu/eatssu/domain/user/department/entity/Department.java @@ -14,6 +14,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import ssu.eatssu.domain.partnership.entity.Partnership; +import ssu.eatssu.domain.user.entity.Language; +import ssu.eatssu.global.i18n.Localizable; import java.util.ArrayList; import java.util.List; @@ -21,20 +23,34 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Department { +public class Department implements Localizable { @OneToMany(mappedBy = "partnershipDepartment", cascade = CascadeType.ALL, orphanRemoval = true) private final List partnerships = new ArrayList<>(); @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "department_id") private Long id; - @Column(nullable = false) - private String name; + @Column(name = "name_ko", nullable = false) + private String nameKo; + @Column(name = "name_en") + private String nameEn; + @Column(name = "name_ja") + private String nameJa; + @Column(name = "name_vi") + private String nameVi; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "college_id") private College college; public Department(String name) { - this.name = name; + this.nameKo = name; + } + + public String getName() { + return nameKo; + } + + public String getNameByLanguage(Language language) { + return getLocalizedValue(language, nameKo, nameEn, nameJa, nameVi); } } diff --git a/src/main/java/ssu/eatssu/domain/user/department/persistence/CollegeRepository.java b/src/main/java/ssu/eatssu/domain/user/department/persistence/CollegeRepository.java index 4e7c6d89..37dcb3bd 100644 --- a/src/main/java/ssu/eatssu/domain/user/department/persistence/CollegeRepository.java +++ b/src/main/java/ssu/eatssu/domain/user/department/persistence/CollegeRepository.java @@ -6,5 +6,5 @@ import java.util.Optional; public interface CollegeRepository extends JpaRepository { - Optional findByName(String name); + Optional findByNameKo(String nameKo); } diff --git a/src/main/java/ssu/eatssu/domain/user/department/persistence/DepartmentRepository.java b/src/main/java/ssu/eatssu/domain/user/department/persistence/DepartmentRepository.java index 50e55f12..b4e66879 100644 --- a/src/main/java/ssu/eatssu/domain/user/department/persistence/DepartmentRepository.java +++ b/src/main/java/ssu/eatssu/domain/user/department/persistence/DepartmentRepository.java @@ -8,7 +8,7 @@ import java.util.Optional; public interface DepartmentRepository extends JpaRepository { - Optional findByName(String name); + Optional findByNameKo(String nameKo); List findByCollege(College college); } diff --git a/src/main/java/ssu/eatssu/domain/user/dto/DepartmentResponse.java b/src/main/java/ssu/eatssu/domain/user/dto/DepartmentResponse.java index 105ad9e3..708a1ba7 100644 --- a/src/main/java/ssu/eatssu/domain/user/dto/DepartmentResponse.java +++ b/src/main/java/ssu/eatssu/domain/user/dto/DepartmentResponse.java @@ -3,22 +3,26 @@ import lombok.Builder; import ssu.eatssu.domain.user.department.entity.College; import ssu.eatssu.domain.user.department.entity.Department; +import ssu.eatssu.domain.user.entity.Language; @Builder public record DepartmentResponse( Long departmentId, String departmentName, Long collegeId, String collegeName ) { public static DepartmentResponse from(Department department) { + return from(department, Language.KO); + } + + public static DepartmentResponse from(Department department, Language language) { if (department == null) { return new DepartmentResponse(null, null, null, null); } final College college = department.getCollege(); return DepartmentResponse.builder() .departmentId(department.getId()) - .departmentName(department.getName()) + .departmentName(department.getNameByLanguage(language)) .collegeId(college != null ? college.getId() : null) - .collegeName(college != null ? college.getName() : null) + .collegeName(college != null ? college.getNameByLanguage(language) : null) .build(); } } - diff --git a/src/main/java/ssu/eatssu/domain/user/dto/LanguageResponse.java b/src/main/java/ssu/eatssu/domain/user/dto/LanguageResponse.java new file mode 100644 index 00000000..5c43b0d0 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/user/dto/LanguageResponse.java @@ -0,0 +1,15 @@ +package ssu.eatssu.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import ssu.eatssu.domain.user.entity.Language; +import ssu.eatssu.domain.user.entity.User; + +@Schema(title = "언어 설정 조회") +public record LanguageResponse( + @Schema(description = "언어 설정", example = "KO") + Language language) { + + public static LanguageResponse from(User user) { + return new LanguageResponse(user.getLanguage()); + } +} diff --git a/src/main/java/ssu/eatssu/domain/user/dto/LanguageUpdateRequest.java b/src/main/java/ssu/eatssu/domain/user/dto/LanguageUpdateRequest.java new file mode 100644 index 00000000..9ab1c1b0 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/user/dto/LanguageUpdateRequest.java @@ -0,0 +1,12 @@ +package ssu.eatssu.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import ssu.eatssu.domain.user.entity.Language; + +@Schema(title = "언어 설정 수정") +public record LanguageUpdateRequest( + @NotNull(message = "언어 설정을 입력해주세요.") + @Schema(description = "언어 설정", example = "EN", allowableValues = {"KO", "EN", "JA", "VI"}) + Language language) { +} diff --git a/src/main/java/ssu/eatssu/domain/user/dto/MyPageResponse.java b/src/main/java/ssu/eatssu/domain/user/dto/MyPageResponse.java index 4a5ba2af..aac3c525 100644 --- a/src/main/java/ssu/eatssu/domain/user/dto/MyPageResponse.java +++ b/src/main/java/ssu/eatssu/domain/user/dto/MyPageResponse.java @@ -7,6 +7,7 @@ import ssu.eatssu.domain.auth.entity.OAuthProvider; import ssu.eatssu.domain.user.department.entity.College; import ssu.eatssu.domain.user.department.entity.Department; +import ssu.eatssu.domain.user.entity.Language; import ssu.eatssu.domain.user.entity.User; @AllArgsConstructor @@ -21,6 +22,9 @@ public class MyPageResponse { @Schema(description = "연결 계정 정보", example = "GOOGLE") private OAuthProvider provider; + @Schema(description = "언어 설정", example = "KO") + private Language language; + @Schema(description = "학과 id", example = "1") private Long departmentId; @@ -44,10 +48,11 @@ public static MyPageResponse from(User user) { return MyPageResponse.builder() .nickname(user.getNickname()) .provider(user.getProvider()) + .language(user.getLanguage()) .departmentId(department != null ? department.getId() : null) - .departmentName(department != null ? department.getName() : null) + .departmentName(department != null ? department.getNameByLanguage(user.getLanguage()) : null) .collegeId(college != null ? college.getId() : null) - .collegeName(college != null ? college.getName() : null) + .collegeName(college != null ? college.getNameByLanguage(user.getLanguage()) : null) .build(); } } diff --git a/src/main/java/ssu/eatssu/domain/user/entity/Language.java b/src/main/java/ssu/eatssu/domain/user/entity/Language.java new file mode 100644 index 00000000..a20985e3 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/user/entity/Language.java @@ -0,0 +1,8 @@ +package ssu.eatssu.domain.user.entity; + +public enum Language { + KO, + EN, + JA, + VI +} diff --git a/src/main/java/ssu/eatssu/domain/user/entity/User.java b/src/main/java/ssu/eatssu/domain/user/entity/User.java index 912d2129..d1025d5f 100644 --- a/src/main/java/ssu/eatssu/domain/user/entity/User.java +++ b/src/main/java/ssu/eatssu/domain/user/entity/User.java @@ -59,6 +59,9 @@ public class User extends BaseTimeEntity { private UserStatus status; @Enumerated(EnumType.STRING) private DeviceType deviceType; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Language language = Language.KO; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "department_id") private Department department; @@ -123,6 +126,10 @@ public void updateDepartment(Department department) { this.department = department; } + public void updateLanguage(@NotNull Language language) { + this.language = language; + } + // 추후에 기종이 바뀌더라도 업데이트합니다. + V1-> V2 마이그레이션을 수행하는 역할을 하기도 합니다. public void updateDeviceType(DeviceType deviceType) { this.deviceType = deviceType; } } diff --git a/src/main/java/ssu/eatssu/domain/user/presentation/UserController.java b/src/main/java/ssu/eatssu/domain/user/presentation/UserController.java index 1b198be2..9b7fe845 100644 --- a/src/main/java/ssu/eatssu/domain/user/presentation/UserController.java +++ b/src/main/java/ssu/eatssu/domain/user/presentation/UserController.java @@ -33,6 +33,8 @@ import ssu.eatssu.domain.user.dto.DepartmentResponse; import ssu.eatssu.domain.user.dto.GetCollegeResponse; import ssu.eatssu.domain.user.dto.GetDepartmentResponse; +import ssu.eatssu.domain.user.dto.LanguageResponse; +import ssu.eatssu.domain.user.dto.LanguageUpdateRequest; import ssu.eatssu.domain.user.dto.MyMealReviewResponse; import ssu.eatssu.domain.user.dto.MyPageResponse; import ssu.eatssu.domain.user.dto.MyReviewDetail; @@ -114,6 +116,31 @@ public BaseResponse updateNickname( return BaseResponse.success(); } + @Operation(summary = "언어 설정 수정", description = "유저의 언어 설정을 수정하는 API 입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "언어 설정 수정 성공"), + @ApiResponse(responseCode = "400", description = "지원하지 않는 언어 또는 누락된 언어 설정", content = @Content(schema = @Schema(implementation = BaseResponse.class))), + @ApiResponse(responseCode = "404", description = "존재하지 않는 유저", content = @Content(schema = @Schema(implementation = BaseResponse.class))) + }) + @PatchMapping("/language") + public BaseResponse updateLanguage( + @Valid @RequestBody LanguageUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + userService.updateLanguage(userDetails, request); + return BaseResponse.success(); + } + + @Operation(summary = "언어 설정 조회", description = "유저의 언어 설정을 조회하는 API 입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "언어 설정 조회 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 유저", content = @Content(schema = @Schema(implementation = BaseResponse.class))) + }) + @GetMapping("/language") + public BaseResponse getLanguage( + @AuthenticationPrincipal CustomUserDetails userDetails) { + return BaseResponse.success(userService.findLanguage(userDetails)); + } + @Operation(summary = "유저 탈퇴", description = "유저 탈퇴 API 입니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "유저 탈퇴 성공"), @@ -229,8 +256,9 @@ public BaseResponse> getMyReviews( @ApiResponse(responseCode = "404", description = "존재하지 않는 단과대", content = @Content(schema = @Schema(implementation = BaseResponse.class))) }) @GetMapping("/lookup/colleges") - public BaseResponse> getColleges() { - List getCollegeResponses = userService.getCollegeList(); + public BaseResponse> getColleges( + @AuthenticationPrincipal CustomUserDetails userDetails) { + List getCollegeResponses = userService.getCollegeList(userDetails); return BaseResponse.success(getCollegeResponses); } @@ -239,8 +267,9 @@ public BaseResponse> getColleges() { @ApiResponse(responseCode = "200", description = "단과대 리스트 조회 성공"), }) @GetMapping("/lookup/departments") - public BaseResponse> getDepartments(@RequestParam Long collegeId) { - List getCollegeResponses = userService.getDepartmentList(collegeId); + public BaseResponse> getDepartments(@RequestParam Long collegeId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + List getCollegeResponses = userService.getDepartmentList(collegeId, userDetails); return BaseResponse.success(getCollegeResponses); } diff --git a/src/main/java/ssu/eatssu/domain/user/service/UserService.java b/src/main/java/ssu/eatssu/domain/user/service/UserService.java index d970b265..60ab183c 100644 --- a/src/main/java/ssu/eatssu/domain/user/service/UserService.java +++ b/src/main/java/ssu/eatssu/domain/user/service/UserService.java @@ -1,11 +1,10 @@ package ssu.eatssu.domain.user.service; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.annotation.Transactional; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; import ssu.eatssu.domain.auth.entity.OAuthProvider; import ssu.eatssu.domain.auth.security.CustomUserDetails; @@ -18,10 +17,13 @@ import ssu.eatssu.domain.user.dto.DepartmentResponse; import ssu.eatssu.domain.user.dto.GetCollegeResponse; import ssu.eatssu.domain.user.dto.GetDepartmentResponse; +import ssu.eatssu.domain.user.dto.LanguageResponse; +import ssu.eatssu.domain.user.dto.LanguageUpdateRequest; import ssu.eatssu.domain.user.dto.MyPageResponse; import ssu.eatssu.domain.user.dto.NicknameUpdateRequest; import ssu.eatssu.domain.user.dto.UpdateDepartmentRequest; import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.domain.user.entity.Language; import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.domain.user.util.NicknameValidator; @@ -39,7 +41,6 @@ @Service @RequiredArgsConstructor @Transactional -@Component public class UserService { private final UserRepository userRepository; @@ -65,8 +66,7 @@ public User joinV2(String email, OAuthProvider provider, String providerId, Devi } public void updateNickname(CustomUserDetails userDetails, NicknameUpdateRequest request) { - User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + User user = findUserByUserDetails(userDetails); nicknameValidator.validateNickname(request.nickname()); @@ -84,14 +84,28 @@ public void updateNickname(CustomUserDetails userDetails, NicknameUpdateRequest } public MyPageResponse findMyPage(CustomUserDetails userDetails) { - User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + User user = findUserByUserDetails(userDetails); return MyPageResponse.from(user); } + public void updateLanguage(CustomUserDetails userDetails, LanguageUpdateRequest request) { + User user = findUserByUserDetails(userDetails); + + user.updateLanguage(request.language()); + + eventPublisher.publishEvent(LogEvent.of( + String.format("User language updated: userId=%d, language=%s", + user.getId(), request.language())) + ); + } + + public LanguageResponse findLanguage(CustomUserDetails userDetails) { + User user = findUserByUserDetails(userDetails); + return LanguageResponse.from(user); + } + public boolean withdraw(CustomUserDetails userDetails) { - User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + User user = findUserByUserDetails(userDetails); user.getReviews().forEach(Review::clearUser); user.getUserInquiries().forEach(inquiry -> inquiry.clearUser()); @@ -122,8 +136,7 @@ private String createCredentials(OAuthProvider provider, String providerId) { @Transactional public void registerDepartment(UpdateDepartmentRequest request, CustomUserDetails userDetails) { - User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + User user = findUserByUserDetails(userDetails); Department department = departmentRepository.findById(request.getDepartmentId()) .orElseThrow(() -> new BaseException(NOT_FOUND_DEPARTMENT)); @@ -131,28 +144,41 @@ public void registerDepartment(UpdateDepartmentRequest request, CustomUserDetail } public DepartmentResponse getDepartment(CustomUserDetails userDetails) { - User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); - return DepartmentResponse.from(user.getDepartment()); + User user = findUserByUserDetails(userDetails); + return DepartmentResponse.from(user.getDepartment(), user.getLanguage()); + } + + private User findUserByUserDetails(CustomUserDetails userDetails) { + return userRepository.findById(userDetails.getId()) + .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); } - public List getCollegeList() { + public List getCollegeList(CustomUserDetails userDetails) { + Language language = findLanguageOrDefault(userDetails); List colleges = collegeRepository.findAll(); return colleges.stream().map(college -> GetCollegeResponse.builder() .id(college.getId()) - .name(college.getName()) + .name(college.getNameByLanguage(language)) .build()) .toList(); } - public List getDepartmentList(Long collegeId) { + public List getDepartmentList(Long collegeId, CustomUserDetails userDetails) { + Language language = findLanguageOrDefault(userDetails); College college = collegeRepository.findById(collegeId) .orElseThrow(() -> new BaseException(VALIDATION_ERROR)); List departments = departmentRepository.findByCollege(college); return departments.stream().map(department -> GetDepartmentResponse.builder() .id(department.getId()) - .name(department.getName()) + .name(department.getNameByLanguage(language)) .build()) .toList(); } + + private Language findLanguageOrDefault(CustomUserDetails userDetails) { + if (userDetails == null) { + return Language.KO; + } + return findUserByUserDetails(userDetails).getLanguage(); + } } diff --git a/src/main/java/ssu/eatssu/global/handler/response/BaseResponseStatus.java b/src/main/java/ssu/eatssu/global/handler/response/BaseResponseStatus.java index 4189568e..dd264ff5 100644 --- a/src/main/java/ssu/eatssu/global/handler/response/BaseResponseStatus.java +++ b/src/main/java/ssu/eatssu/global/handler/response/BaseResponseStatus.java @@ -77,6 +77,7 @@ public enum BaseResponseStatus { INVALID_NICKNAME(false, HttpStatus.NOT_FOUND, 40412, "잘못된 닉네임입니다."), NOT_FOUND_PROVIDER_ID(false, HttpStatus.NOT_FOUND, 40413, "Claims에서 ProviderId(sub)를 찾을 수 없습니다."), NOT_FOUND_EMAIL(false, HttpStatus.NOT_FOUND, 40414, "Claims에서 이메일을 찾을 수 없습니다."), + NOT_FOUND_APPLE_EMAIL_NEW_USER(false, HttpStatus.NOT_FOUND, 40415, "신규 Apple 유저인데 email claim이 없습니다."), /** * 405 METHOD_NOT_ALLOWED 지원하지 않은 method 호출 diff --git a/src/main/java/ssu/eatssu/global/i18n/Localizable.java b/src/main/java/ssu/eatssu/global/i18n/Localizable.java new file mode 100644 index 00000000..7c8068be --- /dev/null +++ b/src/main/java/ssu/eatssu/global/i18n/Localizable.java @@ -0,0 +1,19 @@ +package ssu.eatssu.global.i18n; + +import ssu.eatssu.domain.user.entity.Language; + +public interface Localizable { + + default String getLocalizedValue(Language language, String ko, String en, String ja, String vi) { + if (language == null) { + return ko; + } + + return switch (language) { + case EN -> en != null ? en : ko; + case JA -> ja != null ? ja : ko; + case VI -> vi != null ? vi : ko; + case KO -> ko; + }; + } +} diff --git a/src/main/resources/db/migration/V5__add_period_type_to_partnership.sql b/src/main/resources/db/migration/V5__add_period_type_to_partnership.sql new file mode 100644 index 00000000..b7fd9277 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_period_type_to_partnership.sql @@ -0,0 +1,6 @@ +-- ========================= +-- V5: partnership 기간 타입 추가 +-- ========================= + +ALTER TABLE partnership + ADD COLUMN period_type ENUM ('NORMAL', 'FESTIVAL') NOT NULL DEFAULT 'NORMAL'; diff --git a/src/main/resources/db/migration/V6__add_language_to_user.sql b/src/main/resources/db/migration/V6__add_language_to_user.sql new file mode 100644 index 00000000..450ddd88 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_language_to_user.sql @@ -0,0 +1,2 @@ +ALTER TABLE user + ADD COLUMN language ENUM ('KO', 'EN', 'JA', 'VI') NOT NULL DEFAULT 'KO'; diff --git a/src/main/resources/db/migration/V7__add_i18n_columns_to_college.sql b/src/main/resources/db/migration/V7__add_i18n_columns_to_college.sql new file mode 100644 index 00000000..602aa14c --- /dev/null +++ b/src/main/resources/db/migration/V7__add_i18n_columns_to_college.sql @@ -0,0 +1,5 @@ +ALTER TABLE college + RENAME COLUMN name TO name_ko, + ADD COLUMN name_en VARCHAR(255) NULL, + ADD COLUMN name_ja VARCHAR(255) NULL, + ADD COLUMN name_vi VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V8__add_i18n_columns_to_department.sql b/src/main/resources/db/migration/V8__add_i18n_columns_to_department.sql new file mode 100644 index 00000000..79385f3e --- /dev/null +++ b/src/main/resources/db/migration/V8__add_i18n_columns_to_department.sql @@ -0,0 +1,5 @@ +ALTER TABLE department + RENAME COLUMN name TO name_ko, + ADD COLUMN name_en VARCHAR(255) NULL, + ADD COLUMN name_ja VARCHAR(255) NULL, + ADD COLUMN name_vi VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V9__add_i18n_columns_to_partnership_restaurant.sql b/src/main/resources/db/migration/V9__add_i18n_columns_to_partnership_restaurant.sql new file mode 100644 index 00000000..5583cfcc --- /dev/null +++ b/src/main/resources/db/migration/V9__add_i18n_columns_to_partnership_restaurant.sql @@ -0,0 +1,5 @@ +ALTER TABLE partnership_restaurant + RENAME COLUMN store_name TO store_name_ko, + ADD COLUMN store_name_en VARCHAR(255) NULL, + ADD COLUMN store_name_ja VARCHAR(255) NULL, + ADD COLUMN store_name_vi VARCHAR(255) NULL; diff --git a/src/test/java/ssu/eatssu/domain/user/dto/MyPageResponseTest.java b/src/test/java/ssu/eatssu/domain/user/dto/MyPageResponseTest.java new file mode 100644 index 00000000..987636b1 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/user/dto/MyPageResponseTest.java @@ -0,0 +1,37 @@ +package ssu.eatssu.domain.user.dto; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import ssu.eatssu.domain.auth.entity.OAuthProvider; +import ssu.eatssu.domain.user.department.entity.College; +import ssu.eatssu.domain.user.department.entity.Department; +import ssu.eatssu.domain.user.entity.Language; +import ssu.eatssu.domain.user.entity.User; + +import static org.assertj.core.api.Assertions.assertThat; + +class MyPageResponseTest { + + @Test + void shouldReturnLocalizedNamesBasedOnUserLanguage() { + // given + College college = new College("IT 대학"); + ReflectionTestUtils.setField(college, "nameEn", "College of IT"); + + Department department = new Department("컴퓨터학부"); + ReflectionTestUtils.setField(department, "nameEn", "School of Computer Science"); + ReflectionTestUtils.setField(department, "college", college); + + User user = User.create("test@test.com", "tester", OAuthProvider.EATSSU, "1234", "credentials"); + user.updateLanguage(Language.EN); + user.updateDepartment(department); + + // when + MyPageResponse response = MyPageResponse.from(user); + + // then + assertThat(response.getLanguage()).isEqualTo(Language.EN); + assertThat(response.getDepartmentName()).isEqualTo("School of Computer Science"); + assertThat(response.getCollegeName()).isEqualTo("College of IT"); + } +}