store register add

This commit is contained in:
youbeen 2025-06-13 16:40:46 +09:00
parent b25c2edcc0
commit 17a68d3cdb
38 changed files with 2906 additions and 2327 deletions

View File

@ -44,6 +44,8 @@ spring:
order_updates: true order_updates: true
open-in-view: false open-in-view: false
# Redis 설정 (올바른 구조) # Redis 설정 (올바른 구조)
data: data:
redis: redis:

View File

@ -1,4 +1,342 @@
// store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java
package com.ktds.hi.store.biz.service; package com.ktds.hi.store.biz.service;
public class StoreService { import com.ktds.hi.store.biz.usecase.in.StoreUseCase;
import com.ktds.hi.store.biz.usecase.out.*;
import com.ktds.hi.store.biz.domain.Store;
import com.ktds.hi.store.biz.domain.StoreStatus;
import com.ktds.hi.store.infra.dto.*;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 매장 서비스 구현체
* Clean Architecture의 Application Service Layer
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StoreService implements StoreUseCase {
private final StoreRepositoryPort storeRepositoryPort;
private final MenuRepositoryPort menuRepositoryPort;
private final StoreTagRepositoryPort storeTagRepositoryPort;
private final GeocodingPort geocodingPort;
private final CachePort cachePort;
private final EventPort eventPort;
@Override
@Transactional
public StoreCreateResponse createStore(Long ownerId, StoreCreateRequest request) {
log.info("매장 등록 시작: ownerId={}, storeName={}", ownerId, request.getStoreName());
try {
// 1. 입력값 검증
validateStoreCreateRequest(request);
// 2. 점주 매장 개수 제한 확인 (: 최대 10개)
validateOwnerStoreLimit(ownerId);
// 3. 주소 지오코딩 (좌표 변환)
Coordinates coordinates = geocodingPort.getCoordinates(request.getAddress());
// 4. Store 도메인 객체 생성
Store store = Store.builder()
.ownerId(ownerId)
.storeName(request.getStoreName())
.address(request.getAddress())
.latitude(coordinates.getLatitude())
.longitude(coordinates.getLongitude())
.description(request.getDescription())
.phone(request.getPhone())
.operatingHours(request.getOperatingHours())
.category(request.getCategory())
.status(StoreStatus.ACTIVE)
.rating(0.0)
.reviewCount(0)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
// 5. 매장 저장
Store savedStore = storeRepositoryPort.saveStore(store);
// 6. 매장 태그 저장
if (request.getTags() != null && !request.getTags().isEmpty()) {
storeTagRepositoryPort.saveStoreTags(savedStore.getId(), request.getTags());
}
// 7. 메뉴 정보 저장
if (request.getMenus() != null && !request.getMenus().isEmpty()) {
menuRepositoryPort.saveMenus(savedStore.getId(),
request.getMenus().stream()
.map(menuReq -> menuReq.toDomain(savedStore.getId()))
.collect(Collectors.toList()));
}
// 8. 매장 생성 이벤트 발행
eventPort.publishStoreCreatedEvent(savedStore);
// 9. 캐시 무효화
cachePort.invalidateStoreCache(ownerId);
log.info("매장 등록 완료: storeId={}", savedStore.getId());
return StoreCreateResponse.builder()
.storeId(savedStore.getId())
.storeName(savedStore.getStoreName())
.message("매장이 성공적으로 등록되었습니다.")
.build();
} catch (Exception e) {
log.error("매장 등록 실패: ownerId={}, error={}", ownerId, e.getMessage(), e);
throw new BusinessException("STORE_CREATE_FAILED", "매장 등록에 실패했습니다: " + e.getMessage());
}
}
@Override
public List<MyStoreListResponse> getMyStores(Long ownerId) {
log.info("내 매장 목록 조회: ownerId={}", ownerId);
// 1. 캐시 확인
String cacheKey = "stores:owner:" + ownerId;
List<MyStoreListResponse> cachedStores = cachePort.getStoreCache(cacheKey);
if (cachedStores != null) {
log.info("캐시에서 매장 목록 반환: ownerId={}, count={}", ownerId, cachedStores.size());
return cachedStores;
}
// 2. DB에서 매장 목록 조회
List<Store> stores = storeRepositoryPort.findStoresByOwnerId(ownerId);
// 3. 응답 DTO 변환
List<MyStoreListResponse> responses = stores.stream()
.map(store -> {
String status = calculateStoreStatus(store);
return MyStoreListResponse.builder()
.storeId(store.getId())
.storeName(store.getStoreName())
.address(store.getAddress())
.category(store.getCategory())
.rating(store.getRating())
.reviewCount(store.getReviewCount())
.status(status)
.operatingHours(store.getOperatingHours())
.build();
})
.collect(Collectors.toList());
// 4. 캐시 저장 (1시간)
cachePort.putStoreCache(cacheKey, responses, 3600);
log.info("내 매장 목록 조회 완료: ownerId={}, count={}", ownerId, responses.size());
return responses;
}
@Override
public StoreDetailResponse getStoreDetail(Long storeId) {
log.info("매장 상세 조회: storeId={}", storeId);
// 1. 매장 기본 정보 조회
Store store = storeRepositoryPort.findStoreById(storeId)
.orElseThrow(() -> new BusinessException("STORE_NOT_FOUND", "매장을 찾을 수 없습니다."));
// 2. 매장 태그 조회
List<String> tags = storeTagRepositoryPort.findTagsByStoreId(storeId);
// 3. 메뉴 정보 조회
List<MenuResponse> menus = menuRepositoryPort.findMenusByStoreId(storeId)
.stream()
.map(MenuResponse::from)
.collect(Collectors.toList());
// 4. AI 요약 정보 조회 (외부 서비스)
String aiSummary = getAISummary(storeId);
return StoreDetailResponse.builder()
.storeId(store.getId())
.storeName(store.getStoreName())
.address(store.getAddress())
.latitude(store.getLatitude())
.longitude(store.getLongitude())
.description(store.getDescription())
.phone(store.getPhone())
.operatingHours(store.getOperatingHours())
.category(store.getCategory())
.rating(store.getRating())
.reviewCount(store.getReviewCount())
.status(store.getStatus().name())
.tags(tags)
.menus(menus)
.aiSummary(aiSummary)
.build();
}
@Override
@Transactional
public StoreUpdateResponse updateStore(Long storeId, Long ownerId, StoreUpdateRequest request) {
log.info("매장 정보 수정: storeId={}, ownerId={}", storeId, ownerId);
// 1. 매장 소유권 확인
Store store = validateStoreOwnership(storeId, ownerId);
// 2. 주소 변경 지오코딩
Coordinates coordinates = null;
if (!store.getAddress().equals(request.getAddress())) {
coordinates = geocodingPort.getCoordinates(request.getAddress());
}
// 3. 매장 정보 업데이트
store.updateBasicInfo(
request.getStoreName(),
request.getAddress(),
request.getDescription(),
request.getPhone(),
request.getOperatingHours()
);
if (coordinates != null) {
store.updateLocation(coordinates);
}
Store updatedStore = storeRepositoryPort.saveStore(store);
// 4. 태그 업데이트
if (request.getTags() != null) {
storeTagRepositoryPort.deleteTagsByStoreId(storeId);
storeTagRepositoryPort.saveStoreTags(storeId, request.getTags());
}
// 5. 매장 수정 이벤트 발행
eventPort.publishStoreUpdatedEvent(updatedStore);
// 6. 캐시 무효화
cachePort.invalidateStoreCache(storeId);
cachePort.invalidateStoreCache(ownerId);
return StoreUpdateResponse.builder()
.storeId(storeId)
.message("매장 정보가 성공적으로 수정되었습니다.")
.build();
}
@Override
@Transactional
public StoreDeleteResponse deleteStore(Long storeId, Long ownerId) {
log.info("매장 삭제: storeId={}, ownerId={}", storeId, ownerId);
// 1. 매장 소유권 확인
Store store = validateStoreOwnership(storeId, ownerId);
// 2. 소프트 삭제 (상태 변경)
store.delete();
storeRepositoryPort.saveStore(store);
// 3. 매장 삭제 이벤트 발행
eventPort.publishStoreDeletedEvent(storeId);
// 4. 캐시 무효화
cachePort.invalidateStoreCache(storeId);
cachePort.invalidateStoreCache(ownerId);
return StoreDeleteResponse.builder()
.storeId(storeId)
.message("매장이 성공적으로 삭제되었습니다.")
.build();
}
@Override
public List<StoreSearchResponse> searchStores(String keyword, String category, String tags,
Double latitude, Double longitude, Integer radius,
Integer page, Integer size) {
log.info("매장 검색: keyword={}, category={}, location=({}, {})", keyword, category, latitude, longitude);
StoreSearchCriteria criteria = StoreSearchCriteria.builder()
.keyword(keyword)
.category(category)
.tags(tags)
.latitude(latitude)
.longitude(longitude)
.radius(radius)
.page(page)
.size(size)
.build();
List<Store> stores = storeRepositoryPort.searchStores(criteria);
return stores.stream()
.map(store -> StoreSearchResponse.builder()
.storeId(store.getId())
.storeName(store.getStoreName())
.address(store.getAddress())
.category(store.getCategory())
.rating(store.getRating())
.reviewCount(store.getReviewCount())
.distance(calculateDistance(latitude, longitude, store.getLatitude(), store.getLongitude()))
.build())
.collect(Collectors.toList());
}
// === Private Helper Methods ===
private void validateStoreCreateRequest(StoreCreateRequest request) {
if (request.getStoreName() == null || request.getStoreName().trim().isEmpty()) {
throw new BusinessException("INVALID_STORE_NAME", "매장명은 필수입니다.");
}
if (request.getStoreName().length() > 100) {
throw new BusinessException("INVALID_STORE_NAME", "매장명은 100자를 초과할 수 없습니다.");
}
if (request.getAddress() == null || request.getAddress().trim().isEmpty()) {
throw new BusinessException("INVALID_ADDRESS", "주소는 필수입니다.");
}
if (request.getPhone() != null && !request.getPhone().matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
throw new BusinessException("INVALID_PHONE", "전화번호 형식이 올바르지 않습니다.");
}
}
private void validateOwnerStoreLimit(Long ownerId) {
Long storeCount = storeRepositoryPort.countStoresByOwnerId(ownerId);
if (storeCount >= 10) {
throw new BusinessException("STORE_LIMIT_EXCEEDED", "매장은 최대 10개까지 등록할 수 있습니다.");
}
}
private Store validateStoreOwnership(Long storeId, Long ownerId) {
return storeRepositoryPort.findStoreByIdAndOwnerId(storeId, ownerId)
.orElseThrow(() -> new BusinessException("STORE_ACCESS_DENIED", "매장에 대한 권한이 없습니다."));
}
private String calculateStoreStatus(Store store) {
if (!store.isActive()) {
return "비활성";
}
// 운영시간 기반 현재 상태 계산 로직
return "운영중";
}
private String getAISummary(Long storeId) {
// TODO: AI 분석 서비스 연동 구현
return "AI 요약 정보가 준비 중입니다.";
}
private Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2) {
if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) {
return null;
}
return geocodingPort.calculateDistance(
new Coordinates(lat1, lon1),
new Coordinates(lat2, lon2)
);
}
} }

