From ba1ff79ab843de802f2f5a04dbeca7aebbc61158 Mon Sep 17 00:00:00 2001 From: shinchaerin79 Date: Fri, 22 Aug 2025 01:12:35 +0900 Subject: [PATCH 01/13] =?UTF-8?q?:recycle:=20rewardName=20=E2=86=92=20rewa?= =?UTF-8?q?rd=20=ED=95=84=EB=93=9C=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/openAI/dto/response/MissionRecommendResponse.java | 2 +- .../danchu/domain/openAI/service/MissionRecommendService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/likelion/danchu/domain/openAI/dto/response/MissionRecommendResponse.java b/src/main/java/com/likelion/danchu/domain/openAI/dto/response/MissionRecommendResponse.java index 45f1684..1b17915 100644 --- a/src/main/java/com/likelion/danchu/domain/openAI/dto/response/MissionRecommendResponse.java +++ b/src/main/java/com/likelion/danchu/domain/openAI/dto/response/MissionRecommendResponse.java @@ -16,7 +16,7 @@ public class MissionRecommendResponse { private String title; @Schema(description = "미션 보상(이름/설명)", example = "사이다 1잔") - private String rewardName; + private String reward; @Schema(description = "가게 이름", example = "동방손칼국수") private String storeName; diff --git a/src/main/java/com/likelion/danchu/domain/openAI/service/MissionRecommendService.java b/src/main/java/com/likelion/danchu/domain/openAI/service/MissionRecommendService.java index 53fd7a1..778dbc7 100644 --- a/src/main/java/com/likelion/danchu/domain/openAI/service/MissionRecommendService.java +++ b/src/main/java/com/likelion/danchu/domain/openAI/service/MissionRecommendService.java @@ -195,7 +195,7 @@ private MissionRecommendResponse toDto(Mission m) { return MissionRecommendResponse.builder() .missionId(m.getId()) .title(m.getTitle()) - .rewardName(m.getReward()) + .reward(m.getReward()) .storeName(s != null ? s.getName() : null) .build(); } From e18ed46116b703a9552b22cdb32ed34fc4c43d5a Mon Sep 17 00:00:00 2001 From: shinchaerin79 Date: Fri, 22 Aug 2025 01:54:22 +0900 Subject: [PATCH 02/13] =?UTF-8?q?:recycle:=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=ED=8C=A8=ED=84=B4=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?=EC=8A=A4=EB=A7=88=ED=8A=B8=EC=BD=9C=20=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../danchu/domain/store/controller/StoreController.java | 2 +- .../danchu/domain/store/dto/request/StoreRequest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java b/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java index 4c7c4e1..5442327 100644 --- a/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java +++ b/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java @@ -56,7 +56,7 @@ public class StoreController { - 가게 이름: **1자 이상, 10자 이내** - 주소: **1자 이상, 50자 이내** - 가게 소개: **1자 이상, 200자 이내** - - 전화번호: **010-1234-5678** 또는 **02-345-6789** 형식 + - 전화번호: **010-1234-5678** / **02-345-6789** / **0507-1418-3557** 형식 - 오픈/마감 시간: **HH:mm** 형식 (예: 09:00, 21:00) - 인증 코드: **숫자 4자리 (예: 0123)** - 이미지 파일: **multipart/form-data** 형식 diff --git a/src/main/java/com/likelion/danchu/domain/store/dto/request/StoreRequest.java b/src/main/java/com/likelion/danchu/domain/store/dto/request/StoreRequest.java index 98b192c..3b2720e 100644 --- a/src/main/java/com/likelion/danchu/domain/store/dto/request/StoreRequest.java +++ b/src/main/java/com/likelion/danchu/domain/store/dto/request/StoreRequest.java @@ -46,8 +46,8 @@ public class StoreRequest { @NotBlank(message = "전화번호는 필수입니다.") @Pattern( - regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", - message = "전화번호는 형식에 맞게 입력해주세요. (예: 010-1234-5678 / 02-345-6789)") + regexp = "^(?:\\d{2,3}-\\d{3,4}-\\d{4}|050\\d-\\d{3,4}-\\d{4})$", + message = "전화번호는 형식에 맞게 입력해주세요. (예: 010-1234-5678 / 02-345-6789 / 0507-1418-3557)") @Schema(description = "전화번호", example = "010-1234-5678") private String phoneNumber; From 2f448d05c8b1e83afba309c6dadf133179442af4 Mon Sep 17 00:00:00 2001 From: shinchaerin79 Date: Fri, 22 Aug 2025 02:44:53 +0900 Subject: [PATCH 03/13] =?UTF-8?q?:recycle:=20Store=20Response=EC=97=90=20a?= =?UTF-8?q?uthCode=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/dto/response/StoreResponse.java | 3 + .../domain/store/mapper/StoreMapper.java | 1 + .../store/repository/StoreRepository.java | 74 ++++++++++--------- .../domain/store/service/StoreService.java | 2 +- 4 files changed, 44 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreResponse.java b/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreResponse.java index 4492560..9b29712 100644 --- a/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreResponse.java +++ b/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreResponse.java @@ -35,6 +35,9 @@ public class StoreResponse { @Schema(description = "마감 시간 (HH:mm)", example = "21:00") private String closeTime; + @Schema(description = "가게 인증 코드") + private String authCode; + @Schema(description = "대표 이미지 URL", example = "https://s3.amazonaws.com/bucket/image.jpg") private String mainImageUrl; diff --git a/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java b/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java index 9cbc3ec..97e16c3 100644 --- a/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java +++ b/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java @@ -58,6 +58,7 @@ public StoreResponse toResponse( .phoneNumber(store.getPhoneNumber()) .openTime(store.getOpenTime()) .closeTime(store.getCloseTime()) + .authCode(store.getAuthCode()) .mainImageUrl(store.getMainImageUrl()) .hashtags(hashtags != null ? hashtags : List.of()) // null 방어 .isOpen(isOpen) diff --git a/src/main/java/com/likelion/danchu/domain/store/repository/StoreRepository.java b/src/main/java/com/likelion/danchu/domain/store/repository/StoreRepository.java index 9c083f4..75f79e0 100644 --- a/src/main/java/com/likelion/danchu/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/likelion/danchu/domain/store/repository/StoreRepository.java @@ -56,6 +56,8 @@ interface StoreWithDistanceProjection { Double getLongitude(); Double getDistance_m(); // alias와 동일해야 함 + + String getAuth_Code(); } /** @@ -84,7 +86,8 @@ interface StoreWithDistanceProjection { (6371000 * ACOS(LEAST(1, COS(RADIANS(:lat)) * COS(RADIANS(s.latitude)) * COS(RADIANS(s.longitude) - RADIANS(:lng)) + SIN(RADIANS(:lat)) * SIN(RADIANS(s.latitude)) - ))) AS distance_m + ))) AS distance_m, + s.auth_code FROM store s WHERE (:radius IS NULL OR (6371000 * ACOS(LEAST(1, @@ -113,42 +116,43 @@ Page findNearby( @Query( value = """ - SELECT - s.id, - s.name, - s.address, - s.description, - s.phone_number, - s.open_time, - s.close_time, - s.main_image_url, - s.latitude, - s.longitude, - (6371000 * ACOS(LEAST(1, - COS(RADIANS(:lat)) * COS(RADIANS(s.latitude)) * - COS(RADIANS(s.longitude) - RADIANS(:lng)) + - SIN(RADIANS(:lat)) * SIN(RADIANS(s.latitude)) - ))) AS distance_m - FROM store s - JOIN store_hashtag sh ON sh.store_id = s.id - JOIN hashtag h ON h.id = sh.hashtag_id - WHERE LOWER(h.name) IN (:names) - GROUP BY s.id - HAVING COUNT(DISTINCT LOWER(h.name)) = :size - ORDER BY distance_m ASC - """, + SELECT + s.id, + s.name, + s.address, + s.description, + s.phone_number, + s.open_time, + s.close_time, + s.main_image_url, + s.latitude, + s.longitude, + (6371000 * ACOS(LEAST(1, + COS(RADIANS(:lat)) * COS(RADIANS(s.latitude)) * + COS(RADIANS(s.longitude) - RADIANS(:lng)) + + SIN(RADIANS(:lat)) * SIN(RADIANS(s.latitude)) + ))) AS distance_m, + s.auth_code + FROM store s + JOIN store_hashtag sh ON sh.store_id = s.id + JOIN hashtag h ON h.id = sh.hashtag_id + WHERE LOWER(h.name) IN (:names) + GROUP BY s.id + HAVING COUNT(DISTINCT LOWER(h.name)) = :size + ORDER BY distance_m ASC + """, countQuery = """ - SELECT COUNT(*) FROM ( - SELECT s.id - FROM store s - JOIN store_hashtag sh ON sh.store_id = s.id - JOIN hashtag h ON h.id = sh.hashtag_id - WHERE LOWER(h.name) IN (:names) - GROUP BY s.id - HAVING COUNT(DISTINCT LOWER(h.name)) = :size - ) t - """, + SELECT COUNT(*) FROM ( + SELECT s.id + FROM store s + JOIN store_hashtag sh ON sh.store_id = s.id + JOIN hashtag h ON h.id = sh.hashtag_id + WHERE LOWER(h.name) IN (:names) + GROUP BY s.id + HAVING COUNT(DISTINCT LOWER(h.name)) = :size + ) t + """, nativeQuery = true) Page findByAllHashtagsWithDistance( @Param("names") List names, diff --git a/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java b/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java index bd0e8ec..db91e59 100644 --- a/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java +++ b/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java @@ -313,7 +313,7 @@ public PageableResponse getNearbyStores( .mainImageUrl(p.getMain_Image_Url()) .latitude(p.getLatitude()) .longitude(p.getLongitude()) - .authCode("") // 응답 비노출 + .authCode(p.getAuth_Code()) .build(); StoreResponse storeRes = From 78f2041151d92aebac246597c052f34dc051cd0b Mon Sep 17 00:00:00 2001 From: shinchaerin79 Date: Fri, 22 Aug 2025 03:48:20 +0900 Subject: [PATCH 04/13] =?UTF-8?q?:sparkles:=20=ED=8A=B9=EC=A0=95=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../menu/controller/MenuController.java | 10 +++++ .../domain/menu/service/MenuService.java | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/main/java/com/likelion/danchu/domain/menu/controller/MenuController.java b/src/main/java/com/likelion/danchu/domain/menu/controller/MenuController.java index 78136c3..1aabdbc 100644 --- a/src/main/java/com/likelion/danchu/domain/menu/controller/MenuController.java +++ b/src/main/java/com/likelion/danchu/domain/menu/controller/MenuController.java @@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -69,4 +70,13 @@ public ResponseEntity>> getMenus(@PathVariable L List responses = menuService.getMenus(storeId); return ResponseEntity.ok(BaseResponse.success("메뉴 전체 조회에 성공했습니다.", responses)); } + + @Operation(summary = "특정 메뉴 삭제", description = "storeId와 menuId로 특정 메뉴를 삭제합니다.") + @DeleteMapping("/{menuId}") + public ResponseEntity deleteMenu( + @Parameter(description = "가게 ID", example = "1") @PathVariable Long storeId, + @Parameter(description = "메뉴 ID", example = "3") @PathVariable Long menuId) { + menuService.deleteMenu(storeId, menuId); + return ResponseEntity.ok(BaseResponse.success("메뉴 삭제에 성공했습니다.")); + } } diff --git a/src/main/java/com/likelion/danchu/domain/menu/service/MenuService.java b/src/main/java/com/likelion/danchu/domain/menu/service/MenuService.java index be2f080..411e126 100644 --- a/src/main/java/com/likelion/danchu/domain/menu/service/MenuService.java +++ b/src/main/java/com/likelion/danchu/domain/menu/service/MenuService.java @@ -86,4 +86,43 @@ public List getMenus(Long storeId) { .map(menuMapper::toResponse) // priceFormatted 포함됨 .toList(); } + + /** + * 특정 메뉴 삭제 + * + * @param storeId 대상 가게 ID + * @param menuId 삭제할 메뉴 ID + * @throws CustomException {@code STORE_NOT_FOUND} - 가게가 존재하지 않는 경우 + * @throws CustomException {@code MENU_NOT_FOUND} - 해당 가게에 메뉴가 존재하지 않는 경우 + */ + public void deleteMenu(Long storeId, Long menuId) { + // 가게 존재 확인 + if (!storeRepository.existsById(storeId)) { + throw new CustomException( + com.likelion.danchu.domain.store.exception.StoreErrorCode.STORE_NOT_FOUND); + } + + // 메뉴 조회 (storeId 일치 여부 검증) + Menu menu = + menuRepository + .findById(menuId) + .orElseThrow(() -> new CustomException(MenuErrorCode.MENU_NOT_FOUND)); + + if (!menu.getStore().getId().equals(storeId)) { + throw new CustomException(MenuErrorCode.MENU_NOT_FOUND); // 다른 가게의 메뉴를 잘못 요청한 경우 + } + + // S3 이미지 삭제 (있을 경우) + String url = menu.getImageUrl(); // 엔티티 필드명 맞춰 사용 + if (url != null && !url.isBlank()) { + try { + s3Service.deleteByUrl(url); + } catch (Exception ignored) { + // 이미지 삭제 실패는 무시하고 메뉴 삭제 진행 + } + } + + // 메뉴 삭제 + menuRepository.delete(menu); + } } From de30c2bdf6f8747528c4ea4c37120fab1bee1d09 Mon Sep 17 00:00:00 2001 From: shinchaerin79 Date: Fri, 22 Aug 2025 04:39:08 +0900 Subject: [PATCH 05/13] =?UTF-8?q?:bug:=20=EA=B0=80=EA=B2=8C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/service/StoreHashtagService.java | 34 +++++++++++++- .../domain/store/service/StoreService.java | 44 +++++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/likelion/danchu/domain/store/service/StoreHashtagService.java b/src/main/java/com/likelion/danchu/domain/store/service/StoreHashtagService.java index 9862e95..a092a7b 100644 --- a/src/main/java/com/likelion/danchu/domain/store/service/StoreHashtagService.java +++ b/src/main/java/com/likelion/danchu/domain/store/service/StoreHashtagService.java @@ -17,6 +17,10 @@ import com.likelion.danchu.domain.hashtag.exception.HashtagErrorCode; import com.likelion.danchu.domain.hashtag.mapper.HashtagMapper; import com.likelion.danchu.domain.hashtag.repository.HashtagRepository; +import com.likelion.danchu.domain.menu.dto.response.MenuResponse; +import com.likelion.danchu.domain.menu.entity.Menu; +import com.likelion.danchu.domain.menu.mapper.MenuMapper; +import com.likelion.danchu.domain.menu.repository.MenuRepository; import com.likelion.danchu.domain.store.dto.response.PageableResponse; import com.likelion.danchu.domain.store.dto.response.StoreDistanceResponse; import com.likelion.danchu.domain.store.dto.response.StoreResponse; @@ -42,6 +46,8 @@ public class StoreHashtagService { private final StoreRepository storeRepository; private final StoreHashtagRepository storeHashtagRepository; private final StoreMapper storeMapper; + private final MenuRepository menuRepository; + private final MenuMapper menuMapper; /** * 특정 가게에 해시태그를 생성/연결하는 서비스 메서드 @@ -178,13 +184,18 @@ public PageableResponse filterStoresByHashtags( storeHashtag -> hashtagMapper.toResponse(storeHashtag.getHashtag()), Collectors.toList()))); + // menusByStoreId 생성 + Map> menusByStoreId = loadMenusByStoreIds(pageStoreIds); + // 해시태그 포함 DTO로 변환 Page responsePage = storePage.map( store -> { StoreResponse storeResponse = storeMapper.toResponse( - store, hashtagsByStoreId.getOrDefault(store.getId(), List.of())); + store, + hashtagsByStoreId.getOrDefault(store.getId(), List.of()), + menusByStoreId.getOrDefault(store.getId(), List.of())); Double meters = null; if (lat != null @@ -224,4 +235,25 @@ public PageableResponse filterStoresByHashtags( // 좌표가 없으면: 기존 순서 그대로 반환 return PageableResponse.from(responsePage); } + + /** + * 주어진 가게 ID 목록에 해당하는 메뉴들을 한 번에 조회하여 storeId 기준으로 MenuResponse 리스트로 매핑합니다. + * + * @param storeIds 메뉴를 조회할 가게 ID 리스트 + * @return Map + */ + private Map> loadMenusByStoreIds(List storeIds) { + if (storeIds == null || storeIds.isEmpty()) { + return Map.of(); + } + // N+1 방지: 한 번에 조회 + List menus = menuRepository.findByStore_IdInOrderByIdAsc(storeIds); + + // storeId 기준 그룹핑 → DTO 매핑 + return menus.stream() + .collect( + Collectors.groupingBy( + m -> m.getStore().getId(), + Collectors.mapping(menuMapper::toResponse, Collectors.toList()))); + } } diff --git a/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java b/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java index db91e59..22fa983 100644 --- a/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java +++ b/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java @@ -153,12 +153,17 @@ public PageableResponse getPaginatedStores(int page, int size) { storeHashtag -> hashtagMapper.toResponse(storeHashtag.getHashtag()), Collectors.toList()))); + // 메뉴 응답 리스트도 한 번에 로딩하여 (N+1 방지) + Map> menusByStoreId = loadMenusByStoreIds(storeIds); + // 각 가게 별로 해시태그 포함하여 DTO 변환 Page storeResponsePage = storePage.map( store -> storeMapper.toResponse( - store, hashtagsByStoreId.getOrDefault(store.getId(), List.of()))); + store, + hashtagsByStoreId.getOrDefault(store.getId(), List.of()), + menusByStoreId.getOrDefault(store.getId(), List.of()))); return PageableResponse.from(storeResponsePage); } @@ -181,11 +186,17 @@ public PageableResponse searchStoresByKeyword( Page storePage = storeRepository.findByNameContainingIgnoreCase(keyword.trim(), pageable); List stores = storePage.getContent(); + // 가게 ID 목록 추출 + List storeIds = stores.stream().map(Store::getId).toList(); // 현재 페이지 가게들의 해시태그를 한 번에 조회 (N+1 방지) Map> hashtagsByStoreId = stores.isEmpty() ? Map.of() : loadHashtagsByStoreIds(stores); + // 메뉴 한 번에 로딩 + Map> menusByStoreId = + stores.isEmpty() ? Map.of() : loadMenusByStoreIds(storeIds); + /** * 검색된 가게를 StoreDistanceResponse 형태로 변환합니다. * @@ -201,7 +212,9 @@ public PageableResponse searchStoresByKeyword( store -> { StoreResponse sr = storeMapper.toResponse( - store, hashtagsByStoreId.getOrDefault(store.getId(), List.of())); + store, + hashtagsByStoreId.getOrDefault(store.getId(), List.of()), + menusByStoreId.getOrDefault(store.getId(), List.of())); Double meters = null; if (lat != null @@ -288,6 +301,12 @@ public PageableResponse getNearbyStores( Page projPage = storeRepository.findNearby(lat, lng, radiusMeters, pageable); + // projection에서 가게 ID만 추출 + List storeIds = + projPage.getContent().stream() + .map(StoreRepository.StoreWithDistanceProjection::getId) + .toList(); + // 현재 페이지 가게들의 해시태그를 한 번에 조회 Map> hashtagsByStoreId = projPage.getContent().isEmpty() @@ -297,6 +316,10 @@ public PageableResponse getNearbyStores( .map(p -> Store.builder().id(p.getId()).build()) .toList()); + // 메뉴 배치 로딩 추가 + Map> menusByStoreId = + projPage.getContent().isEmpty() ? Map.of() : loadMenusByStoreIds(storeIds); + Page mapped = projPage.map( p -> { @@ -318,7 +341,9 @@ public PageableResponse getNearbyStores( StoreResponse storeRes = storeMapper.toResponse( - store, hashtagsByStoreId.getOrDefault(store.getId(), List.of())); + store, + hashtagsByStoreId.getOrDefault(store.getId(), List.of()), + menusByStoreId.getOrDefault(store.getId(), List.of())); double meters = p.getDistance_m(); return StoreDistanceResponse.builder() @@ -400,4 +425,17 @@ public void deleteStore(Long storeId) { storeRepository.delete(store); } + + /** 여러 가게(storeIds)에 속한 메뉴를 한 번에 조회하여 가게 ID -> 메뉴 리스트(Map) 형태로 변환합니다. */ + private Map> loadMenusByStoreIds(List storeIds) { + if (storeIds == null || storeIds.isEmpty()) { + return Map.of(); + } + + return menuRepository.findByStore_IdInOrderByIdAsc(storeIds).stream() + .collect( + Collectors.groupingBy( + m -> m.getStore().getId(), + Collectors.mapping(menuMapper::toResponse, Collectors.toList()))); + } } From 1a7a0f5f2cd755abe2cf15a79a5dd142552a6874 Mon Sep 17 00:00:00 2001 From: shinchaerin79 Date: Sat, 23 Aug 2025 00:09:00 +0900 Subject: [PATCH 06/13] =?UTF-8?q?:sparkles:=20content[i].store=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A5=BC=20=EB=A7=9E=EC=B6=94=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20StoreListItemResponse=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/StoreListItemResponse.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/java/com/likelion/danchu/domain/store/dto/response/StoreListItemResponse.java diff --git a/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreListItemResponse.java b/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreListItemResponse.java new file mode 100644 index 0000000..555798a --- /dev/null +++ b/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreListItemResponse.java @@ -0,0 +1,18 @@ +package com.likelion.danchu.domain.store.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(title = "StoreListItemResponse", description = "목록 아이템(가게 정보를 store로 래핑)") +public class StoreListItemResponse { + + @Schema(description = "가게 정보", requiredMode = Schema.RequiredMode.REQUIRED) + private StoreResponse store; +} From 135724152c0fe1a436369db265533691fadce18a Mon Sep 17 00:00:00 2001 From: shinchaerin79 Date: Sat, 23 Aug 2025 00:09:41 +0900 Subject: [PATCH 07/13] =?UTF-8?q?:recycle:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/store/controller/StoreController.java | 6 ++++-- .../danchu/domain/store/mapper/StoreMapper.java | 15 +++++++++++++++ .../danchu/domain/store/service/StoreService.java | 15 +++++++++------ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java b/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java index 5442327..5be3d56 100644 --- a/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java +++ b/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java @@ -23,6 +23,7 @@ import com.likelion.danchu.domain.store.dto.request.StoreRequest; import com.likelion.danchu.domain.store.dto.response.PageableResponse; import com.likelion.danchu.domain.store.dto.response.StoreDistanceResponse; +import com.likelion.danchu.domain.store.dto.response.StoreListItemResponse; import com.likelion.danchu.domain.store.dto.response.StoreResponse; import com.likelion.danchu.domain.store.exception.StoreErrorCode; import com.likelion.danchu.domain.store.service.StoreHashtagService; @@ -98,10 +99,11 @@ public ResponseEntity> createStore( - size : 페이지 당 보여줄 가게 수입니다. (기본값: 3) """) @GetMapping - public ResponseEntity>> getPaginatedStores( + public ResponseEntity>> getPaginatedStores( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "3") int size) { - PageableResponse storeResponses = storeService.getPaginatedStores(page, size); + PageableResponse storeResponses = + storeService.getPaginatedStores(page, size); return ResponseEntity.ok(BaseResponse.success("가게 페이징 조회에 성공했습니다.", storeResponses)); } diff --git a/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java b/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java index 97e16c3..76da72c 100644 --- a/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java +++ b/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java @@ -10,6 +10,7 @@ import com.likelion.danchu.domain.hashtag.dto.response.HashtagResponse; import com.likelion.danchu.domain.menu.dto.response.MenuResponse; import com.likelion.danchu.domain.store.dto.request.StoreRequest; +import com.likelion.danchu.domain.store.dto.response.StoreListItemResponse; import com.likelion.danchu.domain.store.dto.response.StoreResponse; import com.likelion.danchu.domain.store.entity.Store; @@ -73,4 +74,18 @@ public StoreResponse toResponse(Store store, List hashtags) { public StoreResponse toResponse(Store store) { return toResponse(store, List.of(), List.of()); } + + // StoreResponse를 store로 감싼 목록 아이템으로 변환 + public StoreListItemResponse toListItem( + Store store, List hashtags, List menus) { + return StoreListItemResponse.builder().store(toResponse(store, hashtags, menus)).build(); + } + + public StoreListItemResponse toListItem(Store store, List hashtags) { + return StoreListItemResponse.builder().store(toResponse(store, hashtags)).build(); + } + + public StoreListItemResponse toListItem(Store store) { + return StoreListItemResponse.builder().store(toResponse(store)).build(); + } } diff --git a/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java b/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java index 22fa983..ed3b958 100644 --- a/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java +++ b/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java @@ -26,6 +26,7 @@ import com.likelion.danchu.domain.store.dto.request.StoreRequest; import com.likelion.danchu.domain.store.dto.response.PageableResponse; import com.likelion.danchu.domain.store.dto.response.StoreDistanceResponse; +import com.likelion.danchu.domain.store.dto.response.StoreListItemResponse; import com.likelion.danchu.domain.store.dto.response.StoreResponse; import com.likelion.danchu.domain.store.entity.Store; import com.likelion.danchu.domain.store.entity.StoreHashtag; @@ -128,13 +129,15 @@ public StoreResponse createStore(StoreRequest storeRequest, MultipartFile imageF * @param size 한 페이지에 포함될 가게 수 * @return 페이징된 가게 목록 응답 */ - public PageableResponse getPaginatedStores(int page, int size) { + public PageableResponse getPaginatedStores(int page, int size) { PageRequest pageRequest = PageRequest.of(page, size); // 페이지당 3개 Page storePage = storeRepository.findAll(pageRequest); List stores = storePage.getContent(); if (stores.isEmpty()) { - return PageableResponse.from(storePage.map(storeMapper::toResponse)); // 그대로 빈 페이지 반환 + Page empty = + storePage.map(s -> storeMapper.toListItem(s, List.of(), List.of())); // 메뉴 빈 리스트 + return PageableResponse.from(empty); } // 현재 페이지의 가게 ID들 수집 @@ -156,16 +159,16 @@ public PageableResponse getPaginatedStores(int page, int size) { // 메뉴 응답 리스트도 한 번에 로딩하여 (N+1 방지) Map> menusByStoreId = loadMenusByStoreIds(storeIds); - // 각 가게 별로 해시태그 포함하여 DTO 변환 - Page storeResponsePage = + // StoreListItemResponse(store로 래핑) + Page wrappedPage = storePage.map( store -> - storeMapper.toResponse( + storeMapper.toListItem( store, hashtagsByStoreId.getOrDefault(store.getId(), List.of()), menusByStoreId.getOrDefault(store.getId(), List.of()))); - return PageableResponse.from(storeResponsePage); + return PageableResponse.from(wrappedPage); } /** From 9953a0fd3c34d514eadce6f80f167043a4583dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 23 Aug 2025 06:56:05 +0900 Subject: [PATCH 08/13] =?UTF-8?q?:wrench:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20monitoring=20=EC=84=A4=EC=A0=95=EA=B0=92=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources b/src/main/resources index deff0bc..b0607fb 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit deff0bc2e5091b5c3d3bd3a7829373199e2c8a64 +Subproject commit b0607fb7b7b0ee73af8510c278a468f0c9d2ea9c From 2557e86243ab627965c7a7fb3670b1395ccbfbc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 23 Aug 2025 06:57:13 +0900 Subject: [PATCH 09/13] =?UTF-8?q?:wrench:=20SecurityConfig=20prometheus=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/likelion/danchu/global/config/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/likelion/danchu/global/config/SecurityConfig.java b/src/main/java/com/likelion/danchu/global/config/SecurityConfig.java index 3cc9721..583ac37 100644 --- a/src/main/java/com/likelion/danchu/global/config/SecurityConfig.java +++ b/src/main/java/com/likelion/danchu/global/config/SecurityConfig.java @@ -56,6 +56,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**") .permitAll() + .requestMatchers("/actuator/health", "/actuator/info", "/actuator/prometheus") + .permitAll() .anyRequest() .authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); From 4227cfec6554788be5e81be020422e7ce5cde5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 23 Aug 2025 06:58:58 +0900 Subject: [PATCH 10/13] =?UTF-8?q?:heavy=5Fplus=5Fsign:=20monitoring=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index 3e4193d..e6470d5 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,10 @@ dependencies { // Flyway implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + + // Grafana & Prometheus + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { From bcf79d7dfa7ad9e8ea46acd5e5a473a1fe0f81e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 23 Aug 2025 06:59:36 +0900 Subject: [PATCH 11/13] =?UTF-8?q?:sparkles:=20prometheus.yml=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=B3=B5=EC=82=AC=20=EA=B3=BC=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 9f7d369..9bfe0f3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -51,14 +51,14 @@ jobs: token: ${{ secrets.GIT_ACTION_TOKEN }} submodules: true - - name: Copy docker-compose.yml to Server + - name: Copy compose & prometheus.yml to Server uses: appleboy/scp-action@master with: host: ${{ secrets.SSH_HOST }} port: ${{ secrets.SSH_PORT }} username: ${{ secrets.SSH_USERNAME }} key: ${{ secrets.SSH_PRIVATE_KEY }} - source: "docker-compose.yml" + source: "docker-compose.yml,prometheus.yml" target: "~/app" - name: Deploy on server From ddbde07f32cf8ec72972bbddfd72b204d6cfd057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 23 Aug 2025 07:01:05 +0900 Subject: [PATCH 12/13] =?UTF-8?q?:sparkles:=20Prometheus=20&=20Grafana=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 66 +++++++++++++++++++++++++++++++++++++++++++--- prometheus.yml | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 prometheus.yml diff --git a/docker-compose.yml b/docker-compose.yml index 02a009e..afc8691 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,6 +66,8 @@ services: depends_on: - app - dozzle + - grafana + - prometheus networks: - danchu-network restart: unless-stopped @@ -74,9 +76,66 @@ services: image: amir20/dozzle:latest container_name: dozzle volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro # 로그 접근을 위한 도커 소켓 마운트 + - /var/run/docker.sock:/var/run/docker.sock:ro # 로그 접근을 위한 도커 소켓 마운트 networks: - - danchu-network + - danchu-network + restart: unless-stopped + + grafana: + image: grafana/grafana + container_name: grafana + environment: + - GF_SERVER_ROOT_URL=https://grafana.danchu.site/ + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana-storage:/var/lib/grafana + networks: + - danchu-network + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + networks: + - danchu-network + restart: unless-stopped + + node-exporter: + image: prom/node-exporter + container_name: node-exporter + command: + - '--path.rootfs=/host' + volumes: + - '/:/host:ro,rslave' + networks: + - danchu-network + restart: unless-stopped + + mysqld-exporter: + image: prom/mysqld-exporter:v0.14.0 + container_name: mysqld-exporter + environment: + - DATA_SOURCE_NAME=exporter:${MYSQL_EXPORTER_PASSWORD}@(mysql:3306)/danchu + depends_on: + mysql: + condition: service_healthy + networks: + - danchu-network + restart: unless-stopped + + redis-exporter: + image: oliver006/redis_exporter:v1.67.0 + container_name: redis-exporter + environment: + - REDIS_ADDR=redis:6379 + depends_on: + redis: + condition: service_healthy + networks: + - danchu-network restart: unless-stopped networks: @@ -85,4 +144,5 @@ networks: volumes: mysql-data: - redis-data: \ No newline at end of file + redis-data: + grafana-storage: \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..5cbef93 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,49 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + scrape_timeout: 4s + +scrape_configs: + # Prometheus + - job_name: 'prometheus' + static_configs: + - targets: ['prometheus:9090'] + labels: + application: prometheus + service: prometheus + + # Spring Boot + - job_name: 'danchu' + metrics_path: /actuator/prometheus + static_configs: + - targets: ['app:8080'] + labels: + application: danchu + service: app + component: spring-boot + + # node-exporter + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + labels: + application: danchu + service: node-exporter + + # MySQL + - job_name: 'mysql' + static_configs: + - targets: ['mysqld-exporter:9104'] + labels: + application: danchu + service: mysql + component: exporter + + # Redis + - job_name: 'redis' + static_configs: + - targets: ['redis-exporter:9121'] + labels: + application: danchu + service: redis + component: exporter \ No newline at end of file From f1356c67868a4085c8c35b0208b771c6542dea93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 23 Aug 2025 14:00:08 +0900 Subject: [PATCH 13/13] =?UTF-8?q?:wrench:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20AccessToken=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources b/src/main/resources index b0607fb..b8a089b 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit b0607fb7b7b0ee73af8510c278a468f0c9d2ea9c +Subproject commit b8a089b2f7846e90aae0f901887b5ea4ba983f75