From a0e8878d9ab2678233ee5151a1b64020352e4eb2 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 14:48:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20P5-2=20=EC=9E=91=EC=9D=80=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D=20(#338+#320+#340+#333)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #338: /api/version 신규 - HealthController에 @Value 빌드 정보 + GET /api/version 추가 - SecurityConfig.permitAll에 /api/version 추가 - application.yml app.build.version/commit (env APP_VERSION/APP_COMMIT) - 부수: SecurityConfig에서 /api/daemon/config permitAll 제거 (이미 admin-only) #320: findRegionFromCoords 거리 보정 - 유클리드 거리 → cos(lat) 가중치(equirectangular approx)로 위경도 실거리 보정 - 위도가 큰 지역(부산↔서울)에서 city 추정 정확도 향상 #340: MapView 마커/범례 ARIA - 클러스터 마커: role=button + aria-label - 개별 식당 마커: role=button + aria-label (name + 폐업 여부) - 채널 범례: role=region + aria-label, 색상 점은 aria-hidden #333: ChannelController 캐시 세분화 - cache.flush() 전체 무효화 → cache.del(makeKey("channels"))로 채널 키만 evict - 다른 모듈(restaurants/search) 캐시 hit율 보존 후속: deploy.sh에 APP_VERSION/APP_COMMIT env 주입은 별도 (현재 dev/unknown 응답) Refs: #338 #320 #340 #333 --- .../java/com/tasteby/config/SecurityConfig.java | 3 ++- .../com/tasteby/controller/ChannelController.java | 3 ++- .../com/tasteby/controller/HealthController.java | 13 +++++++++++++ backend-java/src/main/resources/application.yml | 5 +++++ frontend/src/app/page.tsx | 7 ++++++- frontend/src/components/MapView.tsx | 14 ++++++++++++-- 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/backend-java/src/main/java/com/tasteby/config/SecurityConfig.java b/backend-java/src/main/java/com/tasteby/config/SecurityConfig.java index 10c2eaa..580549f 100644 --- a/backend-java/src/main/java/com/tasteby/config/SecurityConfig.java +++ b/backend-java/src/main/java/com/tasteby/config/SecurityConfig.java @@ -30,13 +30,14 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth // Public endpoints .requestMatchers("/api/health").permitAll() + .requestMatchers("/api/version").permitAll() // #338 — 빌드 정보 공개 .requestMatchers("/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels").permitAll() .requestMatchers(HttpMethod.GET, "/api/search").permitAll() .requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll() .requestMatchers("/api/stats/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/daemon/config").permitAll() + // #275 — /api/daemon/config는 admin-only로 변경 (이전 permitAll 제거) // Everything else requires authentication (controller-level admin checks) .anyRequest().authenticated() ) diff --git a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java index eec618a..a7cebb3 100644 --- a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java +++ b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java @@ -62,7 +62,8 @@ public class ChannelController { } try { String id = channelService.create(channelId, channelName, titleFilter); - cache.flush(); + // #333 — 전체 flush 대신 channels 키만 evict (다른 모듈 캐시 보존) + cache.del(cache.makeKey("channels")); return Map.of("id", id, "channel_id", channelId); } catch (DataIntegrityViolationException e) { // #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고). diff --git a/backend-java/src/main/java/com/tasteby/controller/HealthController.java b/backend-java/src/main/java/com/tasteby/controller/HealthController.java index 735c34e..b220d7f 100644 --- a/backend-java/src/main/java/com/tasteby/controller/HealthController.java +++ b/backend-java/src/main/java/com/tasteby/controller/HealthController.java @@ -1,5 +1,6 @@ package com.tasteby.controller; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -8,8 +9,20 @@ import java.util.Map; @RestController public class HealthController { + // #338 — 배포 시 set되는 빌드 정보. 미설정 시 "dev"로 표시. + @Value("${app.build.version:dev}") + private String version; + + @Value("${app.build.commit:unknown}") + private String commit; + @GetMapping("/api/health") public Map health() { return Map.of("status", "ok"); } + + @GetMapping("/api/version") + public Map version() { + return Map.of("version", version, "commit", commit); + } } diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index b997d83..acf899b 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -64,6 +64,11 @@ app: # 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값. max-distance: ${SEARCH_MAX_DISTANCE:0.57} + build: + # #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown. + version: ${APP_VERSION:dev} + commit: ${APP_COMMIT:unknown} + daemon: # 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로 # dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다. diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ff46e54..9ce6335 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -168,10 +168,15 @@ function findRegionFromCoords( } let best: { country: string; city: string } | null = null; let bestDist = Infinity; + // #320 — 유클리드 거리는 적도/극지에서 경도·위도의 실거리 차이가 커서 왜곡됨. + // cos(lat) 가중치(equirectangular approximation)로 위도 의존 보정. + const cosLat = Math.cos((lat * Math.PI) / 180); for (const g of groups.values()) { const cLat = g.lats.reduce((a, b) => a + b, 0) / g.lats.length; const cLng = g.lngs.reduce((a, b) => a + b, 0) / g.lngs.length; - const dist = (cLat - lat) ** 2 + (cLng - lng) ** 2; + const dLat = cLat - lat; + const dLng = (cLng - lng) * cosLat; + const dist = dLat * dLat + dLng * dLng; if (dist < bestDist) { bestDist = dist; best = { country: g.country, city: g.city }; diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index 85db334..a3ad87e 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -209,6 +209,8 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh zIndex={100} >
handleMarkerClick(r)} zIndex={isSelected ? 1000 : 1} > -
+
)} {channelNames.length > 0 && ( -
+
{channelNames.map((ch) => (