View File

@ -1,26 +1,24 @@
package com.ktds.hi.store.biz.usecase.out; package com.ktds.hi.store.biz.usecase.out;
import java.time.Duration; import java.util.List;
import java.util.Optional;
/** /**
* 캐시 포트 인터페이스 * 캐시 포트 인터페이스
* 캐시 기능을 정의
*/ */
public interface CachePort { public interface CachePort {
/** /**
* 캐시에서 매장 데이터 조회 * 캐시에서 매장 정보 조회
*/ */
Optional<Object> getStoreCache(String key); <T> T getStoreCache(String key);
/** /**
* 캐시에 매장 데이터 저장 * 캐시에 매장 정보 저장
*/ */
void putStoreCache(String key, Object value, Duration ttl); void putStoreCache(String key, Object value, long ttlSeconds);
/** /**
* 캐시 무효화 * 캐시 무효화
*/ */
void invalidateStoreCache(Long storeId); void invalidateStoreCache(Object key);
} }

View File

@ -1,4 +1,16 @@
package com.ktds.hi.store.biz.usecase.out; package com.ktds.hi.store.biz.usecase.out;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 좌표 정보 객체
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Coordinates { public class Coordinates {
private Double latitude;
private Double longitude;
} }

View File

@ -1,4 +1,17 @@
package com.ktds.hi.store.biz.usecase.out; package com.ktds.hi.store.biz.usecase.out;
/**
* 지오코딩 포트 인터페이스
*/
public interface GeocodingPort { public interface GeocodingPort {
/**
* 주소를 좌표로 변환
*/
Coordinates getCoordinates(String address);
/**
* 좌표 거리 계산 (km)
*/
Double calculateDistance(Coordinates coord1, Coordinates coord2);
} }

