store Menu update

This commit is contained in:
youbeen 2025-06-13 17:07:45 +09:00
parent 17a68d3cdb
commit a6548db953
8 changed files with 281 additions and 258 deletions

View File

@ -1,5 +1,7 @@
package com.ktds.hi.common.security;
import jakarta.servlet.http.HttpServletRequest;
import com.ktds.hi.common.exception.BusinessException;
import com.ktds.hi.common.constants.SecurityConstants;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
@ -55,6 +57,82 @@ public class JwtTokenProvider {
.verifyWith(secretKey)
.build();
}
/**
* HTTP 요청에서 점주 정보 추출
*/
public Long extractOwnerInfo(HttpServletRequest request) {
try {
// Authorization 헤더에서 토큰 추출
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new BusinessException("UNAUTHORIZED", "인증 토큰이 필요합니다.");
}
String token = authHeader.substring(7); // "Bearer " 제거
// 토큰 유효성 검증
if (!validateToken(token)) {
throw new BusinessException("UNAUTHORIZED", "유효하지 않은 토큰입니다.");
}
// 토큰에서 사용자 ID 추출
String userId = getUserIdFromToken(token);
if (userId == null) {
throw new BusinessException("UNAUTHORIZED", "토큰에서 사용자 정보를 찾을 수 없습니다.");
}
// 토큰에서 권한 정보 추출
String roles = getRolesFromToken(token);
if (roles == null || !roles.contains("OWNER")) {
throw new BusinessException("FORBIDDEN", "점주 권한이 필요합니다.");
}
log.debug("점주 정보 추출 완료: ownerId={}", userId);
return Long.parseLong(userId);
} catch (NumberFormatException e) {
log.error("사용자 ID 형변환 실패: {}", e.getMessage());
throw new BusinessException("UNAUTHORIZED", "잘못된 사용자 ID 형식입니다.");
} catch (BusinessException e) {
throw e; // 비즈니스 예외는 그대로 전파
} catch (Exception e) {
log.error("점주 정보 추출 중 오류 발생: {}", e.getMessage(), e);
throw new BusinessException("UNAUTHORIZED", "인증 처리 중 오류가 발생했습니다.");
}
}
/**
* HTTP 요청에서 사용자 정보 추출 (일반 사용자용)
*/
public Long extractUserInfo(HttpServletRequest request) {
try {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new BusinessException("UNAUTHORIZED", "인증 토큰이 필요합니다.");
}
String token = authHeader.substring(7);
if (!validateToken(token)) {
throw new BusinessException("UNAUTHORIZED", "유효하지 않은 토큰입니다.");
}
String userId = getUserIdFromToken(token);
if (userId == null) {
throw new BusinessException("UNAUTHORIZED", "토큰에서 사용자 정보를 찾을 수 없습니다.");
}
return Long.parseLong(userId);
} catch (NumberFormatException e) {
throw new BusinessException("UNAUTHORIZED", "잘못된 사용자 ID 형식입니다.");
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("사용자 정보 추출 중 오류 발생: {}", e.getMessage(), e);
throw new BusinessException("UNAUTHORIZED", "인증 처리 중 오류가 발생했습니다.");
}
}
/**
* 액세스 토큰 생성

View File

@ -2,10 +2,9 @@
package com.ktds.hi.store.biz.service;
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.store.infra.gateway.entity.StoreEntity;
import com.ktds.hi.store.infra.gateway.repository.StoreJpaRepository;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -17,11 +16,7 @@ import java.util.List;
import java.util.stream.Collectors;
/**
* 매장 서비스 구현체
* Clean Architecture의 Application Service Layer
*
* @author 하이오더 개발팀
* @version 1.0.0
* 매장 서비스 구현체 (간단 버전)
*/
@Slf4j
@Service
@ -29,141 +24,77 @@ import java.util.stream.Collectors;
@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;
private final StoreJpaRepository storeJpaRepository;
@Override
@Transactional
public StoreCreateResponse createStore(Long ownerId, StoreCreateRequest request) {
log.info("매장 등록 시작: ownerId={}, storeName={}", ownerId, request.getStoreName());
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());
// 기본 검증
if (request.getStoreName() == null || request.getStoreName().trim().isEmpty()) {
throw new BusinessException("INVALID_STORE_NAME", "매장명은 필수입니다.");
}
if (request.getAddress() == null || request.getAddress().trim().isEmpty()) {
throw new BusinessException("INVALID_ADDRESS", "주소는 필수입니다.");
}
// 매장 엔티티 생성
StoreEntity store = StoreEntity.builder()
.ownerId(ownerId)
.storeName(request.getStoreName())
.address(request.getAddress())
.latitude(37.5665) // 기본 좌표 (서울시청)
.longitude(126.9780)
.description(request.getDescription())
.phone(request.getPhone())
.operatingHours(request.getOperatingHours())
.category(request.getCategory())
.status("ACTIVE")
.rating(0.0)
.reviewCount(0)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
StoreEntity savedStore = storeJpaRepository.save(store);
log.info("매장 등록 완료: storeId={}", savedStore.getId());
return StoreCreateResponse.builder()
.storeId(savedStore.getId())
.storeName(savedStore.getStoreName())
.message("매장이 성공적으로 등록되었습니다.")
.build();
}
@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;
}
List<StoreEntity> stores = storeJpaRepository.findByOwnerId(ownerId);
// 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();
})
return stores.stream()
.map(store -> MyStoreListResponse.builder()
.storeId(store.getId())
.storeName(store.getStoreName())
.address(store.getAddress())
.category(store.getCategory())
.rating(store.getRating())
.reviewCount(store.getReviewCount())
.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)
StoreEntity store = storeJpaRepository.findById(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())
@ -176,58 +107,26 @@ public class StoreService implements StoreUseCase {
.category(store.getCategory())
.rating(store.getRating())
.reviewCount(store.getReviewCount())
.status(store.getStatus().name())
.tags(tags)
.menus(menus)
.aiSummary(aiSummary)
.status(store.getStatus())
.build();
}
@Override
@Transactional
public StoreUpdateResponse updateStore(Long storeId, Long ownerId, StoreUpdateRequest request) {
log.info("매장 정보 수정: storeId={}, ownerId={}", storeId, ownerId);
log.info("매장 수정: storeId={}, ownerId={}", storeId, ownerId);
// 1. 매장 소유권 확인
Store store = validateStoreOwnership(storeId, ownerId);
StoreEntity store = storeJpaRepository.findByIdAndOwnerId(storeId, ownerId)
.orElseThrow(() -> new BusinessException("STORE_ACCESS_DENIED", "매장에 대한 권한이 없습니다."));
// 2. 주소 변경 지오코딩
Coordinates coordinates = null;
if (!store.getAddress().equals(request.getAddress())) {
coordinates = geocodingPort.getCoordinates(request.getAddress());
}
store.updateInfo(request.getStoreName(), request.getAddress(), request.getDescription(),
request.getPhone(), request.getOperatingHours());
// 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);
storeJpaRepository.save(store);
return StoreUpdateResponse.builder()
.storeId(storeId)
.message("매장 정보가 성공적으로 수정되었습니다.")
.message("매장 정보가 수정되었습니다.")
.build();
}
@ -236,23 +135,15 @@ public class StoreService implements StoreUseCase {
public StoreDeleteResponse deleteStore(Long storeId, Long ownerId) {
log.info("매장 삭제: storeId={}, ownerId={}", storeId, ownerId);
// 1. 매장 소유권 확인
Store store = validateStoreOwnership(storeId, ownerId);
StoreEntity store = storeJpaRepository.findByIdAndOwnerId(storeId, ownerId)
.orElseThrow(() -> new BusinessException("STORE_ACCESS_DENIED", "매장에 대한 권한이 없습니다."));
// 2. 소프트 삭제 (상태 변경)
store.delete();
storeRepositoryPort.saveStore(store);
// 3. 매장 삭제 이벤트 발행
eventPort.publishStoreDeletedEvent(storeId);
// 4. 캐시 무효화
cachePort.invalidateStoreCache(storeId);
cachePort.invalidateStoreCache(ownerId);
store.updateStatus("DELETED");
storeJpaRepository.save(store);
return StoreDeleteResponse.builder()
.storeId(storeId)
.message("매장이 성공적으로 삭제되었습니다.")
.message("매장이 삭제되었습니다.")
.build();
}
@ -260,20 +151,16 @@ public class StoreService implements StoreUseCase {
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);
log.info("매장 검색: keyword={}, category={}", keyword, category);
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);
List<StoreEntity> stores;
if (keyword != null && !keyword.trim().isEmpty()) {
stores = storeJpaRepository.findByStoreNameContainingOrAddressContaining(keyword, keyword);
} else if (category != null && !category.trim().isEmpty()) {
stores = storeJpaRepository.findByCategory(category);
} else {
stores = storeJpaRepository.findAll();
}
return stores.stream()
.map(store -> StoreSearchResponse.builder()
@ -283,60 +170,8 @@ public class StoreService implements StoreUseCase {
.category(store.getCategory())
.rating(store.getRating())
.reviewCount(store.getReviewCount())
.distance(calculateDistance(latitude, longitude, store.getLatitude(), store.getLongitude()))
.distance(1.5) // 더미 거리
.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

@ -0,0 +1,76 @@
package com.ktds.hi.store.biz.usecase.in;
import com.ktds.hi.store.infra.dto.*;
import java.util.List;
/**
* 매장 관리 유스케이스 인터페이스
* Clean Architecture의 Input Port
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
public interface StoreUseCase {
/**
* 매장 등록
*
* @param ownerId 점주 ID
* @param request 매장 등록 요청 정보
* @return 매장 등록 응답
*/
StoreCreateResponse createStore(Long ownerId, StoreCreateRequest request);
/**
* 매장 목록 조회
*
* @param ownerId 점주 ID
* @return 매장 목록
*/
List<MyStoreListResponse> getMyStores(Long ownerId);
/**
* 매장 상세 조회
*
* @param storeId 매장 ID
* @return 매장 상세 정보
*/
StoreDetailResponse getStoreDetail(Long storeId);
/**
* 매장 정보 수정
*
* @param storeId 매장 ID
* @param ownerId 점주 ID
* @param request 매장 수정 요청 정보
* @return 매장 수정 응답
*/
StoreUpdateResponse updateStore(Long storeId, Long ownerId, StoreUpdateRequest request);
/**
* 매장 삭제
*
* @param storeId 매장 ID
* @param ownerId 점주 ID
* @return 매장 삭제 응답
*/
StoreDeleteResponse deleteStore(Long storeId, Long ownerId);
/**
* 매장 검색
*
* @param keyword 검색 키워드
* @param category 카테고리
* @param tags 태그
* @param latitude 위도
* @param longitude 경도
* @param radius 검색 반경(km)
* @param page 페이지 번호
* @param size 페이지 크기
* @return 검색된 매장 목록
*/
List<StoreSearchResponse> searchStores(String keyword, String category, String tags,
Double latitude, Double longitude, Integer radius,
Integer page, Integer size);
}

View File

@ -1,7 +1,8 @@
package com.ktds.hi.store.biz.domain;
package com.ktds.hi.store.domain;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 메뉴 도메인 엔티티
@ -18,6 +19,8 @@ public class Menu {
private String category;
private String imageUrl;
private Boolean available;
private LocalDateTime createdAt; // 추가
private LocalDateTime updatedAt; // 추가
/**
* 메뉴 정보 업데이트

View File

@ -1,6 +1,6 @@
package com.ktds.hi.store.infra.dto;
import com.ktds.hi.store.biz.domain.Menu;
import com.ktds.hi.store.domain.Menu;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;

View File

@ -9,6 +9,8 @@ import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 캐시 어댑터 클래스
@ -34,27 +36,43 @@ public class CacheAdapter implements CachePort {
}
@Override
public void putStoreCache(String key, Object value, Duration ttl) {
public void putStoreCache(String key, Object value, long ttlSeconds) {
try {
redisTemplate.opsForValue().set(key, value, ttl);
log.debug("매장 캐시 저장 완료: key={}, ttl={}분", key, ttl.toMinutes());
redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
log.debug("캐시 저장: key={}, ttl={}초", key, ttlSeconds);
} catch (Exception e) {
log.error("매장 캐시 저장 실패: key={}, error={}", key, e.getMessage());
log.warn("캐시 저장 실패: key={}, error={}", key, e.getMessage());
}
}
@Override
public void invalidateStoreCache(Long storeId) {
public void invalidateStoreCache(Object key) {
try {
// 매장 관련 모든 캐시 패턴 삭제
String storeDetailKey = "store_detail:" + storeId;
String myStoresKey = "my_stores:*";
if (key instanceof Long) {
// 매장 ID로 특정 매장 캐시 삭제
Long storeId = (Long) key;
String storeDetailKey = "store_detail:" + storeId;
redisTemplate.delete(storeDetailKey);
log.debug("매장 캐시 무효화 완료: storeId={}", storeId);
redisTemplate.delete(storeDetailKey);
} else if (key instanceof String) {
// 패턴으로 캐시 삭제
String pattern = key.toString();
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
log.debug("패턴 캐시 무효화 완료: pattern={}", pattern);
} else {
// 기본적으로 toString()으로 생성
String cacheKey = "stores:" + key.toString();
redisTemplate.delete(cacheKey);
log.debug("캐시 무효화 완료: key={}", key);
}
log.debug("매장 캐시 무효화 완료: storeId={}", storeId);
} catch (Exception e) {
log.error("매장 캐시 무효화 실패: storeId={}, error={}", storeId, e.getMessage());
log.error("캐시 무효화 실패: key={}, error={}", key, e.getMessage());
}
}
}

View File

@ -96,7 +96,7 @@ public class MenuRepositoryAdapter implements MenuRepositoryPort {
.price(entity.getPrice())
.category(entity.getCategory())
.imageUrl(entity.getImageUrl())
.isAvailable(entity.getIsAvailable())
.available(entity.getIsAvailable())
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();

View File

@ -119,6 +119,19 @@ public class StoreEntity {
this.reviewCount = reviewCount;
}
/**
* 매장 기본 정보 업데이트
*/
public void updateInfo(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;
this.updatedAt = LocalDateTime.now();
}
/**
* 매장 태그 업데이트
*/