From d538e89d5baf1dbad019e56c6d16e021529d901c Mon Sep 17 00:00:00 2001 From: youbeen Date: Thu, 12 Jun 2025 14:56:33 +0900 Subject: [PATCH] store update --- .../biz/usecase/out/StoreRepositoryPort.java | 138 ++++++++++++++ .../biz/usecase/out/StoreSearchCriteria.java | 168 +++++++++++++++++ .../java/com/ktds/hi/store/domain/Menu.java | 173 ++++++++++++++++- .../java/com/ktds/hi/store/domain/Store.java | 2 +- .../infra/gateway/StoreRepositoryAdapter.java | 138 ++++++++++++-- .../infra/gateway/entity/StoreEntity.java | 177 ++++++++++++++++++ .../repository/StoreJpaRepository.java | 135 +++++++++++++ 7 files changed, 912 insertions(+), 19 deletions(-) create mode 100644 store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreRepositoryPort.java create mode 100644 store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreSearchCriteria.java create mode 100644 store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java create mode 100644 store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreJpaRepository.java diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreRepositoryPort.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreRepositoryPort.java new file mode 100644 index 0000000..318a12d --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreRepositoryPort.java @@ -0,0 +1,138 @@ +package com.ktds.hi.store.biz.usecase.out; + +import com.ktds.hi.store.domain.Store; + +import java.util.List; +import java.util.Optional; + +/** + * 매장 리포지토리 포트 인터페이스 + * 매장 데이터 영속성 기능을 정의 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +public interface StoreRepositoryPort { + + /** + * 점주 ID로 매장 목록 조회 + * + * @param ownerId 점주 ID + * @return 매장 목록 + */ + List findStoresByOwnerId(Long ownerId); + + /** + * 매장 ID로 매장 조회 + * + * @param storeId 매장 ID + * @return 매장 정보 (Optional) + */ + Optional findStoreById(Long storeId); + + /** + * 매장 ID와 점주 ID로 매장 조회 + * + * @param storeId 매장 ID + * @param ownerId 점주 ID + * @return 매장 정보 (Optional) + */ + Optional findStoreByIdAndOwnerId(Long storeId, Long ownerId); + + /** + * 매장 저장 + * + * @param store 저장할 매장 정보 + * @return 저장된 매장 정보 + */ + Store saveStore(Store store); + + /** + * 매장 삭제 + * + * @param storeId 삭제할 매장 ID + */ + void deleteStore(Long storeId); + + /** + * 매장 검색 + * + * @param searchCriteria 검색 조건 + * @return 검색된 매장 목록 + */ + List searchStores(StoreSearchCriteria searchCriteria); + + /** + * 카테고리별 매장 목록 조회 + * + * @param category 카테고리 + * @return 카테고리별 매장 목록 + */ + List findStoresByCategory(String category); + + /** + * 위치 기반 매장 검색 (반경 내) + * + * @param latitude 위도 + * @param longitude 경도 + * @param radiusKm 반경 (킬로미터) + * @return 반경 내 매장 목록 + */ + List findStoresWithinRadius(Double latitude, Double longitude, Double radiusKm); + + /** + * 매장명 또는 주소로 검색 + * + * @param keyword 검색 키워드 + * @return 검색된 매장 목록 + */ + List findStoresByKeyword(String keyword); + + /** + * 활성 상태의 매장 목록 조회 + * + * @return 활성 매장 목록 + */ + List findActiveStores(); + + /** + * 평점 기준 상위 매장 조회 + * + * @param limit 조회할 매장 수 + * @return 상위 평점 매장 목록 + */ + List findTopRatedStores(Integer limit); + + /** + * 매장 존재 여부 확인 + * + * @param storeId 매장 ID + * @return 존재 여부 + */ + boolean existsById(Long storeId); + + /** + * 점주의 매장 수 조회 + * + * @param ownerId 점주 ID + * @return 매장 수 + */ + Long countStoresByOwnerId(Long ownerId); + + /** + * 매장 상태 업데이트 + * + * @param storeId 매장 ID + * @param status 새로운 상태 + * @return 업데이트된 매장 정보 + */ + Store updateStoreStatus(Long storeId, String status); + + /** + * 여러 매장 일괄 저장 + * + * @param stores 저장할 매장 목록 + * @return 저장된 매장 목록 + */ + List saveStores(List stores); +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreSearchCriteria.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreSearchCriteria.java new file mode 100644 index 0000000..2971fa7 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreSearchCriteria.java @@ -0,0 +1,168 @@ +package com.ktds.hi.store.biz.usecase.out; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 매장 검색 조건 클래스 + * 매장 검색 시 사용되는 필터 조건들을 정의 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoreSearchCriteria { + + /** + * 검색 키워드 (매장명, 주소) + */ + private String keyword; + + /** + * 카테고리 필터 + */ + private String category; + + /** + * 태그 필터 목록 + */ + private List tags; + + /** + * 검색 중심 위도 + */ + private Double latitude; + + /** + * 검색 중심 경도 + */ + private Double longitude; + + /** + * 검색 반경 (킬로미터) + */ + private Double radiusKm; + + /** + * 최소 평점 + */ + private Double minRating; + + /** + * 최대 평점 + */ + private Double maxRating; + + /** + * 매장 상태 필터 + */ + private String status; + + /** + * 정렬 기준 (rating, distance, reviewCount 등) + */ + private String sortBy; + + /** + * 정렬 방향 (ASC, DESC) + */ + private String sortDirection; + + /** + * 페이지 번호 (0부터 시작) + */ + private Integer page; + + /** + * 페이지 크기 + */ + private Integer size; + + /** + * 검색 조건 유효성 검증 + */ + public boolean isValid() { + // 위치 기반 검색인 경우 위도, 경도, 반경이 모두 있어야 함 + if (latitude != null || longitude != null || radiusKm != null) { + return latitude != null && longitude != null && radiusKm != null && + latitude >= -90 && latitude <= 90 && + longitude >= -180 && longitude <= 180 && + radiusKm > 0; + } + + // 키워드나 카테고리 중 하나는 있어야 함 + return (keyword != null && !keyword.trim().isEmpty()) || + (category != null && !category.trim().isEmpty()) || + (tags != null && !tags.isEmpty()); + } + + /** + * 위치 기반 검색 여부 확인 + */ + public boolean hasLocationFilter() { + return latitude != null && longitude != null && radiusKm != null; + } + + /** + * 키워드 검색 여부 확인 + */ + public boolean hasKeywordFilter() { + return keyword != null && !keyword.trim().isEmpty(); + } + + /** + * 카테고리 필터 여부 확인 + */ + public boolean hasCategoryFilter() { + return category != null && !category.trim().isEmpty(); + } + + /** + * 태그 필터 여부 확인 + */ + public boolean hasTagFilter() { + return tags != null && !tags.isEmpty(); + } + + /** + * 평점 필터 여부 확인 + */ + public boolean hasRatingFilter() { + return minRating != null || maxRating != null; + } + + /** + * 정렬 조건 여부 확인 + */ + public boolean hasSortFilter() { + return sortBy != null && !sortBy.trim().isEmpty(); + } + + /** + * 기본 페이징 설정 적용 + */ + public StoreSearchCriteria withDefaultPaging() { + return StoreSearchCriteria.builder() + .keyword(this.keyword) + .category(this.category) + .tags(this.tags) + .latitude(this.latitude) + .longitude(this.longitude) + .radiusKm(this.radiusKm) + .minRating(this.minRating) + .maxRating(this.maxRating) + .status(this.status) + .sortBy(this.sortBy != null ? this.sortBy : "rating") + .sortDirection(this.sortDirection != null ? this.sortDirection : "DESC") + .page(this.page != null ? this.page : 0) + .size(this.size != null ? this.size : 20) + .build(); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/domain/Menu.java b/store/src/main/java/com/ktds/hi/store/domain/Menu.java index a3ac246..691f6b4 100644 --- a/store/src/main/java/com/ktds/hi/store/domain/Menu.java +++ b/store/src/main/java/com/ktds/hi/store/domain/Menu.java @@ -5,10 +5,14 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.math.BigDecimal; +import java.time.LocalDateTime; /** - * 메뉴 도메인 엔티티 + * 메뉴 도메인 클래스 + * 메뉴 정보를 담는 도메인 객체 + * + * @author 하이오더 개발팀 + * @version 1.0.0 */ @Getter @Builder @@ -18,10 +22,169 @@ public class Menu { private Long id; private Long storeId; - private String name; + private String menuName; private String description; - private BigDecimal price; + private Integer price; private String category; private String imageUrl; private Boolean isAvailable; -} + private Integer orderCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** + * 메뉴 기본 정보 업데이트 + */ + public Menu updateInfo(String menuName, String description, Integer price) { + return Menu.builder() + .id(this.id) + .storeId(this.storeId) + .menuName(menuName) + .description(description) + .price(price) + .category(this.category) + .imageUrl(this.imageUrl) + .isAvailable(this.isAvailable) + .orderCount(this.orderCount) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 메뉴 이미지 업데이트 + */ + public Menu updateImage(String imageUrl) { + return Menu.builder() + .id(this.id) + .storeId(this.storeId) + .menuName(this.menuName) + .description(this.description) + .price(this.price) + .category(this.category) + .imageUrl(imageUrl) + .isAvailable(this.isAvailable) + .orderCount(this.orderCount) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 메뉴 판매 가능 상태 설정 + */ + public Menu setAvailable(Boolean available) { + return Menu.builder() + .id(this.id) + .storeId(this.storeId) + .menuName(this.menuName) + .description(this.description) + .price(this.price) + .category(this.category) + .imageUrl(this.imageUrl) + .isAvailable(available) + .orderCount(this.orderCount) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 주문 수 증가 + */ + public Menu incrementOrderCount() { + return Menu.builder() + .id(this.id) + .storeId(this.storeId) + .menuName(this.menuName) + .description(this.description) + .price(this.price) + .category(this.category) + .imageUrl(this.imageUrl) + .isAvailable(this.isAvailable) + .orderCount(this.orderCount != null ? this.orderCount + 1 : 1) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 메뉴 판매 가능 여부 확인 + */ + public Boolean isAvailable() { + return this.isAvailable != null && this.isAvailable; + } + + /** + * 특정 매장에 속하는지 확인 + */ + public boolean belongsToStore(Long storeId) { + return this.storeId != null && this.storeId.equals(storeId); + } + + /** + * 메뉴가 유효한지 확인 + */ + public boolean isValid() { + return this.menuName != null && !this.menuName.trim().isEmpty() && + this.price != null && this.price > 0 && + this.storeId != null; + } + + /** + * 메뉴 카테고리 업데이트 + */ + public Menu updateCategory(String category) { + return Menu.builder() + .id(this.id) + .storeId(this.storeId) + .menuName(this.menuName) + .description(this.description) + .price(this.price) + .category(category) + .imageUrl(this.imageUrl) + .isAvailable(this.isAvailable) + .orderCount(this.orderCount) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 메뉴 가격 할인 적용 + */ + public Menu applyDiscount(double discountRate) { + if (discountRate < 0 || discountRate > 1) { + throw new IllegalArgumentException("할인율은 0~1 사이의 값이어야 합니다."); + } + + int discountedPrice = (int) (this.price * (1 - discountRate)); + + return Menu.builder() + .id(this.id) + .storeId(this.storeId) + .menuName(this.menuName) + .description(this.description) + .price(discountedPrice) + .category(this.category) + .imageUrl(this.imageUrl) + .isAvailable(this.isAvailable) + .orderCount(this.orderCount) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 메뉴 정보가 변경되었는지 확인 + */ + public boolean hasChanges(Menu other) { + if (other == null) return true; + + return !this.menuName.equals(other.menuName) || + !this.description.equals(other.description) || + !this.price.equals(other.price) || + !this.category.equals(other.category) || + !this.isAvailable.equals(other.isAvailable); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/domain/Store.java b/store/src/main/java/com/ktds/hi/store/domain/Store.java index a7fbfd8..cee1a87 100644 --- a/store/src/main/java/com/ktds/hi/store/domain/Store.java +++ b/store/src/main/java/com/ktds/hi/store/domain/Store.java @@ -1,4 +1,4 @@ -package com.ktds.hi.store.biz.domain; +package com.ktds.hi.store.domain; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java index e3bab1f..07939b0 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java @@ -1,10 +1,15 @@ package com.ktds.hi.store.infra.gateway; -import com.ktds.hi.store.biz.domain.Store; +import com.ktds.hi.store.domain.Store; +import com.ktds.hi.store.domain.StoreStatus; import com.ktds.hi.store.biz.usecase.out.StoreRepositoryPort; +import com.ktds.hi.store.biz.usecase.out.StoreSearchCriteria; import com.ktds.hi.store.infra.gateway.entity.StoreEntity; import com.ktds.hi.store.infra.gateway.repository.StoreJpaRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import java.util.Arrays; @@ -15,13 +20,16 @@ import java.util.stream.Collectors; /** * 매장 리포지토리 어댑터 클래스 * Store Repository Port를 구현하여 데이터 영속성 기능을 제공 + * + * @author 하이오더 개발팀 + * @version 1.0.0 */ @Component @RequiredArgsConstructor public class StoreRepositoryAdapter implements StoreRepositoryPort { - + private final StoreJpaRepository storeJpaRepository; - + @Override public List findStoresByOwnerId(Long ownerId) { return storeJpaRepository.findByOwnerId(ownerId) @@ -29,31 +37,135 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort { .map(this::toDomain) .collect(Collectors.toList()); } - + @Override public Optional findStoreById(Long storeId) { return storeJpaRepository.findById(storeId) .map(this::toDomain); } - + @Override public Optional findStoreByIdAndOwnerId(Long storeId, Long ownerId) { return storeJpaRepository.findByIdAndOwnerId(storeId, ownerId) .map(this::toDomain); } - + @Override public Store saveStore(Store store) { StoreEntity entity = toEntity(store); StoreEntity saved = storeJpaRepository.save(entity); return toDomain(saved); } - + @Override public void deleteStore(Long storeId) { storeJpaRepository.deleteById(storeId); } - + + @Override + public List searchStores(StoreSearchCriteria searchCriteria) { + // 복잡한 검색 로직을 단순화 - 실제로는 QueryDSL이나 Criteria API 사용 권장 + List entities; + + if (searchCriteria.hasKeywordFilter()) { + entities = storeJpaRepository.findByStoreNameContainingOrAddressContaining( + searchCriteria.getKeyword(), searchCriteria.getKeyword()); + } else if (searchCriteria.hasCategoryFilter()) { + entities = storeJpaRepository.findByCategory(searchCriteria.getCategory()); + } else { + entities = storeJpaRepository.findAll(); + } + + return entities.stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findStoresByCategory(String category) { + return storeJpaRepository.findByCategory(category) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findStoresWithinRadius(Double latitude, Double longitude, Double radiusKm) { + // 실제로는 공간 데이터베이스 쿼리 사용 (PostGIS 등) + // 여기서는 단순한 구현으로 대체 + return storeJpaRepository.findAll() + .stream() + .map(this::toDomain) + .filter(store -> { + if (store.getLatitude() == null || store.getLongitude() == null) { + return false; + } + Double distance = store.calculateDistance(latitude, longitude); + return distance != null && distance <= radiusKm; + }) + .collect(Collectors.toList()); + } + + @Override + public List findStoresByKeyword(String keyword) { + return storeJpaRepository.findByStoreNameContainingOrAddressContaining(keyword, keyword) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findActiveStores() { + return storeJpaRepository.findByStatus(StoreStatus.ACTIVE.name()) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findTopRatedStores(Integer limit) { + Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "rating")); + return storeJpaRepository.findAllByOrderByRatingDesc(pageable) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public boolean existsById(Long storeId) { + return storeJpaRepository.existsById(storeId); + } + + @Override + public Long countStoresByOwnerId(Long ownerId) { + return storeJpaRepository.countByOwnerId(ownerId); + } + + @Override + public Store updateStoreStatus(Long storeId, String status) { + Optional entityOpt = storeJpaRepository.findById(storeId); + if (entityOpt.isPresent()) { + StoreEntity entity = entityOpt.get(); + entity.updateStatus(status); + StoreEntity saved = storeJpaRepository.save(entity); + return toDomain(saved); + } + return null; + } + + @Override + public List saveStores(List stores) { + List entities = stores.stream() + .map(this::toEntity) + .collect(Collectors.toList()); + + List savedEntities = storeJpaRepository.saveAll(entities); + + return savedEntities.stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + /** * Entity를 Domain으로 변환 */ @@ -70,7 +182,7 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort { .phone(entity.getPhone()) .operatingHours(entity.getOperatingHours()) .tags(entity.getTagsJson() != null ? parseTagsJson(entity.getTagsJson()) : List.of()) - .status(entity.getStatus()) + .status(StoreStatus.fromString(entity.getStatus())) .rating(entity.getRating()) .reviewCount(entity.getReviewCount()) .imageUrl(entity.getImageUrl()) @@ -78,7 +190,7 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort { .updatedAt(entity.getUpdatedAt()) .build(); } - + /** * Domain을 Entity로 변환 */ @@ -95,7 +207,7 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort { .phone(domain.getPhone()) .operatingHours(domain.getOperatingHours()) .tagsJson(domain.getTags() != null ? String.join(",", domain.getTags()) : "") - .status(domain.getStatus()) + .status(domain.getStatus() != null ? domain.getStatus().name() : StoreStatus.INACTIVE.name()) .rating(domain.getRating()) .reviewCount(domain.getReviewCount()) .imageUrl(domain.getImageUrl()) @@ -103,7 +215,7 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort { .updatedAt(domain.getUpdatedAt()) .build(); } - + /** * JSON 태그를 List로 파싱 */ @@ -113,4 +225,4 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort { } return Arrays.asList(tagsJson.split(",")); } -} +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java new file mode 100644 index 0000000..df72c01 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java @@ -0,0 +1,177 @@ +package com.ktds.hi.store.infra.gateway.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 매장 엔티티 클래스 + * 데이터베이스 stores 테이블과 매핑되는 JPA 엔티티 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Entity +@Table(name = "stores") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class StoreEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "owner_id", nullable = false) + private Long ownerId; + + @Column(name = "store_name", nullable = false, length = 100) + private String storeName; + + @Column(nullable = false, length = 300) + private String address; + + @Column(precision = 10, scale = 8) + private Double latitude; + + @Column(precision = 11, scale = 8) + private Double longitude; + + @Column(length = 500) + private String description; + + @Column(length = 20) + private String phone; + + @Column(name = "operating_hours", length = 200) + private String operatingHours; + + @Column(length = 50) + private String category; + + @Column(name = "tags_json", columnDefinition = "TEXT") + private String tagsJson; + + @Column(length = 20) + @Builder.Default + private String status = "INACTIVE"; + + @Column(precision = 3, scale = 2) + @Builder.Default + private Double rating = 0.0; + + @Column(name = "review_count") + @Builder.Default + private Integer reviewCount = 0; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 매장 상태 업데이트 + */ + public void updateStatus(String status) { + this.status = status; + } + + /** + * 매장 기본 정보 업데이트 + */ + public void updateBasicInfo(String storeName, String address, String description, + String phone, String operatingHours) { + this.storeName = storeName; + this.address = address; + this.description = description; + this.phone = phone; + this.operatingHours = operatingHours; + } + + /** + * 매장 위치 정보 업데이트 + */ + public void updateLocation(Double latitude, Double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + + /** + * 매장 평점 및 리뷰 수 업데이트 + */ + public void updateRating(Double rating, Integer reviewCount) { + this.rating = rating; + this.reviewCount = reviewCount; + } + + /** + * 매장 태그 업데이트 + */ + public void updateTags(String tagsJson) { + this.tagsJson = tagsJson; + } + + /** + * 매장 카테고리 업데이트 + */ + public void updateCategory(String category) { + this.category = category; + } + + /** + * 매장 이미지 URL 업데이트 + */ + public void updateImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + /** + * 매장이 활성 상태인지 확인 + */ + public boolean isActive() { + return "ACTIVE".equals(this.status); + } + + /** + * 특정 점주의 매장인지 확인 + */ + public boolean isOwnedBy(Long ownerId) { + return this.ownerId != null && this.ownerId.equals(ownerId); + } + + /** + * 매장 위치 정보가 있는지 확인 + */ + public boolean hasLocationInfo() { + return this.latitude != null && this.longitude != null; + } + + /** + * 매장 평점이 설정되어 있는지 확인 + */ + public boolean hasRating() { + return this.rating != null && this.rating > 0; + } + + /** + * 매장에 리뷰가 있는지 확인 + */ + public boolean hasReviews() { + return this.reviewCount != null && this.reviewCount > 0; + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreJpaRepository.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreJpaRepository.java new file mode 100644 index 0000000..54834d9 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreJpaRepository.java @@ -0,0 +1,135 @@ +package com.ktds.hi.store.infra.gateway.repository; + +import com.ktds.hi.store.infra.gateway.entity.StoreEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 매장 JPA 리포지토리 인터페이스 + * 매장 데이터의 CRUD 작업을 담당 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Repository +public interface StoreJpaRepository extends JpaRepository { + + /** + * 점주 ID로 매장 목록 조회 + */ + List findByOwnerId(Long ownerId); + + /** + * 매장 ID와 점주 ID로 매장 조회 + */ + Optional findByIdAndOwnerId(Long id, Long ownerId); + + /** + * 매장명 또는 주소로 검색 + */ + List findByStoreNameContainingOrAddressContaining(String storeName, String address); + + /** + * 카테고리로 매장 조회 + */ + List findByCategory(String category); + + /** + * 상태로 매장 조회 + */ + List findByStatus(String status); + + /** + * 평점 기준 내림차순으로 매장 조회 + */ + @Query("SELECT s FROM StoreEntity s ORDER BY s.rating DESC") + Page findAllByOrderByRatingDesc(Pageable pageable); + + /** + * 점주별 매장 수 조회 + */ + Long countByOwnerId(Long ownerId); + + /** + * 활성 상태 매장 조회 + */ + List findByStatusAndRatingGreaterThanEqual(String status, Double minRating); + + /** + * 카테고리와 상태로 매장 조회 (평점 내림차순) + */ + List findByCategoryAndStatusOrderByRatingDesc(String category, String status); + + /** + * 위치 기반 매장 검색 (네이티브 쿼리 - PostGIS 사용 시) + */ + @Query(value = "SELECT * FROM stores s WHERE " + + "ST_DWithin(ST_Point(s.longitude, s.latitude)::geography, " + + "ST_Point(:longitude, :latitude)::geography, :radiusMeters) " + + "AND s.status = 'ACTIVE'", nativeQuery = true) + List findStoresWithinRadius(@Param("latitude") Double latitude, + @Param("longitude") Double longitude, + @Param("radiusMeters") Double radiusMeters); + + /** + * 키워드로 매장 검색 (매장명, 주소, 설명 포함) + */ + @Query("SELECT s FROM StoreEntity s WHERE " + + "s.storeName LIKE %:keyword% OR " + + "s.address LIKE %:keyword% OR " + + "s.description LIKE %:keyword%") + List findByKeyword(@Param("keyword") String keyword); + + /** + * 평점 범위로 매장 조회 + */ + List findByRatingBetweenAndStatus(Double minRating, Double maxRating, String status); + + /** + * 리뷰 수 기준으로 인기 매장 조회 + */ + @Query("SELECT s FROM StoreEntity s WHERE s.status = 'ACTIVE' ORDER BY s.reviewCount DESC") + Page findPopularStores(Pageable pageable); + + /** + * 최근 생성된 매장 조회 + */ + @Query("SELECT s FROM StoreEntity s WHERE s.status = 'ACTIVE' ORDER BY s.createdAt DESC") + Page findRecentStores(Pageable pageable); + + /** + * 특정 지역(주소 포함) 매장 조회 + */ + List findByAddressContainingAndStatus(String addressKeyword, String status); + + /** + * 점주별 활성 매장 조회 + */ + List findByOwnerIdAndStatus(Long ownerId, String status); + + /** + * 매장 존재 여부 확인 (점주 ID와 매장명으로) + */ + boolean existsByOwnerIdAndStoreName(Long ownerId, String storeName); + + /** + * 복합 검색 쿼리 + */ + @Query("SELECT s FROM StoreEntity s WHERE " + + "(:category IS NULL OR s.category = :category) AND " + + "(:status IS NULL OR s.status = :status) AND " + + "(:minRating IS NULL OR s.rating >= :minRating) AND " + + "(:keyword IS NULL OR s.storeName LIKE %:keyword% OR s.address LIKE %:keyword%)") + Page findStoresWithFilters(@Param("category") String category, + @Param("status") String status, + @Param("minRating") Double minRating, + @Param("keyword") String keyword, + Pageable pageable); +} \ No newline at end of file