View File

@ -1,4 +1,24 @@
package com.ktds.hi.store.biz.usecase.out; package com.ktds.hi.store.biz.usecase.out;
import java.util.List;
/**
* 매장 태그 리포지토리 포트 인터페이스
*/
public interface StoreTagRepositoryPort { public interface StoreTagRepositoryPort {
/**
* 매장 ID로 태그 목록 조회
*/
List<String> findTagsByStoreId(Long storeId);
/**
* 매장 태그 저장
*/
void saveStoreTags(Long storeId, List<String> tags);
/**
* 매장 태그 삭제
*/
void deleteTagsByStoreId(Long storeId);
} }

View File

@ -1,23 +1,13 @@
package com.ktds.hi.store.biz.domain; package com.ktds.hi.store.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/** /**
* 메뉴 도메인 클래스 * 메뉴 도메인 엔티티
* 메뉴 정보를 담는 도메인 객체
*
* @author 하이오더 개발팀
* @version 1.0.0
*/ */
@Getter @Getter
@Builder @Builder
@NoArgsConstructor
@AllArgsConstructor
public class Menu { public class Menu {
private Long id; private Long id;
@ -27,164 +17,31 @@ public class Menu {
private Integer price; private Integer price;
private String category; private String category;
private String imageUrl; private String imageUrl;
private Boolean isAvailable; private Boolean available;
private Integer orderCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** /**
* 메뉴 기본 정보 업데이트 * 메뉴 정보 업데이트
*/ */
public Menu updateInfo(String menuName, String description, Integer price) { public void updateMenuInfo(String menuName, String description, Integer price,
return Menu.builder() String category, String imageUrl) {
.id(this.id) this.menuName = menuName;
.storeId(this.storeId) this.description = description;
.menuName(menuName) this.price = price;
.description(description) this.category = category;
.price(price) this.imageUrl = imageUrl;
.category(this.category)
.imageUrl(this.imageUrl)
.isAvailable(this.isAvailable)
.orderCount(this.orderCount)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
} }
/** /**
* 메뉴 이미지 업데이트 * 메뉴 판매 상태 변경
*/ */
public Menu updateImage(String imageUrl) { public void setAvailable(Boolean available) {
return Menu.builder() this.available = available;
.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) { public boolean isAvailable() {
return Menu.builder() return this.available != null && this.available;
.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);
} }
} }

View File

@ -1,24 +1,19 @@
// store/src/main/java/com/ktds/hi/store/biz/domain/Store.java
package com.ktds.hi.store.domain; package com.ktds.hi.store.domain;
import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 매장 도메인 클래스 * 매장 도메인 엔티티
* 매장 정보를 담는 도메인 객체 * Clean Architecture의 Domain Layer
* *
* @author 하이오더 개발팀 * @author 하이오더 개발팀
* @version 1.0.0 * @version 1.0.0
*/ */
@Getter @Getter
@Builder @Builder
@NoArgsConstructor
@AllArgsConstructor
public class Store { public class Store {
private Long id; private Long id;
@ -27,160 +22,87 @@ public class Store {
private String address; private String address;
private Double latitude; private Double latitude;
private Double longitude; private Double longitude;
private String category;
private String description; private String description;
private String phone; private String phone;
private String operatingHours; private String operatingHours;
private List<String> tags; private String category;
private StoreStatus status;
private Double rating; private Double rating;
private Integer reviewCount; private Integer reviewCount;
private String imageUrl; private StoreStatus status;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** /**
* 매장 기본 정보 업데이트 * 매장 기본 정보 업데이트
*/ */
public Store updateBasicInfo(String storeName, String address, String description, public void updateBasicInfo(String storeName, String address, String description,
String phone, String operatingHours) { String phone, String operatingHours) {
return Store.builder() this.storeName = storeName;
.id(this.id) this.address = address;
.ownerId(this.ownerId) this.description = description;
.storeName(storeName) this.phone = phone;
.address(address) this.operatingHours = operatingHours;
.latitude(this.latitude) this.updatedAt = LocalDateTime.now();
.longitude(this.longitude)
.category(this.category)
.description(description)
.phone(phone)
.operatingHours(operatingHours)
.tags(this.tags)
.status(this.status)
.rating(this.rating)
.reviewCount(this.reviewCount)
.imageUrl(this.imageUrl)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
} }
/** /**
* 매장 위치 정보 업데이트 * 매장 위치 업데이트
*/ */
public Store updateLocation(Double latitude, Double longitude) { public void updateLocation(Coordinates coordinates) {
return Store.builder() this.latitude = coordinates.getLatitude();
.id(this.id) this.longitude = coordinates.getLongitude();
.ownerId(this.ownerId) this.updatedAt = LocalDateTime.now();
.storeName(this.storeName)
.address(this.address)
.latitude(latitude)
.longitude(longitude)
.category(this.category)
.description(this.description)
.phone(this.phone)
.operatingHours(this.operatingHours)
.tags(this.tags)
.status(this.status)
.rating(this.rating)
.reviewCount(this.reviewCount)
.imageUrl(this.imageUrl)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
} }
/** /**
* 매장 평점 리뷰 업데이트 * 매장 평점 업데이트
*/ */
public Store updateRating(Double rating, Integer reviewCount) { public void updateRating(Double rating, Integer reviewCount) {
return Store.builder() this.rating = rating;
.id(this.id) this.reviewCount = reviewCount;
.ownerId(this.ownerId) this.updatedAt = LocalDateTime.now();
.storeName(this.storeName)
.address(this.address)
.latitude(this.latitude)
.longitude(this.longitude)
.category(this.category)
.description(this.description)
.phone(this.phone)
.operatingHours(this.operatingHours)
.tags(this.tags)
.status(this.status)
.rating(rating)
.reviewCount(reviewCount)
.imageUrl(this.imageUrl)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
} }
/** /**
* 매장 활성화 * 매장 활성화
*/ */
public Store activate() { public void activate() {
return Store.builder() this.status = StoreStatus.ACTIVE;
.id(this.id) this.updatedAt = LocalDateTime.now();
.ownerId(this.ownerId)
.storeName(this.storeName)
.address(this.address)
.latitude(this.latitude)
.longitude(this.longitude)
.category(this.category)
.description(this.description)
.phone(this.phone)
.operatingHours(this.operatingHours)
.tags(this.tags)
.status(StoreStatus.ACTIVE)
.rating(this.rating)
.reviewCount(this.reviewCount)
.imageUrl(this.imageUrl)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
} }
/** /**
* 매장 비활성화 * 매장 비활성화
*/ */
public Store deactivate() { public void deactivate() {
return Store.builder() this.status = StoreStatus.INACTIVE;
.id(this.id) this.updatedAt = LocalDateTime.now();
.ownerId(this.ownerId)
.storeName(this.storeName)
.address(this.address)
.latitude(this.latitude)
.longitude(this.longitude)
.category(this.category)
.description(this.description)
.phone(this.phone)
.operatingHours(this.operatingHours)
.tags(this.tags)
.status(StoreStatus.INACTIVE)
.rating(this.rating)
.reviewCount(this.reviewCount)
.imageUrl(this.imageUrl)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
} }
/** /**
* 매장 활성 상태 확인 * 매장 삭제 (소프트 삭제)
*/
public void delete() {
this.status = StoreStatus.DELETED;
this.updatedAt = LocalDateTime.now();
}
/**
* 활성 상태 확인
*/ */
public boolean isActive() { public boolean isActive() {
return StoreStatus.ACTIVE.equals(this.status); return this.status == StoreStatus.ACTIVE;
} }
/** /**
* 매장 소유권 확인 * 점주 소유 확인
*/ */
public boolean isOwnedBy(Long ownerId) { public boolean isOwnedBy(Long ownerId) {
return this.ownerId != null && this.ownerId.equals(ownerId); return this.ownerId.equals(ownerId);
} }
/** /**
* 좌표 간의 거리 계산 (킬로미터) * 거리 계산
*/ */
public Double calculateDistance(Double targetLatitude, Double targetLongitude) { public Double calculateDistance(Double targetLatitude, Double targetLongitude) {
if (this.latitude == null || this.longitude == null || if (this.latitude == null || this.longitude == null ||
@ -188,17 +110,18 @@ public class Store {
return null; return null;
} }
final int EARTH_RADIUS = 6371; // 지구 반지름 (킬로미터) // Haversine 공식을 사용한 거리 계산
double earthRadius = 6371; // 지구 반지름 (km)
double latDistance = Math.toRadians(targetLatitude - this.latitude); double dLat = Math.toRadians(targetLatitude - this.latitude);
double lonDistance = Math.toRadians(targetLongitude - this.longitude); double dLon = Math.toRadians(targetLongitude - this.longitude);
double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2) double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(Math.toRadians(this.latitude)) * Math.cos(Math.toRadians(targetLatitude)) Math.cos(Math.toRadians(this.latitude)) * Math.cos(Math.toRadians(targetLatitude)) *
* Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c; return earthRadius * c;
} }
} }

View File

@ -2,32 +2,12 @@ package com.ktds.hi.store.domain;
/** /**
* 매장 상태 열거형 * 매장 상태 열거형
* 매장의 운영 상태를 정의
*
* @author 하이오더 개발팀
* @version 1.0.0
*/ */
public enum StoreStatus { public enum StoreStatus {
/**
* 활성 상태 - 정상 운영
*/
ACTIVE("활성"), ACTIVE("활성"),
/**
* 비활성 상태 - 임시 휴업
*/
INACTIVE("비활성"), INACTIVE("비활성"),
DELETED("삭제됨"),
/** PENDING("승인대기");
* 일시 정지 상태 - 관리자에 의한 일시 정지
*/
SUSPENDED("일시정지"),
/**
* 삭제 상태 - 영구 삭제 (소프트 삭제)
*/
DELETED("삭제");
private final String description; private final String description;
@ -38,33 +18,4 @@ public enum StoreStatus {
public String getDescription() { public String getDescription() {
return description; return description;
} }
/**
* 문자열로부터 StoreStatus 변환
*/
public static StoreStatus fromString(String status) {
if (status == null) {
return INACTIVE;
}
try {
return StoreStatus.valueOf(status.toUpperCase());
} catch (IllegalArgumentException e) {
return INACTIVE;
}
}
/**
* 매장이 서비스 가능한 상태인지 확인
*/
public boolean isServiceable() {
return this == ACTIVE;
}
/**
* 매장이 삭제된 상태인지 확인
*/
public boolean isDeleted() {
return this == DELETED;
}
} }

View File

@ -1,4 +1,120 @@
// store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java
package com.ktds.hi.store.infra.controller; package com.ktds.hi.store.infra.controller;
import com.ktds.hi.store.biz.usecase.in.StoreUseCase;
import com.ktds.hi.store.infra.dto.*;
import com.ktds.hi.common.dto.ApiResponse;
import com.ktds.hi.common.security.JwtTokenProvider;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
/**
* 매장 관리 컨트롤러
* 매장 등록, 수정, 삭제, 조회 기능 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Tag(name = "매장 관리", description = "매장 등록, 수정, 삭제, 조회 API")
@Slf4j
@RestController
@RequestMapping("/api/stores")
@RequiredArgsConstructor
@Validated
public class StoreController { public class StoreController {
private final StoreUseCase storeUseCase;
private final JwtTokenProvider jwtTokenProvider;
@Operation(summary = "매장 등록", description = "새로운 매장을 등록합니다.")
@PostMapping
@PreAuthorize("hasRole('OWNER')")
public ResponseEntity<ApiResponse<StoreCreateResponse>> createStore(
@Valid @RequestBody StoreCreateRequest request,
HttpServletRequest httpRequest) {
Long ownerId = jwtTokenProvider.extractOwnerInfo(httpRequest);
StoreCreateResponse response = storeUseCase.createStore(ownerId, request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, "매장이 성공적으로 등록되었습니다."));
}
@Operation(summary = "내 매장 목록 조회", description = "점주가 등록한 매장 목록을 조회합니다.")
@GetMapping("/my")
@PreAuthorize("hasRole('OWNER')")
public ResponseEntity<ApiResponse<List<MyStoreListResponse>>> getMyStores(
HttpServletRequest httpRequest) {
Long ownerId = jwtTokenProvider.extractOwnerInfo(httpRequest);
List<MyStoreListResponse> responses = storeUseCase.getMyStores(ownerId);
return ResponseEntity.ok(ApiResponse.success(responses, "내 매장 목록 조회 완료"));
}
@Operation(summary = "매장 상세 조회", description = "매장의 상세 정보를 조회합니다.")
@GetMapping("/{storeId}")
public ResponseEntity<ApiResponse<StoreDetailResponse>> getStoreDetail(
@Parameter(description = "매장 ID") @PathVariable Long storeId) {
StoreDetailResponse response = storeUseCase.getStoreDetail(storeId);
return ResponseEntity.ok(ApiResponse.success(response, "매장 상세 정보 조회 완료"));
}
@Operation(summary = "매장 정보 수정", description = "매장 정보를 수정합니다.")
@PutMapping("/{storeId}")
@PreAuthorize("hasRole('OWNER')")
public ResponseEntity<ApiResponse<StoreUpdateResponse>> updateStore(
@Parameter(description = "매장 ID") @PathVariable Long storeId,
@Valid @RequestBody StoreUpdateRequest request,
HttpServletRequest httpRequest) {
Long ownerId = jwtTokenProvider.extractOwnerInfo(httpRequest);
StoreUpdateResponse response = storeUseCase.updateStore(storeId, ownerId, request);
return ResponseEntity.ok(ApiResponse.success(response, "매장 정보 수정 완료"));
}
@Operation(summary = "매장 삭제", description = "매장을 삭제합니다.")
@DeleteMapping("/{storeId}")
@PreAuthorize("hasRole('OWNER')")
public ResponseEntity<ApiResponse<StoreDeleteResponse>> deleteStore(
@Parameter(description = "매장 ID") @PathVariable Long storeId,
HttpServletRequest httpRequest) {
Long ownerId = jwtTokenProvider.extractOwnerInfo(httpRequest);
StoreDeleteResponse response = storeUseCase.deleteStore(storeId, ownerId);
return ResponseEntity.ok(ApiResponse.success(response, "매장 삭제 완료"));
}
@Operation(summary = "매장 검색", description = "조건에 따라 매장을 검색합니다.")
@GetMapping("/search")
public ResponseEntity<ApiResponse<List<StoreSearchResponse>>> searchStores(
@Parameter(description = "검색 키워드") @RequestParam(required = false) String keyword,
@Parameter(description = "카테고리") @RequestParam(required = false) String category,
@Parameter(description = "태그") @RequestParam(required = false) String tags,
@Parameter(description = "위도") @RequestParam(required = false) Double latitude,
@Parameter(description = "경도") @RequestParam(required = false) Double longitude,
@Parameter(description = "검색 반경(km)") @RequestParam(defaultValue = "5") Integer radius,
@Parameter(description = "페이지 번호") @RequestParam(defaultValue = "0") Integer page,
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") Integer size) {
List<StoreSearchResponse> responses = storeUseCase.searchStores(
keyword, category, tags, latitude, longitude, radius, page, size);
return ResponseEntity.ok(ApiResponse.success(responses, "매장 검색 완료"));
}
} }

View File

@ -1,4 +1,54 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import com.ktds.hi.store.biz.domain.Menu;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 메뉴 등록 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "메뉴 등록 요청")
public class MenuCreateRequest { public class MenuCreateRequest {
@NotBlank(message = "메뉴명은 필수입니다.")
@Schema(description = "메뉴명", example = "김치찌개")
private String menuName;
@Schema(description = "메뉴 설명", example = "얼큰한 김치찌개")
private String description;
@Min(value = 0, message = "가격은 0원 이상이어야 합니다.")
@Schema(description = "가격", example = "8000")
private Integer price;
@Schema(description = "메뉴 카테고리", example = "메인")
private String category;
@Schema(description = "이미지 URL", example = "https://example.com/kimchi.jpg")
private String imageUrl;
@Schema(description = "이용 가능 여부", example = "true")
private Boolean available = true;
/**
* 도메인 객체로 변환
*/
public Menu toDomain(Long storeId) {
return Menu.builder()
.storeId(storeId)
.menuName(this.menuName)
.description(this.description)
.price(this.price)
.category(this.category)
.imageUrl(this.imageUrl)
.available(this.available != null ? this.available : true)
.build();
}
} }

View File

@ -1,4 +1,55 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import com.ktds.hi.store.biz.domain.Menu;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 메뉴 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "메뉴 응답")
public class MenuResponse { public class MenuResponse {
@Schema(description = "메뉴 ID", example = "1")
private Long menuId;
@Schema(description = "메뉴명", example = "김치찌개")
private String menuName;
@Schema(description = "메뉴 설명", example = "얼큰한 김치찌개")
private String description;
@Schema(description = "가격", example = "8000")
private Integer price;
@Schema(description = "메뉴 카테고리", example = "메인")
private String category;
@Schema(description = "이미지 URL")
private String imageUrl;
@Schema(description = "이용 가능 여부", example = "true")
private Boolean available;
/**
* 도메인 객체로부터 생성
*/
public static MenuResponse from(Menu menu) {
return MenuResponse.builder()
.menuId(menu.getId())
.menuName(menu.getMenuName())
.description(menu.getDescription())
.price(menu.getPrice())
.category(menu.getCategory())
.imageUrl(menu.getImageUrl())
.available(menu.getAvailable())
.build();
}
} }

View File

@ -1,4 +1,42 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 목록 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "내 매장 목록 응답")
public class MyStoreListResponse { public class MyStoreListResponse {
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
private String storeName;
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "카테고리", example = "한식")
private String category;
@Schema(description = "평점", example = "4.5")
private Double rating;
@Schema(description = "리뷰 수", example = "127")
private Integer reviewCount;
@Schema(description = "운영 상태", example = "운영중")
private String status;
@Schema(description = "운영시간", example = "월-금 09:00-21:00")
private String operatingHours;
} }

View File

@ -1,4 +1,48 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 매장 등록 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 등록 요청")
public class StoreCreateRequest { public class StoreCreateRequest {
@NotBlank(message = "매장명은 필수입니다.")
@Size(max = 100, message = "매장명은 100자를 초과할 수 없습니다.")
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
private String storeName;
@NotBlank(message = "주소는 필수입니다.")
@Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "매장 설명", example = "맛있는 한식당입니다.")
private String description;
@Schema(description = "전화번호", example = "02-1234-5678")
private String phone;
@Schema(description = "운영시간", example = "월-금 09:00-21:00, 토-일 10:00-20:00")
private String operatingHours;
@NotBlank(message = "카테고리는 필수입니다.")
@Schema(description = "카테고리", example = "한식")
private String category;
@Schema(description = "매장 태그 목록", example = "[\"맛집\", \"혼밥\", \"가성비\"]")
private List<String> tags;
@Schema(description = "메뉴 목록")
private List<MenuCreateRequest> menus;
} }

View File

@ -1,4 +1,27 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 등록 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 등록 응답")
public class StoreCreateResponse { public class StoreCreateResponse {
@Schema(description = "생성된 매장 ID", example = "1")
private Long storeId;
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
private String storeName;
@Schema(description = "응답 메시지", example = "매장이 성공적으로 등록되었습니다.")
private String message;
} }

View File

@ -1,5 +1,6 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@ -12,8 +13,12 @@ import lombok.NoArgsConstructor;
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Schema(description = "매장 삭제 응답")
public class StoreDeleteResponse { public class StoreDeleteResponse {
private Boolean success; @Schema(description = "삭제된 매장 ID", example = "1")
private Long storeId;
@Schema(description = "응답 메시지", example = "매장이 성공적으로 삭제되었습니다.")
private String message; private String message;
} }

View File

@ -1,4 +1,65 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 매장 상세 조회 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 상세 조회 응답")
public class StoreDetailResponse { public class StoreDetailResponse {
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
private String storeName;
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "위도", example = "37.5665")
private Double latitude;
@Schema(description = "경도", example = "126.9780")
private Double longitude;
@Schema(description = "매장 설명", example = "맛있는 한식당입니다.")
private String description;
@Schema(description = "전화번호", example = "02-1234-5678")
private String phone;
@Schema(description = "운영시간", example = "월-금 09:00-21:00")
private String operatingHours;
@Schema(description = "카테고리", example = "한식")
private String category;
@Schema(description = "평점", example = "4.5")
private Double rating;
@Schema(description = "리뷰 수", example = "127")
private Integer reviewCount;
@Schema(description = "매장 상태", example = "ACTIVE")
private String status;
@Schema(description = "매장 태그 목록")
private List<String> tags;
@Schema(description = "메뉴 목록")
private List<MenuResponse> menus;
@Schema(description = "AI 요약 정보")
private String aiSummary;
} }

View File

@ -1,4 +1,39 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 검색 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 검색 응답")
public class StoreSearchResponse { public class StoreSearchResponse {
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
private String storeName;
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "카테고리", example = "한식")
private String category;
@Schema(description = "평점", example = "4.5")
private Double rating;
@Schema(description = "리뷰 수", example = "127")
private Integer reviewCount;
@Schema(description = "거리(km)", example = "1.2")
private Double distance;
} }

View File

@ -1,4 +1,41 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 매장 수정 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 수정 요청")
public class StoreUpdateRequest { public class StoreUpdateRequest {
@NotBlank(message = "매장명은 필수입니다.")
@Size(max = 100, message = "매장명은 100자를 초과할 수 없습니다.")
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
private String storeName;
@NotBlank(message = "주소는 필수입니다.")
@Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "매장 설명", example = "맛있는 한식당입니다.")
private String description;
@Schema(description = "전화번호", example = "02-1234-5678")
private String phone;
@Schema(description = "운영시간", example = "월-금 09:00-21:00, 토-일 10:00-20:00")
private String operatingHours;
@Schema(description = "매장 태그 목록", example = "[\"맛집\", \"혼밥\", \"가성비\"]")
private List<String> tags;
} }

View File

@ -1,5 +1,6 @@
package com.ktds.hi.store.infra.dto; package com.ktds.hi.store.infra.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@ -12,8 +13,12 @@ import lombok.NoArgsConstructor;
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Schema(description = "매장 수정 응답")
public class StoreUpdateResponse { public class StoreUpdateResponse {
private Boolean success; @Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "응답 메시지", example = "매장 정보가 성공적으로 수정되었습니다.")
private String message; private String message;
} }