diff --git a/.env.dev.example b/.env.dev.example index 0595d360..6a28c3a9 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -11,6 +11,21 @@ REDIS_PORT=6379 JWT_SECRET= +# 키 준비와 공개 활성화는 별개다. ingress·정상 트래픽 용량·후보 규모/byte 예산 검증 전 비활성화한다 (#252). +HASHI_RESTAURANT_MAP_SESSION_ENABLED=false +HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY= +# shared는 기존 ElastiCache의 volatile-lru 정책을 유지한다. 접속값은 명시하며 자동 대체하지 않는다. +# dedicated는 별도 noeviction Redis. 최초 shared 사용/회계 유실 후 30분간 지도 요청은 503이다. +HASHI_RESTAURANT_MAP_REDIS_MODE=dedicated +# 로컬 격리 검증용 컨테이너: docker/docker-compose.map-redis.local.yml +HASHI_RESTAURANT_MAP_REDIS_HOST= +HASHI_RESTAURANT_MAP_REDIS_PORT=6379 +HASHI_RESTAURANT_MAP_REDIS_SSL=false +HASHI_RESTAURANT_MAP_REDIS_USERNAME= +HASHI_RESTAURANT_MAP_REDIS_PASSWORD= +HASHI_RESTAURANT_MAP_REDIS_CONNECTTIMEOUT=2s +HASHI_RESTAURANT_MAP_REDIS_TIMEOUT=3s + KAKAO_CLIENT_ID= KAKAO_CLIENT_SECRET= KAKAO_REDIRECT_URI= @@ -30,5 +45,12 @@ CLOUDFRONT_DOMAIN= CORS_ALLOWED_ORIGINS=http://localhost:5173 CORS_ALLOWED_ORIGIN_PATTERNS= +# Google Places 관리자 위치 검토는 기본 비활성화다. 실제 비밀값은 이 예시 파일에 기록하지 않는다. +HASHI_MAP_GOOGLE_PLACES_ENABLED=false +HASHI_MAP_GOOGLE_PLACES_API_KEY= +HASHI_MAP_PLACES_SELECTION_ENABLED=false +HASHI_MAP_PLACES_SELECTION_SIGNING_KEY= +HASHI_MAP_PLACES_SELECTION_TOKEN_TTL=10m + GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_PASSWORD='' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 291dcb1a..150796a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,14 +134,52 @@ jobs: run: ./gradlew clean build --no-daemon - name: Verify test execution + if: ${{ always() }} run: | python3 - <<'PY' from pathlib import Path + import re import xml.etree.ElementTree as ET report_root = Path("build/test-results/test") reports = sorted(report_root.glob("TEST-*.xml")) assert reports, "Test reports are missing" + + java_identifier = r"[A-Za-z_$][A-Za-z0-9_$]*" + qualified_identifier = rf"{java_identifier}(?:\.{java_identifier})+" + exception_pattern = re.compile( + rf"^(?:Caused by:\s+)?({qualified_identifier})(?::|$)" + ) + failed_tests = set() + failure_types = [] + for report in reports: + suite = ET.parse(report).getroot() + for testcase in suite.findall("testcase"): + outcome = testcase.find("failure") + if outcome is None: + outcome = testcase.find("error") + if outcome is None: + continue + + class_name = testcase.attrib.get("classname", "") + stack = outcome.text or "" + frame = re.search( + rf"\bat\s+{re.escape(class_name)}\.([^\s.(]+)\(", + stack, + ) + method_name = frame.group(1) if frame else "" + failed_tests.add(f"{class_name}#{method_name}") + for line in stack.splitlines(): + sanitized = line.strip() + exception = exception_pattern.match(sanitized) + if exception and exception.group(1) not in failure_types: + failure_types.append(exception.group(1)) + + for failed_test in sorted(failed_tests): + print(f"Failed test: {failed_test}") + for failure_type in failure_types[:8]: + print(f"Failure type: {failure_type}") + assert (report_root / "TEST-org.sopt.hashi.ModularityTests.xml").is_file(), \ "Module verification did not produce a test report" @@ -156,3 +194,15 @@ jobs: print(f"Test suites: {len(reports)}, tests: {total}, failures/errors/skips: 0") PY + + - name: Upload test diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: | + build/test-results/test + build/reports/tests/test + build/reports/problems + if-no-files-found: warn + retention-days: 7 diff --git a/build.gradle b/build.gradle index 4eebaa6b..c9ea087b 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,8 @@ ext { dependencies { // web / docs implementation 'org.springframework.boot:spring-boot-starter-web' + // Geocoding: RestClient transport with explicit retry/redirect disabling (Boot-managed version). + implementation 'org.apache.httpcomponents.client5:httpclient5' implementation 'org.springframework.boot:spring-boot-starter-actuator' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' // 로그 JSON 인코딩(Loki 수집용) — logback-spring.xml에서 사용 @@ -94,6 +96,7 @@ dependencyManagement { tasks.named('bootJar') { archiveFileName = 'app.jar' + mainClass = 'org.sopt.hashi.HashiApplication' } tasks.named('jar') { @@ -101,7 +104,15 @@ tasks.named('jar') { } tasks.named('test') { - useJUnitPlatform() + // The maintenance smoke test launches the same artifact shipped by the existing build. + dependsOn tasks.named('bootJar') + systemProperty 'location.maintenance.jar', tasks.named('bootJar').get().archiveFile.get().asFile.absolutePath + useJUnitPlatform { + excludeTags 'map-load', 'map-live' + } + // Limit retained Spring contexts during the full suite. + maxParallelForks = 1 + systemProperty 'spring.test.context.cache.maxSize', '8' testLogging { exceptionFormat = 'full' } @@ -116,8 +127,46 @@ tasks.named('test') { ).withPropertyName('mediaBackfillInfrastructure') } +// Explicit local capacity experiment; ordinary CI remains deterministic and does not require k6. +tasks.register('mapLoadTest', Test) { + description = 'Runs the isolated MySQL/Redis HTTP map workload using a local k6 binary.' + group = 'verification' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'map-load' } + systemProperty 'map.k6.executable', providers.gradleProperty('k6Executable').getOrElse('k6') + systemProperty 'map.k6.script', file('scripts/load/map-query.js').absolutePath + systemProperty 'map.k6.output', layout.buildDirectory.dir('reports/map-load').get().asFile.absolutePath + systemProperty 'map.k6.profile', providers.gradleProperty('mapLoadProfile').getOrElse('smoke') + systemProperty 'map.k6.traffic', providers.gradleProperty('mapLoadTraffic').getOrElse('shared') + systemProperty 'map.k6.restaurants', providers.gradleProperty('mapLoadRestaurants').getOrElse('620') + systemProperty 'map.k6.budget-mib', providers.gradleProperty('mapLoadBudgetMiB').getOrElse('16') + systemProperty 'hashi.restaurant.map.session.limits.concurrent-requests', + providers.gradleProperty('mapLoadConcurrentRequests').getOrElse('4') + outputs.upToDateWhen { false } + maxParallelForks = 1 + testLogging { exceptionFormat = 'full' } +} + tasks.named('processResources') { from('media-specs') { into 'media-specs' } } + +// Real Google is excluded from ordinary CI and requires explicit opt-in plus an environment-only key. +tasks.register('mapLiveTest', Test) { + group = 'verification' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'map-live' } + systemProperty 'map.live.enabled', providers.gradleProperty('mapLive').getOrElse('false') + outputs.upToDateWhen { false } + maxParallelForks = 1 + doFirst { + if (providers.gradleProperty('mapLive').getOrElse('false') != 'true' + || !System.getenv('HASHI_MAP_GOOGLEGEOCODING_APIKEY')) { + throw new GradleException('mapLiveTest requires -PmapLive=true and an environment API key') + } + } +} diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 0be9943a..8e5902b1 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -37,6 +37,18 @@ services: JWT_SECRET: ${JWT_SECRET} + HASHI_RESTAURANT_MAP_SESSION_ENABLED: ${HASHI_RESTAURANT_MAP_SESSION_ENABLED:-false} + HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY: ${HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY:-} + # shared는 기존 ElastiCache 재사용, dedicated는 별도 noeviction Redis. 호스트는 명시한다. + HASHI_RESTAURANT_MAP_REDIS_MODE: ${HASHI_RESTAURANT_MAP_REDIS_MODE:-dedicated} + HASHI_RESTAURANT_MAP_REDIS_HOST: ${HASHI_RESTAURANT_MAP_REDIS_HOST:-} + HASHI_RESTAURANT_MAP_REDIS_PORT: ${HASHI_RESTAURANT_MAP_REDIS_PORT:-6379} + HASHI_RESTAURANT_MAP_REDIS_SSL: ${HASHI_RESTAURANT_MAP_REDIS_SSL:-false} + HASHI_RESTAURANT_MAP_REDIS_USERNAME: ${HASHI_RESTAURANT_MAP_REDIS_USERNAME:-} + HASHI_RESTAURANT_MAP_REDIS_PASSWORD: ${HASHI_RESTAURANT_MAP_REDIS_PASSWORD:-} + HASHI_RESTAURANT_MAP_REDIS_CONNECTTIMEOUT: ${HASHI_RESTAURANT_MAP_REDIS_CONNECTTIMEOUT:-2s} + HASHI_RESTAURANT_MAP_REDIS_TIMEOUT: ${HASHI_RESTAURANT_MAP_REDIS_TIMEOUT:-3s} + KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI} @@ -47,6 +59,12 @@ services: CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGIN_PATTERNS: ${CORS_ALLOWED_ORIGIN_PATTERNS:-} + HASHI_MAP_GOOGLE_PLACES_ENABLED: ${HASHI_MAP_GOOGLE_PLACES_ENABLED:-false} + HASHI_MAP_GOOGLE_PLACES_API_KEY: ${HASHI_MAP_GOOGLE_PLACES_API_KEY:-} + HASHI_MAP_PLACES_SELECTION_ENABLED: ${HASHI_MAP_PLACES_SELECTION_ENABLED:-false} + HASHI_MAP_PLACES_SELECTION_SIGNING_KEY: ${HASHI_MAP_PLACES_SELECTION_SIGNING_KEY:-} + HASHI_MAP_PLACES_SELECTION_TOKEN_TTL: ${HASHI_MAP_PLACES_SELECTION_TOKEN_TTL:-10m} + prometheus: image: prom/prometheus:v2.53.1 container_name: hashi-dev-prometheus diff --git a/docker/docker-compose.map-redis.local.yml b/docker/docker-compose.map-redis.local.yml new file mode 100644 index 00000000..2e2b9aa1 --- /dev/null +++ b/docker/docker-compose.map-redis.local.yml @@ -0,0 +1,20 @@ +# 로컬 연결 검증 전용. dev/prod compose에 자동으로 추가하지 않는다. +services: + map-redis: + image: redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499 + command: [redis-server, --maxmemory, 64mb, --maxmemory-policy, noeviction, --appendonly, "no", --save, ""] + ports: + - "127.0.0.1:16379:6379" + mem_limit: 128m + memswap_limit: 128m + cpus: 0.5 + healthcheck: + test: [CMD, redis-cli, ping] + interval: 5s + timeout: 2s + retries: 3 + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 6f2056fb..6a0c7522 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -40,6 +40,18 @@ services: JWT_SECRET: ${JWT_SECRET} + HASHI_RESTAURANT_MAP_SESSION_ENABLED: ${HASHI_RESTAURANT_MAP_SESSION_ENABLED:-false} + HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY: ${HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY:-} + # shared는 기존 ElastiCache 재사용, dedicated는 별도 noeviction Redis. 호스트는 명시한다. + HASHI_RESTAURANT_MAP_REDIS_MODE: ${HASHI_RESTAURANT_MAP_REDIS_MODE:-dedicated} + HASHI_RESTAURANT_MAP_REDIS_HOST: ${HASHI_RESTAURANT_MAP_REDIS_HOST:-} + HASHI_RESTAURANT_MAP_REDIS_PORT: ${HASHI_RESTAURANT_MAP_REDIS_PORT:-6379} + HASHI_RESTAURANT_MAP_REDIS_SSL: ${HASHI_RESTAURANT_MAP_REDIS_SSL:-false} + HASHI_RESTAURANT_MAP_REDIS_USERNAME: ${HASHI_RESTAURANT_MAP_REDIS_USERNAME:-} + HASHI_RESTAURANT_MAP_REDIS_PASSWORD: ${HASHI_RESTAURANT_MAP_REDIS_PASSWORD:-} + HASHI_RESTAURANT_MAP_REDIS_CONNECTTIMEOUT: ${HASHI_RESTAURANT_MAP_REDIS_CONNECTTIMEOUT:-2s} + HASHI_RESTAURANT_MAP_REDIS_TIMEOUT: ${HASHI_RESTAURANT_MAP_REDIS_TIMEOUT:-3s} + KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI} @@ -50,6 +62,12 @@ services: CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGIN_PATTERNS: ${CORS_ALLOWED_ORIGIN_PATTERNS:-} + HASHI_MAP_GOOGLE_PLACES_ENABLED: ${HASHI_MAP_GOOGLE_PLACES_ENABLED:-false} + HASHI_MAP_GOOGLE_PLACES_API_KEY: ${HASHI_MAP_GOOGLE_PLACES_API_KEY:-} + HASHI_MAP_PLACES_SELECTION_ENABLED: ${HASHI_MAP_PLACES_SELECTION_ENABLED:-false} + HASHI_MAP_PLACES_SELECTION_SIGNING_KEY: ${HASHI_MAP_PLACES_SELECTION_SIGNING_KEY:-} + HASHI_MAP_PLACES_SELECTION_TOKEN_TTL: ${HASHI_MAP_PLACES_SELECTION_TOKEN_TTL:-10m} + # 운영은 API 문서를 노출하지 않는다 (springdoc.api-docs.enabled / springdoc.swagger-ui.enabled) SPRINGDOC_API_DOCS_ENABLED: "false" SPRINGDOC_SWAGGER_UI_ENABLED: "false" diff --git a/docker/prometheus/rules/map-redis-alerts.example.yml b/docker/prometheus/rules/map-redis-alerts.example.yml new file mode 100644 index 00000000..469ece95 --- /dev/null +++ b/docker/prometheus/rules/map-redis-alerts.example.yml @@ -0,0 +1,51 @@ +# Copy the expressions into Grafana alert rules and select the existing notification policy. +# This file is not mounted or loaded automatically; it contains no contact point or credentials. +groups: + - name: hashi-map-redis + rules: + - alert: HashiMapRedisMemoryWarning + expr: | + (hashi_restaurant_map_redis_used_bytes > hashi_restaurant_map_redis_warning_limit_bytes) + and (hashi_restaurant_map_redis_probe_success == 1) + and (hashi_restaurant_map_redis_enabled == 1) + for: 5m + labels: + severity: warning + component: map-redis + annotations: + summary: "Redis memory is approaching the map admission limit" + description: "Check total Redis growth and map reservations before map requests are rejected." + - alert: HashiMapRedisNearAdmissionLimit + expr: | + (hashi_restaurant_map_redis_used_bytes > 0.9 * hashi_restaurant_map_redis_admission_limit_bytes) + and (hashi_restaurant_map_redis_probe_success == 1) + and (hashi_restaurant_map_redis_enabled == 1) + for: 1m + labels: + severity: critical + component: map-redis + annotations: + summary: "Redis memory is close to stopping map admission" + description: "Keep authentication Redis settings unchanged and inspect map and non-map memory growth." + - alert: HashiMapRedisEvictionObserved + expr: | + (hashi_restaurant_map_redis_evicted_keys - hashi_restaurant_map_redis_evicted_keys offset 1m > 0) + and (hashi_restaurant_map_redis_probe_success == 1) + and (hashi_restaurant_map_redis_enabled == 1) + labels: + severity: critical + component: map-redis + annotations: + summary: "The shared Redis instance has evicted keys" + description: "This is an instance-wide count; inspect authentication as well as map session failures." + - alert: HashiMapRedisProbeUnavailable + expr: | + (hashi_restaurant_map_redis_probe_success == 0) + and (hashi_restaurant_map_redis_enabled == 1) + for: 2m + labels: + severity: warning + component: map-redis + annotations: + summary: "Map Redis telemetry is unavailable" + description: "Inspect connection, TLS, ACL and map configuration; previous memory values are not current." diff --git a/docs/adr/0002-restaurant-map-query-and-location.md b/docs/adr/0002-restaurant-map-query-and-location.md index be963520..9d381795 100644 --- a/docs/adr/0002-restaurant-map-query-and-location.md +++ b/docs/adr/0002-restaurant-map-query-and-location.md @@ -2,6 +2,7 @@ - 상태: #219에서 채택한 후속 구현 기준. 이 문서의 병합은 기능 구현·운영 활성화를 뜻하지 않는다. - 결정일: 2026-09-26 +- 갱신: 2026-10-06, #225에서 좌표 유지와 조회 세션 수명 정책 보완 - 관련 이슈: [#219](https://github.com/TEAM-HASHI/HASHI-SERVER/issues/219), [#216](https://github.com/TEAM-HASHI/HASHI-SERVER/issues/216) - 외부 계약: [Map Contract v1](../map/map-contract-v1.md) - 구현·인수 계획: [Implementation Plan](../map/implementation-plan.md) @@ -65,15 +66,28 @@ M1에서는 새 공개 이벤트를 도입하지 않는다. 같은 모듈 안의 - MySQL: 식당·위치·관광 지역·변환 작업·저장 관계의 원본, BBOX·필터 후보 검색. - Redis: 조회 조건, 후보 ID, 추천 순서, 별점·리뷰 수 기준값의 일시적인 조회 세션. - 생성 후 절대 TTL 15분을 초기 설정으로 채택한다. 읽기·정렬 변경으로 연장하지 않는다. + 조회 ID별 키 하나와 TTL부터 검증한다. 유휴 5분·최대 수명 30분을 초기값으로 두고, + 정상적인 다음 페이지 조회·재정렬 때 `min(현재 시각 + 유휴 시간, 최초 생성 + 최대 수명)`으로 갱신한다. + 유휴 시간은 사용 간격·만료 비율·메모리 측정으로 우선 5~10분 안에서 조정한다. 최적값으로 단정하지 않는다. - 세션에 식당 사진·Google 좌표/원본 응답·개인 저장 여부를 복제하지 않는다. - 현재 DB의 삭제·위치 수명·필터를 매 페이지 다시 확인한다. 신규 후보·최신 순위는 새 조회부터 반영한다. + 작은 묶음으로 유효한 10개와 다음 1개를 확보하면 멈추며, 확인한 11번째는 다음 페이지에 남긴다. - 유실·만료는 전용 410, Redis 접근 장애는 503으로 구분한다. 새 순서를 기존 cursor에 연결하지 않는다. - 세션을 전부 저장할 수 없으면 명시적으로 실패한다. 일부 후보만 성공처럼 저장하지 않는다. 현재 Redis 설정이 있다는 사실은 지도 용량 확보의 증거가 아니다. 인증 토큰과 같은 인스턴스를 쓴다면 memory·eviction 영향을 확인하고 세션별/전체 admission 한도를 측정한다. 키 prefix는 메모리 격리 수단이 아니다. 이 결정은 성능 향상이 검증됐다는 뜻이 아니다. +여러 키로 후보를 나누거나 정렬별 자료구조를 중복 보관하는 최적화는 측정상 필요할 때 검토한다. +갱신 실패·동시 요청·만료 직후 요청의 규칙과 용량 인수 기준은 [구현 계획](../map/implementation-plan.md)에 둔다. + +### 2.4 좌표와 갱신 작업 상태 + +같은 주소의 갱신은 현재 작업 상태만 바꾸고 기존 검증 좌표·출처·취득/만료 시각을 유지한다. +유효한 기존 좌표는 갱신 대기·재시도·실패 중에도 사용할 수 있다. 주소 변경 시에는 바로 제거하며, +새 결과가 검증을 통과했을 때만 교체한다. 만료 시각을 넘겨 사용하거나 실패만으로 수명을 연장하지 않는다. +정리 작업은 갱신 상태와 관계없이 보관 기한을 지키되 진행 중 작업을 취소하지 않아야 한다. +이 모델 전이는 #225에서 구현하며, 조회 SQL·worker·수명 정리의 연결은 후속 PR의 공개 전 조건이다. ## 3. 대안과 영향 diff --git a/docs/adr/0003-restaurant-location-jobs.md b/docs/adr/0003-restaurant-location-jobs.md new file mode 100644 index 00000000..5b2c1857 --- /dev/null +++ b/docs/adr/0003-restaurant-location-jobs.md @@ -0,0 +1,77 @@ +# ADR 0003: 식당 위치 확인 작업과 호출 예산 + +- 범위: #224. 선행 계약은 [M1 #219](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/221). +- 운영 Google 활성화, 기존 식당 backfill, 만료 전 갱신과 보관 데이터 정리는 별도 gate다. + +## 결정 + +식당 등록/주소 변경과 처리할 작업을 같은 MySQL transaction에 저장한다. 메모리 이벤트만으로 +commit 직후의 종료를 복구할 수 없으므로 `restaurant_location_job`을 Restaurant의 작업 이력으로 둔다. +주소, 좌표, Google 원문은 작업 테이블에 복사하지 않는다. 소유 식당의 ID만 FK로 연결한다. + +별도 `RestaurantLocationWorker` Bean이 polling → claim → HTTP/판정 → 완료를 수행한다. +worker는 transaction이 있으면 실행을 거절한다. 별도 `LocationJobTransactions` Bean의 claim/완료는 +`REQUIRES_NEW`, `READ_COMMITTED`다. HTTP는 claim commit 뒤에 시작한다. +모든 쓰기 경로는 식당 → 해당 작업(복수이면 ID 순) → 전역 예산 순서로 잠근다. +관리자 저장은 예산 행을 잠그지 않으며 외부 호출도 하지 않는다. + +관리자 등록/수정/삭제/재처리의 Port와 Service 진입점도 READ_COMMITTED로 맞춘다. +기본 REPEATABLE_READ에서는 비어 있는 작업 범위의 FOR UPDATE 뒤 INSERT가 서로 다른 식당의 +동시 등록에서도 gap lock deadlock을 일으켰다. 부모 식당 잠금으로 같은 식당의 변경을 직렬화하면서 +범위 gap lock을 피한다. 후속 작업이 enqueue/cancel을 호출할 때도 이 transaction 조건을 지킨다. + +HTTP를 기다리는 polling cycle은 전용 단일 스레드 scheduler에서 실행한다. cycle 중복 적재가 없고, +기존 미디어 작업의 기본 scheduler를 점유하지 않는다. 전용 Bean은 defaultCandidate=false로 두어 +Spring Boot의 기본 scheduler 구성을 유지한다. 종료 중인 호출은 중단 후 lease 만료로 복구한다. + +`addressRevision`, `requestId`, 작업 ID, lease token, lease 만료와 soft delete를 완료 시 다시 검사한다. +마지막 예산 잠금을 얻은 뒤 DB 시각을 다시 읽는다. 유효하지 않은 성공/실패는 모두 no-op이다. +주소 변경/삭제로 대체된 작업도 이미 예약한 동시 슬롯은 원래 기한까지 유지한다. +HTTP 자체는 중복 실행될 수 있다. DB 완료 실패나 crash 후에는 lease 만료를 기다렸다가 재처리한다. + +자동 재시도는 같은 작업 행에서 새로운 M2 requestId를 이어받고 attempt는 보존한다. +최초 요청을 포함해 기본 8회다. 실패 후 1분·5분·30분·2시간·6시간·12시간·24시간을 기다리고, +동시 재요청을 분산하도록 간격의 0~10%를 더한다. 취소도 마지막 시도라면 즉시 종료한다. +같은 주소의 이전 좌표는 작업 상태와 별개로 원래 validUntil까지 유지한다. 주소 변경 시에는 제거한다. +관리자 재처리와 주소 변경은 새 작업을 만들며 누적 전역 호출 예산을 우회하지 않는다. +PENDING 재처리는 기존 작업을 돌려준다. 작업 행은 최소 처리 이력만 남긴다. + +## 환경 독립적인 제어 행 + +`V39__add_restaurant_location_jobs.sql`은 `restaurant_geocoding_budget(id=1)`을 `enabled=false`, `daily_limit=0`, +`max_concurrent=0`, `reserved_calls=0`으로 최초 생성한다. 이는 업무 데이터/운영 seed가 아닌 +안전한 필수 제어 상태이므로 database convention의 제어 데이터 예외를 사용한다. +시작 코드나 repeatable migration으로 값을 초기화하지 않는다. 행 누락도 호출을 차단한다. + +서버별 application 설정으로 전역 한도를 각각 계산하면 서버 수에 따라 비용이 늘어난다. +따라서 같은 DB 행 잠금 아래 UTC 일자·예약량·동시 실행 예약 수를 검사한다. 호출 직전 claim의 +commit에 예산을 보수적으로 예약한다. 실제 전송 여부가 불확실해도 일일 사용량을 환급하지 않는다. +timeout/cancel의 실행 슬롯은 lease 기한까지 유지하고 같은 작업을 그 전에 다시 실행하지 않는다. +공유 quota 대기 중에는 다른 식당의 작업과 관리자 재처리도 호출할 수 없다. +마지막 자동 시도에서 quota 오류를 받아도 공유 대기는 기록하며 해당 작업만 FAILED로 끝낸다. + +동시 예약 count는 예산 잠금을 얻은 후 최신 commit을 읽어야 하므로 READ_COMMITTED를 사용한다. +식당/작업은 부모 잠금으로 직렬화한다. 전역 예산만 변경하는 운영 transaction은 예산 행만 잠그고 +식당/작업을 추가로 잠그지 않아야 한다. 중단은 이미 예약된 호출을 소급 취소하지 않는다. + +## UTC 기준 + +lease·재시도·일일 예산 기준은 DB `UTC_TIMESTAMP(6)`이다. 이 시각은 DATE_FORMAT 문자열로 읽어 +JDBC Timestamp의 connectionTimeZone 변환을 피한다. 새 job/budget DATETIME 필드는 +`SqlTypes.LOCAL_DATE_TIME`으로 직접 읽고 쓴다. M2 위치 시각도 같은 선행 보완을 사용한다. +응답은 UTC Instant의 ISO-8601이다. 애플리케이션 Clock은 enqueue 이력 시각에 instant 기준으로만 쓴다. + +Google 좌표 수명은 예약 직전 DB 시각을 보수적인 취득 기준으로 사용한다. `validUntil`은 이 값에 +명시된 retention을 더해 계산한다. 응답 도착/DB 저장 지연은 수명을 연장하지 않는다. +재시도는 새 호출이므로 그 호출의 새 취득 기준을 사용하며 이전 결과/만료일을 재활용하지 않는다. + +## 영향 + +두 테이블과 한 제어 행이 추가된다. 기존 migration과 주소/관광 지역 ID는 유지한다. +새 dependency, 인증 규칙 변경, Redis 의존은 없다. 관리자 저장 응답은 기존 201/200 성공 코드를 +유지하고 상태/revision만 추가한다. 기존 위치 없는 식당은 UNRESOLVED/revision 0이며 일반 조회를 유지한다. +일반 정보만 수정할 때 새 작업이나 revision을 만들지 않는다. +삭제된 식당의 기존 관리자 편집 허용 정책은 유지하되 새 위치 작업은 등록하지 않는다. + +테스트용 fake provider와 실제 MySQL에서 저장 원자성, lease 경합, 늦은 성공/실패, retry 예산, +JDBC Asia/Seoul 세션의 UTC 원시 값과 왕복을 검증한다. 실행 결과는 PR의 최종 HEAD evidence로 확인한다. diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md index 8ebcdfc5..8c90714e 100644 --- a/docs/conventions/architecture.md +++ b/docs/conventions/architecture.md @@ -199,7 +199,8 @@ - **MUST**: `auth`는 `@Modulithic(sharedModules = "auth")`로 등록한다(횡단 관심사). - **MUST**: 인증 **강제**는 Spring Security 필터 체인이 담당한다. 도메인 모듈은 `auth.internal`을 import하지 않고, `auth`가 공개한 `CurrentUserProvider`로 현재 사용자를 읽는다(`SecurityContextHolder` 직접 접근 금지). actor 유형까지 필요한 media는 현재 공개 지점인 `CurrentActorProvider`를 사용한다. -- **MUST**: 의존 방향은 **도메인 → auth**다. 현재 공개 지점은 `CurrentUserProvider`, `CurrentActorProvider`, `AuthAccountPort`다. **`auth`는 어떤 도메인 모듈도 되참조하지 않는다**(순환 방지). auth가 도메인을 관찰해야 하면 **이벤트**로 붙이되, 회원 삭제와 인증 계정 정리처럼 **함께 커밋돼야 하는** 처리는 도메인이 `AuthAccountPort`를 호출한다(탈퇴 — `auth.md` §3). 상세는 `auth.md` §1 참조. +- **MUST**: 의존 방향은 **도메인 → auth**다. 현재 공개 지점은 `CurrentUserProvider`, `CurrentActorProvider`, `AuthAccountPort`, `AdminAccountPort`다. **`auth`는 어떤 도메인 모듈도 되참조하지 않는다**(순환 방지). auth가 도메인을 관찰해야 하면 **이벤트**로 붙이되, 회원 삭제와 인증 계정 정리처럼 **함께 커밋돼야 하는** 처리는 도메인이 `AuthAccountPort`를 호출한다(탈퇴 — `auth.md` §3). 상세는 `auth.md` §1 참조. +- **MUST**: `AdminAccountPort`는 관리자 화면의 확인자 표시에만 사용하는 읽기 전용 capability다. `admin` 진입점이 저장된 관리자 ID 목록으로 현재 로그인 ID를 일괄 조회하며, 포트도 ADMIN 인증을 확인한다. 계정 엔티티·비밀번호는 공개하지 않고 없는 계정은 제외한다. 회원 계정 연결·탈퇴를 담당하는 `AuthAccountPort`와 구분한다. - **MUST NOT**: 도메인 모듈이 인증 로직을 직접 구현하지 않는다. - **MUST**: `admin`은 진입점 모듈로, **도메인 로직을 두지 않는다.** 각 컨텍스트의 `Port`로 위임만 한다. - **MUST NOT**: `admin`이 타 모듈의 `internal`/Repository/엔티티에 직접 접근하지 않는다. @@ -232,7 +233,7 @@ review → restaurant, reservation, point, user (작성자 닉네임·프사 e reservation → restaurant, user, point (user의 WithdrawalBlocker 구현 포함 — 탈퇴 조건) magazine → restaurant (관련 식당 큐레이션, 매핑 테이블 + RestaurantPort) user → restaurant, media, auth (식당 컬렉션 — 저장 식당 매핑 테이블 + RestaurantPort enrich, 대표 이미지 MediaPort; AuthAccountPort로 온보딩 계정 연결·탈퇴 계정 정리) -admin → restaurant, magazine, reservation, user +admin → restaurant, magazine, reservation, user, auth (AdminAccountPort로 확인자 표시용 로그인 ID 일괄 조회) upload → shared (FileStorage) (지원 모듈; 도메인 모듈 의존 없음) 콘텐츠 도메인 → media (MediaPort) (일반 요청의 asset 검증, claim, role별 bulk 조회) 도메인 backfill runner → media (MediaBackfillPort) (migration 전용 READY asset 연결) diff --git a/docs/conventions/auth.md b/docs/conventions/auth.md index 39d3e602..6fd15df5 100644 --- a/docs/conventions/auth.md +++ b/docs/conventions/auth.md @@ -13,7 +13,7 @@ - **MUST NOT**: 도메인 모듈이 `auth`의 `internal`(JWT·OAuth·필터 등)을 import하지 않는다. - **MUST**: 도메인에서 "현재 로그인 사용자"가 필요하면 **`CurrentUserProvider`** 로 읽는다. - **MUST**: USER, ADMIN, ONBOARDING 유형까지 구분해야 하는 media 같은 제한된 기능은 **`CurrentActorProvider`** 로 읽는다. 일반 사용자 도메인은 이를 현재 사용자 조회 대용으로 사용하지 않는다. -- 의존 방향: **도메인 → auth** (auth는 depended-upon 위치). auth는 인증이라는 generic subdomain으로 잘 변하지 않아 불안정한 도메인이 안정적인 auth로 의존을 모은다(SDP, ADP). 현재 공개 지점은 `CurrentUserProvider`, `CurrentActorProvider`, `AuthAccountPort`다. 도메인은 `auth.internal`에 의존하지 않고 이 공개 지점으로만 auth를 참조한다. +- 의존 방향: **도메인 → auth** (auth는 depended-upon 위치). auth는 인증이라는 generic subdomain으로 잘 변하지 않아 불안정한 도메인이 안정적인 auth로 의존을 모은다(SDP, ADP). 현재 공개 지점은 `CurrentUserProvider`, `CurrentActorProvider`, `AuthAccountPort`, `AdminAccountPort`다. 도메인은 `auth.internal`에 의존하지 않고 이 공개 지점으로만 auth를 참조한다. - **MUST**: **`auth`는 어떤 도메인 모듈도 되참조하지 않는다**(순환 방지·안정성 유지). `shared`라서 순환 검사가 면제되는 게 아니라, auth가 도메인을 되참조하지 않아 무순환이 유지된다 — auth가 도메인을 관찰해야 하면 **이벤트**로 붙인다. 단, 탈퇴처럼 회원 삭제와 인증 계정 정리가 함께 커밋돼야 하는 처리는 도메인이 `AuthAccountPort`를 호출한다(§3). --- @@ -21,10 +21,15 @@ ## 2. 현재 사용자 조회 현재 `auth`가 노출하는 공개 지점은 `CurrentUserProvider`(현재 사용자 조회), -`CurrentActorProvider`(인증 주체의 유형과 식별자 조회), `AuthAccountPort`(온보딩 계정 연결·탈퇴 계정 정리)다. +`CurrentActorProvider`(인증 주체의 유형과 식별자 조회), `AuthAccountPort`(온보딩 계정 연결·탈퇴 계정 정리), +`AdminAccountPort`(관리자 응답의 확인자 로그인 ID 일괄 조회)다. 일반적인 현재 사용자 조회는 `CurrentUserProvider`를 쓰고, media의 역할별 권한과 소유권 검사는 `CurrentActorProvider`를 쓴다. +`AdminAccountPort`는 `admin` 진입점이 저장된 관리자 내부 ID로 현재 로그인 ID를 표시할 때만 사용한다. +포트 안에서도 ADMIN 인증을 검사하며 없는 계정은 결과에서 제외한다. 확인 기록은 내부 ID로 유지하고, +로그인 ID를 요청에서 받거나 과거 시점의 계정명으로 해석하지 않는다. 비밀번호·계정 엔티티는 반환하지 않는다. + ```java // auth/CurrentUserProvider.java (auth가 공개) public interface CurrentUserProvider { @@ -78,6 +83,7 @@ auth/ ├─ CurrentActorProvider # 공개 지점 (인증 주체 유형과 식별자 조회) ├─ CurrentActor · ActorType # actor 조회 계약의 반환값과 유형 ├─ AuthAccountPort # 공개 지점 (온보딩 소셜 계정 연결·탈퇴 계정 정리 — user가 원자적 커밋 위해 호출) +├─ AdminAccountPort # 공개 지점 (ADMIN 화면 확인자 표시용 현재 로그인 ID 일괄 조회) ├─ code/ AuthErrorCode · AuthSuccessCode └─ internal/ # 관심사별 하위 패키지 (경계 넘는 협력자는 public, 모듈 밖엔 여전히 비공개) ├─ account/ AuthAccount · AuthAccountRepository · AuthAccountService · AuthProvider · AuthAccountPortImpl diff --git a/docs/conventions/error-handling.md b/docs/conventions/error-handling.md index 3c468991..f9a2a9ec 100644 --- a/docs/conventions/error-handling.md +++ b/docs/conventions/error-handling.md @@ -146,6 +146,13 @@ class GlobalExceptionHandler { - **MUST**: 성공 응답의 알맹이(도메인 DTO)는 각 모듈 `dto/`의 `Response`다. - **MUST**: 성공·실패 응답 모두 `data` 필드를 **항상 노출**한다(없으면 `null`). 실패 응답의 `data`는 **항상 `null`**이다(성공/실패 형태 일관성). - **MUST**: 실패 응답에는 `timestamp`(ISO-8601)·`path`(요청 경로)를 포함하고, **검증 실패 시** `errors`(필드 단위 `FieldError` 목록)를 채운다. 검증 외 일반 에러는 `errors`를 내려보내지 않는다. + + 현재 레거시 `GlobalExceptionHandler`는 DTO `@Valid` 실패에만 `errors`를 채우며, + 경로·쿼리 파라미터의 제약 위반·타입 불일치에는 `COMMON-400`과 `data: null`을 반환하고 + `errors`를 생략한다. 지도 API도 이 공통 동작을 따른다. 위 MUST는 향후 전역 표준이며, + 파라미터 오류까지 확장할 때는 공통 핸들러·전체 API 계약·회귀 테스트를 함께 변경한다. + 개별 도메인 advice만으로 응답 차이를 만들지 않는다. + - **MUST**: `data`를 제외한 `null` 필드는 직렬화에서 제외한다 — `ErrorResponse`에 **클래스 단위 `@JsonInclude(NON_NULL)`**, `data` 필드에만 **`@JsonInclude(ALWAYS)`**를 붙인다(`data`는 `null`이어도 항상 노출, `errors`는 `null`이면 자동 생략). - **MUST NOT**: `SuccessResponse`에는 클래스 단위 `NON_NULL`을 적용하지 않는다(성공 응답 `data`도 `null`이어도 항상 노출). diff --git a/docs/infra/dev-deploy.md b/docs/infra/dev-deploy.md index 4421a40b..10efe359 100644 --- a/docs/infra/dev-deploy.md +++ b/docs/infra/dev-deploy.md @@ -117,6 +117,12 @@ CLOUDFRONT_DOMAIN CORS_ALLOWED_ORIGINS CORS_ALLOWED_ORIGIN_PATTERNS +HASHI_MAP_GOOGLE_PLACES_ENABLED +HASHI_MAP_GOOGLE_PLACES_API_KEY +HASHI_MAP_PLACES_SELECTION_ENABLED +HASHI_MAP_PLACES_SELECTION_SIGNING_KEY +HASHI_MAP_PLACES_SELECTION_TOKEN_TTL + GRAFANA_ADMIN_PASSWORD ``` @@ -124,8 +130,52 @@ GRAFANA_ADMIN_PASSWORD ```text GRAFANA_ADMIN_USER +HASHI_RESTAURANT_MAP_SESSION_ENABLED +HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY +HASHI_RESTAURANT_MAP_REDIS_MODE +HASHI_RESTAURANT_MAP_REDIS_HOST +HASHI_RESTAURANT_MAP_REDIS_PORT +HASHI_RESTAURANT_MAP_REDIS_SSL +HASHI_RESTAURANT_MAP_REDIS_USERNAME +HASHI_RESTAURANT_MAP_REDIS_PASSWORD +HASHI_RESTAURANT_MAP_REDIS_CONNECTTIMEOUT +HASHI_RESTAURANT_MAP_REDIS_TIMEOUT ``` +지도 세션은 `HASHI_RESTAURANT_MAP_SESSION_ENABLED=false`가 기본값이다. 키 준비와 공개 활성화는 +별개이며, 키는 현재 EC2 런타임 환경 파일에서 관리한다. `HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY`는 +모든 서버가 공유하는 Base64 형식 32~64 byte 비밀값이다. 실제 키는 repository나 GitHub Actions에 중복 저장하지 않는다. +비활성화 상태에서는 유효한 키를 넣어도 지도 목록 세 모드가 DB/Redis 접근 전에 RESTAURANT-014 / 503을 반환한다. +활성화한 상태에서 키가 없거나 잘못되면 부팅은 유지하고 시작 시 값·예외 원문 없는 WARN을 한 번 남기며, +지도 세션 요청은 같은 503을 반환한다. 비활성화 상태에서는 키 누락/오류로 WARN을 남기지 않는다. + +지도에는 별도 Redis 연결을 사용한다. `HASHI_RESTAURANT_MAP_REDIS_HOST`가 비어 있으면 +인증용 `REDIS_HOST`로 대체하지 않고 지도 요청만 503으로 반환한다. 기본 포트는 6379, +연결 timeout은 2초, 명령 timeout은 3초다. TLS가 필요한 Redis는 `..._SSL=true`로 설정하고 +Redis 인증이 필요한 경우 `..._PASSWORD`를 넣고, ACL 사용자를 지정할 때 `..._USERNAME`도 넣는다. +비밀번호만 사용하는 Redis도 지원한다. 인증 Redis의 TLS 설정과 별개다. +기존 ElastiCache를 재사용할 때는 `..._MODE=shared`와 기존 endpoint를 명시하고 `volatile-lru`를 유지한다. +기본 mode는 dedicated이며 별도 `noeviction` Redis에서만 사용한다. 인증 Redis의 정책을 바꾸지 않는다. +shared 최초 사용이나 회계 키 유실 후에는 최대 세션 수명인 30분간 503으로 준비/복구하므로 공개 전에 확인한다. +별도 연결이 추가되어도 기존 인증 연결·RedisTemplate·캐시 설정은 유지한다. +호스트가 없거나 설정이 유효하지 않으면 활성화된 경우에만 값 없는 WARN을 남긴다. +포트·SSL·timeout은 문자열로 받은 뒤 검증하므로 형식 오타도 앱 시작을 막지 않는다. +잘못된 SSL 값을 false로 대체하지 않고 지도 요청을 거절한다. +연결은 첫 정상 지도 요청 또는 활성 상태의 주기 관측에서 생성한다. +비활성 상태에서는 연결을 만들지 않으므로 앱 시작에 지도 Redis가 필요 없다. + +추가 ElastiCache나 EC2 Redis 컨테이너는 만들지 않고 기존 ElastiCache를 우선 사용한다. +전체 사용량·지도 예약량·요청 제한과 사전 알림을 함께 확인하며 공유 자체가 인증 장애 격리를 보장하지는 않는다. +서버 활성화와 Grafana 알림 등록은 이 설정 추가만으로 실행되지 않는다. +[공유 운영과 사전 알림](../map/map-pagination.md#지도-redis-연결과-공유-운영)을 따른다. + +공개 활성화 전 신뢰 가능한 ingress 제한·호출자 식별 경계와 정상 트래픽 용량을 검증하고, +실제 후보 규모가 세션별 계획 상한(`snapshot-bytes / 32`)과 직렬화 byte 예산 안에 드는지 확인해야 한다. +[후속 이슈 #252](https://github.com/TEAM-HASHI/HASHI-SERVER/issues/252)에서 이를 별도로 다룬다. +기본 비활성화 추가로 해당 문제가 해결되지는 않는다. 고정 500개 상한은 없으며, 계획 상한이나 실제 +byte 예산을 넘으면 RESTAURANT-016 / 503으로 전체 요청을 실패시키고 결과를 잘라내지 않는다. +상세 계약은 [지도 목록 운영 경계](../map/map-pagination.md#설정오류와-운영-경계)를 따른다. + `GRAFANA_ADMIN_USER`를 생략하면 `admin`을 기본값으로 사용한다. `GRAFANA_ADMIN_PASSWORD`는 기본값이 없으며, 누락하거나 빈 값으로 두면 Docker Compose 실행이 실패한다. 비밀번호에 `$` 등 Docker Compose가 해석하는 문자가 포함될 수 있으므로 실제 값은 작은따옴표로 감싸 입력한다. @@ -148,6 +198,22 @@ CORS_ALLOWED_ORIGIN_PATTERNS=https://hashi-client-*-.vercel.ap 크리덴셜을 포함한 요청을 허용하므로 `https://*.vercel.app`처럼 다른 프로젝트까지 포함하는 넓은 패턴은 사용하지 않는다. +Google Places 관리자 위치 검토 기능은 기본적으로 꺼져 있다. 활성화 준비 시 아래 값을 서버 환경 파일에 +설정하되, 애플리케이션 설정과 DB의 `SEARCH`, `DETAILS` 예산 행을 별도로 검토하고 활성화한다. + +```text +HASHI_MAP_GOOGLE_PLACES_ENABLED=false +HASHI_MAP_GOOGLE_PLACES_API_KEY= +HASHI_MAP_PLACES_SELECTION_ENABLED=false +HASHI_MAP_PLACES_SELECTION_SIGNING_KEY= +HASHI_MAP_PLACES_SELECTION_TOKEN_TTL=10m +``` + +Google Cloud에서 같은 API key에 Geocoding API와 Places API (New)를 모두 허용한 경우에도 +`HASHI_MAP_GOOGLE_PLACES_API_KEY`에는 그 값을 별도로 주입한다. selection 서명키는 provider API key와 +다른 전용 비밀값이며, Base64 디코딩 후 32바이트 이상이어야 한다. 실제 key와 token은 repository, +이슈, PR, 문서와 로그에 기록하지 않는다. + 다음 key는 `.env.dev`에 넣지 않는다. ```text @@ -179,6 +245,13 @@ env_file: - ../.env.dev ``` +기존 `env_file`만으로도 정확한 이름의 지도 세션 환경변수를 컨테이너에 전달할 수 있다. +명시적인 `environment` 매핑은 기본 비활성화와 빈 키 기본값을 드러내기 위한 것이다. +`environment`가 `env_file`보다 우선하므로, 기존 배포 명령처럼 `--env-file ../.env.dev`를 지정해 +같은 파일을 Compose 변수 치환에도 사용한다. 운영 compose도 `--env-file ../.env.prod`를 사용한다. +이 옵션을 빼고 기본 치환값이 적용되면 `env_file`의 키가 빈 값으로 덮일 수 있다. +실제 환경에서 `docker compose config` 전체 출력으로 키를 확인하지 않는다. + 애플리케이션 컨테이너는 localhost에만 바인딩한다. ```yaml diff --git a/docs/map/collection-map-integration.md b/docs/map/collection-map-integration.md new file mode 100644 index 00000000..ec5f1d31 --- /dev/null +++ b/docs/map/collection-map-integration.md @@ -0,0 +1,75 @@ +# 컬렉션 전체 지도와 저장 정보 (#242) + +기존 컬렉션의 저장 관계를 사용해 전체 핀과 저장 정보를 조회한다. +일반 지도 목록의 페이지 조회와 별개이며, 확대·축소나 BBOX로 핀을 줄이지 않는다. + +## API + +`GET /api/v1/collections/{collectionId}/map-markers`는 선택한 컬렉션을 한 번에 표시하기 위한 API다. +기존 `/restaurants` 목록의 스크롤/커서와 독립적으로 전체 핀을 요청한다. BBOX·검색·음식점 분류를 +적용하지 않으며 식당 ID 오름차순이다. `visibleRestaurantCount`는 현재 존재하는 저장 식당 수, +`locationUnavailableCount`는 그중 좌표가 없는 식당 수다. `generatedAt`은 마지막 좌표 유효성 검사 시각이며 UTC다. +예를 들어 삭제 식당 없이 23개 중 2개 좌표가 없으면 전체 수 23, `content` 21개, 위치 불가 수 2다. + +`GET /api/v1/restaurants/save-counts?restaurantIds=3,1,2`는 공개 집계다. +응답 `restaurants` 배열에 `restaurantId`와 `saveCount`를 요청 순서로 반환한다. +삭제/미존재 식당은 배열에서 제외하며, 실제 식당의 저장 관계가 없으면 `saveCount=0`이다. + +`GET /api/v1/users/me/restaurant-saves?restaurantIds=3,1,2`는 USER 본인의 조회다. +응답 `restaurants` 배열에 `restaurantId`와 `saved`를 반환한다. ADMIN/ONBOARDING은 사용할 수 없다. +두 요약 API는 빈 목록·누락·null·0·음수·중복·101개 이상 요청을 COMMON-400으로 거부한다. +새 API 성공 응답은 기존 SuccessResponse 봉투와 `Cache-Control: no-store`를 사용한다. +지도 과량/조회 장애는 신규 `USER-017`(503)이다. M1 초기 초안의 `USER-008`은 현재 #232에서 +이미 지원하지 않는 색상(400)으로 사용 중이므로 재사용하지 않는다. 기존 오류의 의미는 유지한다. + +## 구현 선택 + +- 저장한 사용자 수는 `count(distinct collection.userId)`다. 공개/비공개 모두 집계한다. + 같은 사용자의 두 컬렉션은 1명이며 마지막 저장 관계 제거/컬렉션 삭제로만 감소한다. +- 내 저장 여부는 인증된 USER 본인의 소유 컬렉션에서 조회한다. 식당 ID 1~100개를 + 중복 없이 양수로 요구하고 현재 존재하는 식당만 요청 순서대로 응답한다. + RestaurantPort 실패를 빈 결과나 0/false로 변환하지 않는다. Redis 개인 캐시는 추가하지 않는다. +- 집계는 user의 컬렉션/저장 관계만 조인한다. restaurant 테이블은 조인하지 않는다. + V30의 `saved_restaurant(restaurant_id)` 인덱스로 요청 ID를 한정하며 집계와 내 저장 조회 각각 1 SQL이다. +- 컬렉션 전체 지도는 기존 상한 1,000개를 재사용한다. 초기 조회는 상한+1까지만 읽어 + 과량을 503으로 거부하며, restaurant의 500개 batch Port가 부분 실패를 전파한다. +- 메타데이터/저장관계 변경을 하나의 명시적 `collectionVersion`으로 식별한다. + 모든 기존 쓰기는 부모 행을 먼저 잠그며, 이동은 작은 ID부터 부모 두 행을 잠근다. + inverse 자식 변경만으로 부모 @Version 갱신을 기대하지 않는다. +- 초기 변경번호와 membership은 같은 RR snapshot으로 읽는다. Port 호출 중에는 부모 잠금을 + 유지하지 않는다. 최종 권한/변경번호 검사에는 새 RC 트랜잭션의 current locking read를 사용한다. + 비공개 전환/삭제/권한 없음은 동일 404, 허용된 컬렉션의 변경번호 불일치는 409다. + 검사 종료 이후 미래 변경을 막는 약속은 하지 않는다. +- 응답 직전에 좌표 `validUntil`을 다시 확인한다. 좌표 없는 식당은 기존 목록의 관계를 + 유지하고 핀만 빠진다. 지도 API에는 BBOX/검색/분류/페이지 규칙을 적용하지 않는다. +- 생성·수정 모두 이름과 설명의 앞뒤 공백을 제거하고 중간 공백은 유지한다. + NBSP·전각 공백도 같은 규칙을 적용한다. 공백만 있는 이름은 거부한다. + 이름 20자·설명 100자 제한은 공백을 제거하기 전 입력값에 적용한다. + 설명은 생성 시 비어 있으면 null로 저장하고, PATCH에서는 null이면 유지, 빈 문자열·공백만 보내면 삭제한다. + 예: 이름 ` 도쿄 맛집 `은 `도쿄 맛집`으로 저장하고, 같은 사용자의 이 이름이 이미 있으면 409다. + 이름 중복 판정에는 공백 제거 후 값을 사용하며 기존 DB collation·유니크 제약은 유지한다. + 기존 행을 일괄 수정하지 않는다. 이전 값에 공백이 있더라도 이름을 보내지 않은 PATCH는 유지하며, + 이름을 다시 보냈을 때 정규화 결과가 다른 컬렉션과 충돌하면 저장하지 않고 409로 응답한다. + 공백 처리는 2026-10-12 확인한 수정 화면을 기준으로 생성·수정에 동일하게 적용한다. + +## DB 적용 순서 + +`V41__add_collection_version.sql`은 컬렉션에 `collection_version BIGINT NOT NULL DEFAULT 0`을 추가한다. +이미 병합된 V28/V30 및 `V31__link_magazine_card_news_to_media_assets.sql`은 수정하지 않는다. +지도 스택의 V38(위치)·V39(작업)·V39.1(기본 주소)·V39.2(Places 선택)·V39.3(위치 확인 기록)·V40(유지보수)를 먼저 적용한 뒤 V41을 적용한다. +선행 지도 migration이 빠진 환경에 V41을 먼저 배포하거나 `outOfOrder`로 순서를 우회하지 않는다. +최종 업그레이드 검증은 V40까지 적용한 DB에서 V41을 실행해 기존 컬렉션과 저장 관계의 보존을 확인한다. + +## 좌표 갱신 정책 반영 + +같은 주소의 정기 갱신이나 재시도·실패 중에도 기존 검증 좌표가 유효하면 핀을 유지한다. +주소가 바뀌었거나 validUntil에 도달하면 핀만 제외한다. 식당과 컬렉션의 저장 관계는 유지한다. +새 결과 없이 기존 좌표의 만료 시각을 연장하지 않는다. +Places 제3자 출처가 있으면 `location.attributions`의 이름·HTTPS 링크를 좌표와 함께 표시한다. +일반 지도 카드·단건 위치도 같은 Port 값을 사용하며, 만료된 좌표의 출처는 함께 제외한다. +출처가 손상되면 출처 없는 핀으로 바꾸지 않고 조회 실패로 처리한다. + +관련 검증은 실제 CollectionMapQueryService → RestaurantPort → MySQL 경로로 수행한다. +23개 중 좌표가 없는 2개 제외, 1,000개 전체 핀, 두 번째 batch 실패 시 부분 응답 차단과 함께 +갱신·재시도·실패 중 좌표 유지, 주소 변경·정확한 만료 시각의 핀 제외를 확인한다. +공개/비공개 접근과 응답 직전 컬렉션 변경 검사는 기존 HTTP 통합 테스트로 확인한다. diff --git a/docs/map/frontend-integration-handoff.md b/docs/map/frontend-integration-handoff.md new file mode 100644 index 00000000..7c5a25cc --- /dev/null +++ b/docs/map/frontend-integration-handoff.md @@ -0,0 +1,152 @@ +# 지도 프런트 연동 인계 — 2026-10-11 + +## 범위와 기준 + +서버 API 계약과 백엔드 검증을 정리한다. 화면 연결은 프런트에서 진행한다. + +- 기획: HASHI-PLAN `4058a24`의 MAP_MAIN, MAP_FILTERED, MAP_RESTAURANT_DETAIL, RESTAURANT_SEARCH. +- 프런트 #210 스냅샷: `03636670dd5dafc4689f2ef8b8f607e2653c4c3b`. PR 본문에는 예전 정적 화면 설명이 있지만 최신 `MapPage.spec.md`/코드에는 Google SDK가 있다. 식당·지역 데이터와 저장/예약은 샘플·미연동이다. +- 서버: #236 기존 결합본 및 #251 관리자 지역 설정 작업. 서버 PR은 아직 병합·배포하지 않았다. +- Figma는 디자인 파일의 `UI [디자인 2차 스프린트] > 지도`와 기획 파일의 `MAP_MAIN`을 읽기 전용으로 대조했다. 카테고리, 관광 지역 클러스터, 식당 카드와 바텀시트 구성은 확인했지만 정적인 프레임으로 재시도·상태 복원 동작까지 검증한 것은 아니다. 동작 기준은 위 기획 문서이며, 화면 연결과 최종 UI QA는 프런트 담당자가 진행한다. + +## 연결 순서 + +1. `GET /api/v1/restaurants/map/regions`에서 `initialBounds`, `queryLimits`, `regions`를 받는다. 지역의 `mapRegionId`, `clusterPosition`, `cameraBounds`, `restaurantCount`를 사용한다. 샘플 `areaCode`를 서버 ID로 변환한다고 가정하지 않는다. +2. 지역 선택 시 cameraBounds를 맞추고 `GET /api/v1/restaurants/map?south=...&north=...&west=...&east=...&mapRegionId=...`를 요청한다. cameraBounds는 대표 화면이며 식당의 소속 경계를 계산하는 다각형이 아니다. 소속은 관리자 API로 지정한다. +3. 새 조회는 네 bounds가 필수이며 `mapRegionId`, `keyword`, `genre`, `placeType`, `sort`를 선택적으로 보낸다. sort/분류의 wire 값은 서버 계약과 생성된 API 타입을 따른다. +4. 추가 조회는 `cursor`만 보낸다. bounds나 필터를 섞으면 400이다. `content`와 핀을 함께 추가하고 hasNext=false면 중단한다. +5. 같은 bounds·필터에서 정렬만 바꾸면 `querySessionId`와 `sort`만 보내 기존 추천 동점 순서를 유지한다. 카메라가 움직였거나 검색·카테고리·지역 조건이 달라졌다면 새 bounds/조건으로 새 조회를 만든다. 기존 sessionId에 새 필터를 섞지 않는다. +6. 식당 저장 관계는 기존 컬렉션 API를 사용한다. 지도 카드 자체에는 개인 저장 여부·저장 수가 없으므로 기존 bulk 저장 정보 API를 함께 연결한다. 선택한 컬렉션은 `GET /api/v1/collections/{id}/map-markers`의 모든 유효 핀을 표시한다. + +## 최신 기획과 맞출 화면 동작 + +- 지도 pan/zoom만으로 식당 목록을 자동 재조회하지 않는다. 카메라 이동과 마지막 조회 bounds를 따로 보관한다. +- 지도 이동 시 바텀시트를 최소 높이로 줄인다. 카테고리가 있을 때 마지막 조회 bounds와 달라지면 현재 위치 검색 버튼을 표시한다. 카테고리가 없는 전체 상태에서는 버튼을 숨긴다. +- 사용자가 지역 선택 후 지도를 움직이면 지역 선택 맥락을 해제한다. 다음 신규 조회에 예전 mapRegionId를 남기지 않는다. +- 검색·카테고리가 없을 때 축소 줌 기준에서 지역 클러스터로 복귀한다. #210의 현재 ‘명시적 reset에서만 복귀’와 차이가 있으므로 담당자가 수정해야 한다. SDK 줌 기준 수치는 클라이언트 설정으로 관리한다. +- 현재 카테고리를 다시 누르면 카테고리를 해제한다. 검색/카테고리/정렬의 새 조회는 기존 목록·핀·스크롤을 초기화한다. 추가 페이지 실패 때만 기존 목록·핀을 유지한다. +- 요청 세대 또는 취소 신호로 이전 조건의 늦은 응답을 버린다. 최신 요청 중 카메라만 움직인 경우에는 기획에 따라 요청 당시 bounds로 돌아간 뒤 결과를 반영한다. +- 탭 이탈/백그라운드부터 5분간 화면 상태를 보존하며 그 이후 복귀나 활성 지도 탭 재선택은 초기화한다. 이는 Redis의 ‘마지막 성공 조회부터 유휴 5분, 전체 최대30분’과 다른 타이머다. + +## 오류와 경계 + +- HTTP410 / RESTAURANT-013: 저장된 목록·핀은 그대로 두고 추가 조회 대신 새 조회를 안내한다. 새 결과와 만료 세션의 목록을 이어 붙이지 않는다. +- HTTP429 / RESTAURANT-022: 호출 제한. 즉시 무한 재시도하지 않는다. +- HTTP503: 비활성/키/Redis/예산 제한 등 서버 응답 코드를 구분하고 재시도 UI를 둔다. 설정 누락을 빈 목록 성공으로 표시하지 않는다. +- bounds는 지원 범위 안에서 위도·경도 폭 각각 최대1도다. 광역 화면은 지역 클러스터를 사용하고, 임의로 범위를 잘라 전체 결과라고 표시하지 않는다. +- 지도 검색은 요청한 현재 viewport 안에서 카테고리를 유지해 조회한다. 다른 지역까지 검색 범위를 넓히지 않는다. +- 지도에서 검색한 뒤에도 정렬은 `recommend`·`rating`·`reviews`를 사용한다. 2026-10-09 담당자 확인 사항이며, 일반 검색 화면의 정확도순·인기순 계약과 구분한다. +- ‘검색 결과 모두 fit’은 첫 페이지 10개가 아니라 해당 조건의 전체 결과에 적용한다. 검색 응답의 `searchResult.totalCount`와 `searchResult.bounds`를 사용한다. 0건은 bounds가 없고, 1건은 같은 남북·동서 경계이므로 해당 지점으로 이동한다. 검색어가 없는 일반 탐색에는 이 집계가 없다. +- 지도 조정은 검색 요청 당시 결과를 보여 주는 카메라 동작이다. 조정된 화면으로 즉시 다시 검색하면 조회 조건이 반복 변경되므로 자동 요청하지 않는다. 사용자가 새 검색이나 현재 위치 검색을 실행하면 그 시점의 viewport로 새 세션을 만든다. +- 지도 검색은 식당명·메뉴명·해시태그를 대상으로 공백으로 나눈 단어 중 하나라도 일치하면 찾는다. `#태그` 형식은 단일 해시태그 검색이며, 검색어는 앞뒤 공백 제거 후 30자 이하다. 기존 일반 검색 API의 정렬·커서 계약까지 이번 지도 변경으로 바뀌는 것은 아니다. + +## 공개 전 준비 + +- 실제 지역 대표 위치·cameraBounds·식당 소속 데이터는 기획 승인 자료로 입력한다. 합성 fixture를 실제 초기 지역으로 배포하지 않는다. +- 브라우저 SDK 키/Map ID는 프런트의 `VITE_GOOGLE_MAPS_API_KEY`, `VITE_GOOGLE_MAPS_MAP_ID` 설정을 사용한다. 서버 Geocoding 키를 프런트에 복사하지 않는다. +- 테스트 환경에 서버 PR을 실제 반영하고 필요한 기능만 명시적으로 활성화한 뒤 프런트 담당자가 인증·비로그인·페이지 추가·세션 만료·저장 전체 핀을 검증한다. +- 백엔드 실호출 테스트와 로컬 부하 결과는 실제 브라우저/모바일 QA 및 운영 용량 보장이 아니다. + + +## 관리자 위치 확인 화면 제안 + +식당을 저장하면 Google 위치 확인은 별도 작업으로 처리한다. 저장 성공만으로 지도 핀이 생겼다고 표시하면 안 된다. +기존 식당 수정 화면에 작은 위치 확인 영역을 추가하는 것이 첫 단계다. 별도 대시보드는 확인 대상이 많아질 때 확장한다. + +| 표시 항목 | 서버 계약과 처리 | +|---|---| +| 표시 주소 | `address`. 건물명·층수를 포함해 사용자에게 보여 줄 원문 | +| 위치 확인용 주소 | 선택 항목 `geocodingAddress`. 없으면 `address`로 조회. 일본어 주소를 모든 식당에 새로 입력하는 항목이 아님 | +| 처리 상태 | `UNRESOLVED`, `PENDING`, `READY`, `RETRY_WAIT`, `REVIEW_REQUIRED`, `FAILED`를 운영자가 이해할 말로 표시 | +| 확인 사유 | `failureCode`로 후보 없음, 후보 여러 개, 주소 불일치, 국가 정보 부족, 지원 지역 밖 등을 구분. provider 원문이나 키를 노출하지 않음 | +| 다음 시도·시도 횟수 | `nextAttemptAt`, `attempt`. 일시 장애는 서버가 자동 재시도하므로 중복 클릭을 유도하지 않음 | +| 다시 확인 버튼 | `canRetry=true`일 때 현재 `addressRevision`을 `expectedAddressRevision`으로 전달. 409면 최신 상태를 다시 읽음 | +| 유효기간 | `validUntil`. 갱신 중에도 아직 유효한 기존 좌표가 남을 수 있으므로 처리 상태만으로 핀 노출을 판단하지 않음 | +| 확인 방식 | 단건 상태의 `confirmationMethod`: `AUTOMATIC` 또는 `ADMIN_PLACE_SELECTION`. 좌표 제공자인 `source`와 구분 | +| 관리자 확인 기록 | 단건 상태의 `confirmedByAdminId`, `confirmedAt`, `confirmedRevision`. 관리자 ID는 서버 인증 정보에서 기록. 선택 접수 시각은 좌표 취득 완료 시각과 다르며 과거 기록은 null일 수 있음 | + +관리자 등록·수정 외에 다음 API를 제공한다. + +| API | 사용 방법 | +|---|---| +| `POST /api/v1/admin/restaurants/location-preview` | 등록 전 `name`, `localName`, `address`, 선택 `geocodingAddress`로 후보 검색. 식당 ID가 필요 없으며 식당을 저장하지 않음 | +| `GET /api/v1/admin/restaurants/locations` | 기본으로 `REVIEW_REQUIRED` 식당을 ID 오름차순으로 조회. `status`, 선택 `source`, `page`(0부터 시작, 기본 0), `size`(기본 20, 1~100)를 사용 | +| `GET /api/v1/admin/restaurants/{id}/location` | 수정 화면에서 해당 식당의 최신 상태와 주소 변경 번호를 확인 | +| `POST /api/v1/admin/restaurants/{id}/location/retry` | `canRetry`와 최신 `addressRevision`을 확인한 뒤 재처리 | +| `POST /api/v1/admin/restaurants/{id}/location/place-candidates` | `expectedAddressRevision`을 보내 저장된 식당명·주소로 매장 후보 검색. 후보 이름·주소·위치·Google Maps 링크를 함께 확인 | +| `POST /api/v1/admin/restaurants/{id}/location/place-selection` | 선택한 후보의 `selectionToken`과 `expectedAddressRevision` 전달. 200의 `PENDING`은 선택 접수이며, 상태 조회에서 `READY`를 확인 | + +목록은 `restaurants`, `page`, `size`, `totalCount`, `totalPages`를 반환한다. Hashi 관리자 목록의 offset 방식을 따른다. +한 응답의 목록과 총건수는 같은 DB 스냅샷에서 읽지만 여러 페이지 사이의 상태를 고정하지 않는다. +주소 보완·재처리 뒤에는 현재 페이지를 다시 읽고, 상태 변경으로 페이지가 비었다면 이전 페이지나 첫 페이지로 이동한다. +ADMIN 권한이 필요하고 응답은 캐시하지 않는다. 일반 지도 목록의 cursor 계약과 구분한다. +확인 방식과 관리자 확인 기록은 단건 `GET /{id}/location`에서 읽는다. 위치 검토 목록에는 포함되지 않는다. +시설 내부 매장은 `READY`여도 실제 매장 위치와 다를 수 있으므로 `status=READY&source=GOOGLE_GEOCODING`으로도 점검할 수 있다. +후보 선택은 `READY`, `REVIEW_REQUIRED`, `FAILED`, `RETRY_WAIT`에서 사용할 수 있다. `PENDING`이면 처리 결과를 기다린다. +선택 토큰은 10분 동안만 유효하며 다른 식당·이전 주소 상태에 재사용할 수 없다. 400이면 후보를 다시 검색하고, +409이면 최신 상태를 읽은 뒤 다음 동작을 정한다. 자세한 오류와 예시는 [Places 선택 계약](./place-selection.md)을 따른다. + +### 등록 전 후보 선택과 입력 변경 + +1. 미리보기는 현지명과 `geocodingAddress`로 검색하며 기본 주소가 없으면 표시 주소를 사용한다. + 후보의 이름·지점·주소·지도 위치를 확인한 뒤 `selectionToken`을 등록 요청의 `locationSelectionToken`으로 보낸다. +2. 토큰은 같은 관리자가 같은 네 입력으로 10분 안에 사용할 수 있다. 이름·현지명·표시 주소·기본 주소를 바꾸면 다시 검색한다. + 등록 전 토큰과 기존 식당의 선택 토큰을 서로 바꾸어 사용할 수 없으며, 원문 Place ID나 좌표를 등록 값으로 보내지 않는다. +3. 토큰 변조·만료는 400, 관리자·입력 불일치는 409다. 토큰 검증이 실패하면 식당과 작업 모두 저장되지 않는다. + 후보 없음은 200과 `candidates=[]`, 예산 소진은 429, 설정 문제는 503, Google 조회 실패는 502로 구분한다. +4. 후보가 없거나 선택을 생략해도 토큰 없이 등록할 수 있다. 이때 기존 주소 자동 확인을 요청한다. + 토큰을 보내면 선택한 매장의 Details 작업만 등록하며 등록 transaction에서 Google을 호출하지 않는다. + `201/PENDING`은 접수 성공이며 worker 완료 전에는 새 핀을 표시하지 않는다. Details가 승인된 뒤 `source=GOOGLE_PLACES`가 된다. +5. 후보와 선택 토큰은 화면에서만 일시적으로 보관하고 영구 저장소나 로그에 남기지 않는다. + +저장된 식당의 이름·현지명·표시 주소·위치 확인 입력이 실제로 달라지면 기존 좌표와 확인 기록을 지우고 +`addressRevision`을 올려 다시 확인한다. 같은 기본 주소를 보내면서 층수만 바꾸어도 해당한다. +`addressRevision`이라는 응답 이름은 호환을 위해 유지하지만 의미는 위치 확인 기준 정보의 변경 번호다. +값이 그대로인 PATCH나 가격·소개만 수정하는 요청은 위치 확인에 영향을 주지 않는다. +같은 매장의 정기 갱신은 유효한 기존 핀과 관리자 선택 기록을 보존한다. 관리자 후보 선택 기록만으로 +위치 확인 완료를 표시하거나 `source`를 자체 좌표로 바꾸지 않는다. + +`REVIEW_REQUIRED`는 같은 주소를 자동으로 반복 호출하지 않는다. 관리자가 실제 지점과 주소를 확인해 +위치 확인용 주소를 보완하거나 재처리를 요청한다. 식당이나 컬렉션 저장 관계를 삭제하지 않는다. +역·쇼핑몰 내부 매장은 주소 변환이 성공해도 시설 대표 위치일 수 있다. 이 경우 주소만 반복 조회하는 대신 +식당명·지점명·주소로 Places 후보를 확인한다. 후보를 새로 선택하면 이전 핀을 숨기고 Details 검증 후 새 핀을 표시한다. +이후 같은 매장 위치를 정기 갱신할 때는 기존 유효 핀을 유지한다. 식당의 표시 주소를 Google 후보 주소로 덮어쓰지 않는다. + +### 출처 표시 + +후보 화면은 `displayName`, `address`, `googleMapsUri`로 지점을 확인하고 `attributions`의 이름·링크도 표시한다. +Google 지도가 없는 후보 목록에는 Google Maps 표시가 필요하다. 지도 자체의 Google Maps 로고를 가리면 안 된다. +공개 지도 카드·단건 위치·컬렉션 핀의 `location.attributions`가 비어 있지 않으면 해당 이름·링크를 좌표와 함께 표시한다. +이는 Google Maps 기본 로고와 별개의 제3자 출처다. `validUntil`에 좌표를 제거할 때 함께 제거한다. + +## 실제 Google 추가 검증과 확장 범위 + +- 동일한 정상 주소를 대량 반복하기보다 확인 대상과 시설 내부 매장에 집중한다. 원문 입력을 보존하고 요청 방식, 응답 정확도, 선택한 지점의 일치 여부를 구분해 기록한다. +- Places API (New)는 별도 활성화·키 제한·호출 예산을 확인한 뒤 소량 검증한다. Text Search에 식당명·지점명·주소를 전달하고 필요한 필드만 요청한다. 첫 후보를 무조건 확정하지 않는다. +- 관리자가 후보를 선택하면 place ID 중심으로 저장·재확인한다. Geocoding 자동 저장과 구분되며 첫 후보를 자동 선택하지 않는다. +- 실제 프로젝트에서 처리 방식이 확인되면 격리된 개발 데이터로 등록 → 좌표 저장 → 갱신 → 확인 대상 재처리를 검증한다. 운영 backfill은 대상 수·예산을 먼저 확인하고 작은 범위부터 실행한다. +- Google 대량 부하는 하지 않는다. k6는 합성 식당과 mock provider를 사용한다. +- Routes API는 이번 구현에 포함하지 않는다. 향후 서비스 안에서 경로·이동 시간·복수 목적지 기능을 제공할 때 비용과 제품 요구를 다시 검토한다. 현재는 확장을 위한 빈 추상화나 dependency를 추가하지 않는다. + +### Places 개발 프로젝트 실호출 확인 — 2026-10-09 + +개발 EC2에서 식당 3곳에 Text Search 3회, Place Details 3회, Geocoding 3회를 호출했다. +세 검색에서 각각 식당명·지점명이 일치하는 후보 하나를 받았고, 같은 place ID의 Details 위치와 검색 위치가 같았다. + +| 식당 | Geocoding 주소 위치와 Places 매장 위치의 거리 | +|---|---:| +| 도쿄역 이카루가 | 약 208m | +| 키와미야 도쿄역점 | 약 11m | +| 마구로마트 | 약 4m | + +Places를 쓰면 시설의 주소 위치와 특정 매장 위치를 구분할 수 있다는 확인이다. +지도 핀의 정확한 출입구·층 또는 식당 영업 상태까지 현장 검증한 결과는 아니다. +위 9회 호출은 API 결과를 비교한 실험이다. 이후 별도 검증에서 Text Search 1회와 Details 2회로 +관리자 후보 검색 → 선택 → 좌표 저장 → 같은 매장 갱신 → 지도·컬렉션 핀 조회까지 통과했다. +초기 `REVIEW_REQUIRED`를 만드는 Geocoding 응답만 mock으로 대체했고, Places는 개발 프로젝트의 실제 응답을 사용했다. +애플리케이션과 DB는 로컬의 격리된 테스트 환경에서 실행했고 Google 통신만 개발 EC2 터널을 이용했다. +상세 명령과 범위는 [통합 검증 기록](./map-integration-verification.md#places-매장-선택과-갱신)을 따른다. +운영 DB와 Google 자동 처리 설정은 변경하지 않았고, 키·좌표 원문·응답 원문은 문서에 남기지 않는다. + +공식 참고: [Text Search](https://developers.google.com/maps/documentation/places/web-service/text-search), +[Places 정책](https://developers.google.com/maps/documentation/places/web-service/policies). diff --git a/docs/map/google-geocoding-adapter.md b/docs/map/google-geocoding-adapter.md new file mode 100644 index 00000000..5ffb2320 --- /dev/null +++ b/docs/map/google-geocoding-adapter.md @@ -0,0 +1,147 @@ +# Google Geocoding adapter (M3a, #222) + +## 범위와 호출 경계 + +이 문서는 PR #226 (M3a, #222)의 adapter 범위를 기록한다. +`restaurant.internal.map.GeocodingProvider.geocode(address)`는 저장 전 후보를 반환하며, +원래 PR #226에서는 Controller, RestaurantPort, JPA, migration, SecurityConfig, Redis와 worker에 연결하지 않았다. +M2 #220의 모델과 독립적으로 사용할 수 있다. + +2026-10-03 기준 통합 PR #233에는 관리자 등록·미삭제 식당의 주소 변경에 따른 job 생성, +worker/lease와 이 provider의 연결, 관리자 위치 상태 DTO가 구현돼 있다. +[위치 작업 문서](location-jobs.md)의 코드 연결은 운영 활성화나 실제 지역·주소 품질 검증, +Google 보관 정책 승인 완료를 의미하지 않는다. + +Spring Boot BOM이 관리하는 `org.apache.httpcomponents.client5:httpclient5`를 추가한다. +JDK 21.0.7 client로는 응답 없이 연결을 끊는 합성 테스트에서 GET 요청이 두 번 전송됐다. +재시도/redirect를 명시적으로 차단할 수 있는 Spring의 지원 transport를 사용하기 위한 의존성이다. +기존 HTTP 호출인 KakaoOAuthClient는 `SimpleClientHttpRequestFactory`를 직접 지정하므로 +classpath에 따른 자동 선택의 영향을 받지 않는다. 다른 RestClient/RestTemplate 사용처는 없다. + +패키지는 [M1 ADR 0002의 고정본](https://github.com/TEAM-HASHI/HASHI-SERVER/blob/d2df3100fcfe693956662aae9ab1845275e163d5/docs/adr/0002-restaurant-map-query-and-location.md)의 +`restaurant/internal/map` 경계를 따른다. Google 구현과 설정은 그 아래 `google`에 있다. +다른 모듈은 이 provider나 후보 DTO를 import하지 않는다. + +- `Candidates`: 모든 후보를 불변 목록으로 반환한다. 첫 후보를 선택하지 않는다. +- `NoResults`: 정상 JSON의 빈 `results` 또는 repeated 필드가 생략된 `{}`다. +- `Failure`: 분류 enum과 알려진 HTTP 상태만 전달한다. 원문 메시지나 예외 cause는 없다. + +후보의 위도/경도는 `BigDecimal`이며 각각 [-90, 90], [-180, 180]을 검증한다. +명시적 `(0,0)`은 유효하다. 누락/문자열/비유한 좌표를 영점으로 대체하지 않는다. +국가, 행정구역, 주소 구성요소, types, granularity를 반환하며 PR #233의 채택 정책이 국가·주소·정확도를 검증한다. +누락되거나 새로 추가된 granularity는 `UNKNOWN`이고, 누락된 문자 메타데이터는 빈 문자열이다. +알 수 없는 필드는 무시하지만 알려진 필드의 잘못된 자료형, JSON 중복 키, trailing token은 거부한다. +후보 중 하나라도 형식이 잘못되면 전체 응답이 `INVALID_RESPONSE`다. + +## HTTP 계약 + +2026-09-26 확인한 [v4 GA 개요](https://developers.google.com/maps/documentation/geocoding/geocoding-v4-overview), +[주소 요청](https://developers.google.com/maps/documentation/geocoding/geocoding), +[v3 이전 안내](https://developers.google.com/maps/documentation/geocoding/geocoding-v4-migrate), +[GeocodeResult](https://developers.google.com/maps/documentation/geocoding/reference/rest/v4/GeocodeResult)를 따른다. + +- 고정 `https://geocode.googleapis.com/v4/geocode/address`에 GET을 보낸다. endpoint 설정은 없다. +- 주소는 `address.addressLines` URI 변수로 엄격히 인코딩한다. 일본어와 `+ & # / %`를 데이터로 보존한다. +- `languageCode=en`, `regionCode=JP`는 언어/지역 편향이다. 운영 주소 표본에서 영어 주소의 오해석을 + 줄이기 위해 응답 언어를 고정하며, 일본 결과나 식당 POI 일치를 보장하지 않는다. +- 서버 키는 `X-Goog-Api-Key` 헤더에만 넣는다. +- `X-Goog-FieldMask`는 location, granularity, postalAddress의 regionCode/administrativeArea, + addressComponents의 longText/shortText/types와 결과 types로 제한한다. +- v3의 `status`, `error_message`, `partial_match`를 성공 판정에 사용하지 않는다. +- 성공 200 JSON은 설정된 크기 안에서 읽는다. 403은 권한 거부와 quota 초과가 겹치므로 + JSON·비압축 응답의 처음 8KiB 안에서 `error.status`만 제한적으로 판독한다. + malformed·초과·비 JSON 오류는 ACCESS_DENIED로 분류한다. `error.message`, details, + Retry-After의 원문은 결과나 로그에 전달하지 않는다. +- redirect, proxy, cookie, authenticator와 자동 SDK 재시도는 사용하지 않는다. + +| HTTP/상황 | 실패 분류 | +|---|---| +| 비활성 | DISABLED | +| 빈 주소 또는 기존 주소 한도 255자 초과, HTTP 400/422 | INVALID_REQUEST | +| 401, 403 PERMISSION_DENIED·알 수 없거나 읽을 수 없는 오류 | ACCESS_DENIED | +| 403 RESOURCE_EXHAUSTED | QUOTA_EXCEEDED | +| 404 | CONFIGURATION_ERROR | +| 429 | QUOTA_EXCEEDED | +| 5xx | TRANSIENT_ERROR | +| HTTP 408, DNS/연결/응답 deadline | TIMEOUT | +| 호출자 thread 중단 | CANCELLED | +| 진행 중인 호출 4개로 실행 한도 도달 | CAPACITY_EXCEEDED | +| 연결 실패·응답 도중 연결 유실 | CONNECTION_ERROR | +| 3xx | REDIRECT_REJECTED | +| 응답 크기 초과 | RESPONSE_TOO_LARGE | +| JSON/자료형/좌표/Content-Type 오류, 그 밖의 상태 | INVALID_RESPONSE | + +2026-10-03 [v4 개요](https://developers.google.com/maps/documentation/geocoding/geocoding-v4-overview)와 +[AIP-193의 HTTP JSON 오류 형식](https://google.aip.dev/193#http11json-representation)을 다시 확인했다. +`error.status`는 canonical code이며 v3/legacy의 `OVER_QUERY_LIMIT`, `rateLimitExceeded`, +`userRateLimitExceeded`, `OVER_DAILY_LIMIT`, `dailyLimitExceeded`를 v4 상태로 해석하지 않는다. +이 값이나 알 수 없는 값이 담긴 403은 `ACCESS_DENIED`로 분류한다. +403 분류 검증은 합성 mock 응답 기준이며 실제 Google 403 응답을 확인한 결과가 아니다. +현재 adapter는 quota의 일일/분당 한도를 구별하지 않으며 `ErrorInfo`를 추측해 추가 분류하지 않는다. +이 분류만으로 재시도를 허가하지 않는다. PR #233의 worker는 내부 호출 예산·누적 attempt·공유 quota 대기를 +확인해 제한 재시도/backoff를 적용한다. 특히 quota를 무한 재시도하지 않는다. + +## 제한과 설정 + +Spring 설정 prefix는 `hashi.map.google-geocoding`이다. 별도 설정이 없으면 비활성이며 키 없이 부팅한다. +활성 시 키 형식과 제한값을 Bean 생성 중 검증한다. 검증 자체는 외부 요청을 하지 않는다. + +| 설정 | 기본값 | 허용 범위 | +|---|---|---| +| enabled | false | true/false | +| api-key | 없음 | 비어 있지 않은 서버 키; 공백/헤더 제어문자 거부 | +| connect-timeout | 2s | 1ms~10s | +| response-timeout | 5s | 1ms~30s, connect-timeout 이상 | +| max-response-bytes | 65536 | 1024~1048576 | + +환경 변수는 Spring relaxed binding 규칙을 따른다. 예를 들어 enabled는 +`HASHI_MAP_GOOGLEGEOCODING_ENABLED`, api-key는 `HASHI_MAP_GOOGLEGEOCODING_APIKEY`다. +키는 비밀 설정으로 공급하며 문서·명령줄 인자·로그에 넣지 않는다. 이 PR에서는 활성화하지 않는다. + +RestClient에는 전용 `HttpComponentsClientHttpRequestFactory`를 지정한다. +각 호출은 virtual thread에서 실행하고 호출자는 response-timeout까지만 기다린다. +DNS가 중단을 무시해도 호출자는 `TIMEOUT`으로 반환한다. 이때 Apache request를 취소하고 +전용 client를 즉시 닫아 DNS가 뒤늦게 끝나도 HTTP를 보내지 않는다. 호출자 중단은 +interrupt 상태를 유지하며 `CANCELLED`로 반환한다. 작은 조각을 계속 보내는 본문도 같은 deadline을 적용한다. +실제 작업이 종료될 때까지 동시 실행 슬롯을 유지한다. provider 인스턴스당 최대 4개이며, +장애 중 추가 호출은 큐에 쌓지 않고 `CAPACITY_EXCEEDED`로 반환한다. DNS 작업이 종료되면 슬롯을 회수한다. +Content-Length와 별개로 최대 한도+1 byte만 읽고, 초과 시 스트림을 닫는다. 압축 본문은 거부한다. +Jackson의 중첩 깊이·숫자 길이·문자열 길이도 제한한다. + +한 호출마다 전용 client를 사용하며 자동 재시도, redirect, 인증 재요청, cookie와 압축을 끈다. +본문 처리 후 client를 즉시 닫고 Spring response를 닫으므로 cleanup이 남은 큰 본문을 읽지 않는다. +연결 재사용 비용보다 요청 1회와 즉시 중단 보장을 우선한 구성이다. +HTTP 라이브러리 변경 시 응답 없이 연결을 끊는 wire 테스트를 유지한다. + +## 정보 노출과 검증 + +원문 주소, 요청 전체 URL, 키, Google 원문 응답과 좌표를 로그에 남기지 않는다. +properties, 후보 및 주소 구성요소의 `toString()`을 마스킹하고, 파서/HTTP 예외 cause를 전달하지 않는다. +새 transport의 logger는 logback에서 OFF로 두고, wire/header/request debug override가 있으면 +활성 Bean 생성을 거부한다. 운영에서도 HTTP/TLS wire dump나 +요청 헤더/body 수집을 켜지 않는다. 결과 관측에 허용하는 값은 실패 종류와 HTTP 상태다. +예상하지 못한 `RuntimeException`과 작업의 `ExecutionException`은 `INVALID_RESPONSE`로 유지하며, +진단 WARN에는 고정 phase(`execute`/`await`)와 예외 클래스명만 기록한다. +`await`에서는 바로 아래 cause의 클래스명만 확인한다. 원문 메시지, Throwable, stacktrace와 +cause chain은 로그에 전달하지 않는다. + +테스트는 합성 데이터와 mock/loopback HTTP만 사용한다. 실제 Google 계정·키·유료 API는 사용하지 않는다. +`GoogleGeocodingWireTest`는 인코딩/헤더, redirect, 실제 연결 유실 후 재전송 여부, +429/503, header/body deadline, Content-Length/chunked 크기 제한을 검증한다. +`GoogleGeocodingDeadlineTest`는 중단을 무시하는 합성 DNS로 호출자 반환, 늦은 HTTP 차단, +실행 슬롯 상한, 추가 호출 거부와 DNS 종료 후 회복을 검증한다. +설정/파서 테스트는 활성·비활성, 빈/복수 후보, 누락/알 수 없는 값, 숫자 경계, +잘못된 JSON과 예외/로그/toString 비노출을 검증한다. `GeocodingBoundaryTest`와 +`ModularityTests`가 모듈 외부 사용과 순환 의존을 검사한다. + +## PR #233 연결 구현과 운영 전 확인 + +2026-10-03 기준 다음 1~4는 PR #233에 구현돼 있으며, 5는 운영 전 확인 사항이다. + +1. DB transaction 밖에서 호출하도록 worker와 claim/완료 transaction Bean을 나눴다. +2. 후보 수·국가·지원 영역·주소 일치·정확도를 판정하고 모호하면 검토 상태로 둔다. 관광 지역 ID는 추론하지 않는다. +3. 좌표 6자리 저장 정밀도 변환, source/obtainedAt/validUntil과 주소 revision을 적용한다. +4. job/lease/revision을 재검사한 완료, 제한 재시도/backoff, quota·일일 예산과 안전한 상태 DTO를 연결했다. +5. 실제 계약·키 제한·quota·주소 품질·지역과 보관 정책을 확인한 후 별도 승인으로 운영을 활성화한다. + +M3a adapter와 PR #233의 worker 연결 구현은 실제 Google 호출 검증·운영 활성화나 지도 전체 완료를 의미하지 않는다. diff --git a/docs/map/implementation-plan.md b/docs/map/implementation-plan.md index f0f50cc9..06cf7ea9 100644 --- a/docs/map/implementation-plan.md +++ b/docs/map/implementation-plan.md @@ -1,17 +1,19 @@ # 지도 후속 구현과 인수 기준 상태: [#219](https://github.com/TEAM-HASHI/HASHI-SERVER/issues/219)의 문서 계획을 2026-10-01 PLAN `6f2c99c`와 develop `053fdb3`에 대조했다. +2026-10-06에는 develop `f6908ba`의 문서를 #225에 반영하고, 아래 좌표 보존·갱신·조회 정책을 보완했다. +이번 코드 변경은 #225의 좌표 모델·DB 제약에 한정한다. Redis·재시도·알림·부하 테스트는 후속 구현 기준이다. M2~M7은 아래의 작업 구분자다. 대응 이슈/PR은 별도로 추적하며, Draft PR이나 테스트 성공을 병합·배포로 취급하지 않는다. 계약은 [Map Contract v1](./map-contract-v1.md), 구조 결정은 [ADR 0002](../adr/0002-restaurant-map-query-and-location.md)를 따른다. -## 1. 쪼갠 이슈 계획 +## 1. 기존 이슈와 PR의 작업 범위 | 단위 / 이슈 제목안 | 선행·범위 | 인수 증거·제외 범위 | |---|---|---| | M2 `[Feat] 식당 위치와 관광 지역 모델 추가` | M1. restaurant 위치/지역 모델, nullable legacy 전환, revision·좌표 쌍·수명 제약, Port 값 계약 | 실제 MySQL migration·제약·기존 데이터 호환, ModularityTests. Google 호출·기존 주소 일괄 변환 제외 | | M3 `[Feat] 식당 주소 좌표 변환 작업 추가` | M2. 관리자 원자 저장, durable 작업·lease·CAS, Google adapter, 상태·재처리 API | fake provider와 transaction/경합 검증, 실패 후 재개. 새 dependency·DB·보안 영향 사전 설명. 유료 호출 전 실제 청구 지역·계약과 FE 지도 제공자/사용 필드의 허용 조합 확인. 운영 호출 기본 비활성 | | M4a `[Feat] 관광 지역과 지도 후보 조회 추가` | M2. BBOX·필터·지역 count, 지도 DTO·map-location, 합성 fixture | 경계·삭제·만료·지역 미분류·bulk query 검증. 완성된 공개 페이지 API로 출시하지 않음 | -| M4b `[Feat] 지도 정렬 세션과 페이지 연결 추가` | M4a. Redis 세션·절대 TTL·cursor·정렬 변경·오류 | 실제 Redis TTL/eviction/장애와 MySQL 재검사, 중복·누락 반례. M4a와 함께 Map Contract의 공개 조회 완성 | +| M4b `[Feat] 지도 정렬 세션과 페이지 연결 추가` | M4a. Redis 세션·유휴/최대 수명·cursor·정렬 변경·오류 | 실제 Redis TTL/eviction/장애와 MySQL 재검사, 중복·누락 반례. M4a와 함께 Map Contract의 공개 조회 완성 | | 후속 `#251 관리자 관광 지역 설정과 식당 지역 지정` | M2·M4a. 지역 목록/설정 API와 식당 소속 지정·해제 | ADMIN 권한, 중복·동시 수정, 관리자 등록부터 공개 집계까지 합성 데이터 검증. 실제 대표 좌표·식당 소속은 기획 승인 후 입력 | | M5 `[Feat] 기존 식당 좌표 보완과 수명 정리 추가` | M3. 조회 전용 dry-run·checkpoint·제한 실행·만료 전 갱신/제거 | resume·동시 실행·stop·expiry·백업 복구 방어. 유료 호출/운영 backfill 실행은 별도 승인과 결과 보고 | | M6 `#242 컬렉션 전체 핀과 저장 요약 연동` | M2 Port 및 develop에 병합된 #216의 저장/권한 기반. user 공개 집계·내 상태·전체 핀 API | 소유권·비공개 접근·버전 변경·전체 반환·실패 원자성, security matcher와 모듈 경계. #216 쓰기 API 중복 구현 금지 | @@ -19,15 +21,25 @@ M2~M7은 아래의 작업 구분자다. 대응 이슈/PR은 별도로 추적하 학습 순서는 M2의 좌표 불변식 → M4a의 BBOX → M3의 짧은 transaction과 외부 HTTP → M4b의 정렬 세션 → M5/M6 통합이다. M3와 M4a는 M2 이후 독립 개발할 수 있으나 공유 Port· -관리자 DTO·SecurityConfig·다음 Flyway 번호는 담당을 정한다. 현재 미병합 지도 migration은 -develop V28(매거진)·V30(컬렉션) 다음 V31(위치) → V32(작업) → V33(유지보수) 순서로 정리한다. -M6가 schema를 추가하면 V34 이후를 사용하고, 병합·배포도 이 순서를 지킨다. 이미 적용된 -운영 Flyway 이력은 확인 없이 수정하거나 `outOfOrder`로 우회하지 않는다. +관리자 DTO·SecurityConfig·다음 Flyway 번호는 담당을 정한다. +2026-10-08 develop `e780c4c`의 V37까지 적용한 다음 +V38(위치) → V39(작업) → V39.1(선택 위치 확인 주소) → V39.2(Places 선택·호출 예산) → V39.3(위치 확인 기록) → V40(유지보수) → +V41(컬렉션 변경번호)를 적용한다. +병합된 V32 사용자 익명 닉네임, 공지 V36·V36.1, 약관 V37은 수정하지 않는다. +재번호 대상은 아직 병합되지 않은 지도 migration뿐이다. 해당 지도 파일이 개발·운영 DB에 +적용되지 않았음을 확인한 뒤 파일명과 연결된 테스트·문서만 조정한다. SQL 본문은 바꾸지 않는다. +이미 병합·적용된 공지·약관 migration의 파일명과 SQL, Flyway 이력은 변경하지 않는다. -2026-10-03 기준 다른 담당자의 미병합 PR #249도 V31을 사용한다. 위 번호는 현재 스택의 -번호이며 병합 순서가 확정된 예약 번호가 아니다. 먼저 병합된 migration을 기준으로 남은 -미적용 파일·테스트·문서의 번호를 함께 조정하고, 최종 조합에서 중복 검사와 빈 DB 검증을 한다. -다른 PR의 번호만 피하려고 높은 번호를 선점하거나, 적용된 migration의 checksum을 바꾸지 않는다. +이미 병합된 파일과 Flyway 이력은 수정하지 않고 `outOfOrder`나 checksum repair로 +순서를 우회하지 않는다. 다른 담당자의 PR #249·#250은 수정하지 않는다. +최종 조합에서 버전 중복, 빈 DB 적용, develop V37에서의 기존 데이터 보존을 검증하고, +실제 병합 전 최신 develop에 새 migration이 추가됐는지 다시 확인한다. + +기존 데이터 보존은 #236의 `FlywayMigrationTest`에서 별도 MySQL로 확인한다. +V37까지 적용한 뒤 합성 식당(삭제 식당 포함), 메뉴, 해시태그와 컬렉션 저장 관계를 넣고, +지도 migration을 모두 적용한 전후의 기존 값을 비교한다. 새 좌표·작업은 자동 생성되지 않고 +Geocoding 예산과 Places의 SEARCH·DETAILS 두 예산 행은 모두 비활성인지도 확인한다. +운영 DB를 초기화하거나 실제 식당 주소를 복사하는 테스트가 아니다. ## 2. 테스트 인수 시나리오 @@ -44,19 +56,19 @@ production 테스트를 추가하지 않는다. | MAP-06 / M4b | 모두 동점인 fixture에서 recommend↔rating↔reviews 전환 시 추천 순서 유지. 같은 querySessionId·queryBounds, 새 정렬 첫 페이지로 교체 | | MAP-07 / M4b | 2페이지 전 평점/리뷰 변경·신규 식당·삭제·좌표 만료/주소 변경. 순위 수치 고정, 현재 무효 ID 제외, 이후 후보로 채움, cursor 소비 위치 확인 | | MAP-08 / M4b | cursor 변조/다른 API 토큰/모드 혼합은 400. 같은 cursor 재시도·응답 역전·동시 정렬에 중복 append나 이전 세대 화면 교체 없음 | -| MAP-09 / M4b | 생성 15분 뒤 410, 읽기/재정렬은 TTL 연장 없음. eviction도 410, Redis timeout은 503, 새 순서 몰래 연결 금지 | -| MAP-10 / M4b | 세션 bytes/후보 수/동시 세션 admission 초과와 일부 쓰기 실패에서 전체 실패. 조용한 ID 절단 없음. 인증 Redis 영향과 query plan 측정 | +| MAP-09 / M4b | 유휴 5분 뒤 410, 정상 다음 페이지/재정렬은 현재부터 5분 유지하되 최초 생성 30분 상한. 동시 갱신·만료 직후 요청으로 부활/기한 역행 없음. 잘못된 요청/실패는 연장 없음. eviction 410, Redis timeout 503, 응답 expiresAt과 실제 수명 일치 | +| MAP-10 / M4b | 후보 500·501·620개를 끝까지 조회해 중복·누락 없음. 작은 묶음의 11번째 후보가 다음 페이지에 남음. 실제 용량 초과·부분 쓰기 실패는 전체 실패, 조용한 ID 절단 없음. 호출자별 제한·인증 Redis 영향·query plan 측정 | | LOC-01 / M2·M3 | 주소 저장 중 작업 insert 실패면 식당 저장도 rollback. commit 직후 worker 미실행/프로세스 종료여도 PENDING 작업 복구 | | LOC-02 / M3 | revision 1 결과보다 revision 2가 먼저 완료, 같은 revision 재처리, lease 재claim 후 옛 worker 완료. 오래된 좌표/실패 상태 덮어쓰기 없음 | | LOC-03 / M3 | HTTP adapter 진입 때 실제 transaction 비활성. 느린 HTTP 동안 식당 lock 미점유. 결과 저장 실패·중복 전달은 안전한 재처리 | | LOC-04 / M3 | 0건·복수/저정확도·timeout·quota·권한 오류의 상태 분류, 제한 backoff·attempt 소진·관리자 재처리·동시 재처리 1개 작업 | -| LOC-05 / M5·M7 | validUntil 경계에서 서버·FE 노출 중단, DB/복제 캐시 제거, refresh 실패로 수명 연장 없음, 만료 데이터 백업 복원도 공개 차단 | +| LOC-05 / M2·M5·M7 | 같은 주소 갱신·재시도·실패 중 유효 좌표 유지. 주소 변경 시 즉시 제거, 늦은 결과 거절. validUntil 경계에서 서버·FE 노출 중단, 작업 상태와 무관하게 DB/복제 캐시 제거, 새 결과/진행 작업 보호. 실패로 수명 연장 없음, 만료 데이터 백업 복원도 공개 차단 | | COL-01 / M6 | 23개 공개 식당 중 2개 위치 없음. 목록 첫 10개와 독립적으로 핀 21개, visible=23/unavailable=2. 위치 없는 2개 목록 유지 | | COL-02 / M6 | 같은 사용자의 두 컬렉션에 저장해도 saveCount=1. 하나 제거하면 유지, 마지막 제거면 감소. 타 공개 컬렉션 열람은 내 저장 아님 | | COL-03 / M6 | 익명 공개 열람 성공, 비공개 타인/익명 404, 소유 USER 성공. ADMIN/ONBOARDING의 내 저장·편집 접근 차단. 쓰기 matcher 비공개 유지 | | COL-04 / M6 | 내부 두 번째 bulk 실패·응답 전 권한 철회/삭제·소속 version 변경. 부분 200 없음, 404면 FE 기존 핀 제거, 409면 전체 재조회 | | COL-05 / M6·M7 | 공개 집계/개인 상태 중 하나만 실패해도 false/0으로 대입하지 않음. 0개 핀 성공과 전체 조회 오류 구분. 전체 capacity 초과는 명시적 실패 | -| COL-06 / M6 | #216 쓰기의 공백 제거를 #242에서 수정. 생성·수정 이름/설명 앞뒤 공백 보존, 공백만인 이름 거부, 기존 이름 중복 판정 유지 | +| COL-06 / M6 | 생성·수정 이름/설명의 앞뒤 공백 제거·중간 공백 유지. 공백만인 이름 거부, 제거 후 이름으로 중복 검사. PATCH 설명 null 유지·빈 문자열/공백 삭제. 입력 길이 20/100자 제한 유지(2026-10-12 결정) | | API-01 / M4·M6 | SuccessResponse/ErrorResponse·HTTP/code·errors·nextCursor 생략·UTC data 시각·no-store 직렬화와 실제 SecurityFilterChain 검증 | | API-02 / M2~M6 | RestaurantPort 밖 도메인 내부 참조/순환 없음. 지도 페이지·collection bulk의 ID 수 증가에 item별 DB/media 호출이 늘지 않음 | @@ -90,3 +102,89 @@ M1은 문서 낮은 위험 리뷰 1명이다. 후속 DB·transaction·동시성 서버 키·운영 DB 정보·사용자 정보는 PR 증거에 넣지 않는다. 배포, 유료 호출, backfill 실행, 운영 검증은 각각 실행 여부와 근거를 별도로 기록한다. + +## 5. 2026-10-06 합의한 수정 방향 + +관련 문서와 코드는 기존 PR에 함께 반영한다. 문서만을 위한 새 PR을 만들지 않는다. +#225는 아래 정책 전체의 구현 완료가 아니라 좌표 보존 모델의 첫 변경이다. + +### 좌표 취득·갱신·실패 처리 + +- 기존 Spring Boot·MySQL 작업 큐를 유지한다. 새 SQS·Kafka나 별도 알림 서비스를 추가하지 않는다. + DB transaction 밖 Google 호출, 짧은 claim/완료 transaction, revision·request·lease 검사를 유지한다. +- 같은 주소의 갱신은 기존 좌표·source·obtainedAt·validUntil을 유지한다. 새 결과가 검증을 통과하면 + 한 번에 교체한다. 주소 변경은 즉시 이전 좌표를 지우고 revision을 올린다. +- `validUntil`은 적용 계약상 보관·사용 기한이고, `nextAttemptAt`은 다음 작업 시각이다. + 첫 갱신 대상 시각은 `validUntil - 3일`로 계산한다. 이를 위한 별도 컬럼은 우선 추가하지 않는다. + 정리 지연까지 감안해 기한 안에 제거되도록 작업 간격·처리량을 검증한다. +- 갱신 최초 시도 후 재시도 간격은 **1분 → 5분 → 30분 → 2시간 → 6시간 → 12시간 → 24시간**이다. + 작은 무작위 지연(jitter)을 더하고 최초 요청 포함 최대 8회로 제한한다. timeout·일시 장애 등 + 재시도 가능한 실패에만 적용하며 quota는 전체 호출 제어와 함께 처리한다. 인증·권한·설정 오류는 + 즉시 중단·알림, 결과 없음·모호한 후보는 운영 확인으로 보낸다. +- 재시도 횟수는 DB 작업에 남겨 서버 재시작 후에도 이어간다. 스케줄러가 같은 실패 작업을 계속 + 새 작업으로 바꾸어 시도 횟수를 초기화하면 안 된다. 신규 등록의 재시도 정책은 #233에서 별도 검증하며 + 이 갱신 정책을 문서 수정만으로 적용했다고 보지 않는다. +- 작업 상태가 PENDING/RETRY_WAIT/REVIEW_REQUIRED/FAILED여도 기존 좌표가 유효하면 사용할 수 있다. + 만료되면 사용을 중단하고 제거한다. 재시도는 이어갈 수 있지만 실패했다고 옛 validUntil을 미루지 않는다. + 제거는 진행 중 요청을 취소하거나 직전에 저장된 새 결과를 지워서는 안 된다. +- 자체 확보 좌표와 Google 결과의 출처·보관 조건을 구분한다. Google 결과를 관리자가 확인했다고 + 자체 좌표로 바꾸지 않는다. 현재 #225 모델은 ADMIN에도 validUntil을 요구한다. 자체 좌표의 별도 수명 + 정책 적용에는 모델·CHECK·조회·DTO 변경이 필요하며 이번 구현 범위에 포함하지 않는다. + +### 운영자가 알아야 할 실패 + +기존 Prometheus·Loki·Grafana·Discord 경로를 사용한다. 환경·실패 원인별로 묶고 동일 장애의 식당마다 +알림을 보내지 않는다. 지표에는 낮은 종류 수의 상태/원인만 쓰고 식당·작업 ID는 안전한 로그에 남긴다. +Google 원문·주소·좌표·키·요청 URL은 로그에 남기지 않는다. + +| 조건 | 초기 알림 기준 | +|---|---| +| 인증·권한·설정 오류 | 첫 발생부터 알림 | +| 3회 이상 실패했고 15분 이상 미해결 | 경고 | +| 최대 시도 횟수 소진 또는 만료 24시간 이내 미갱신 | 우선 대응 알림 | +| 실행할 작업이 있는데 진행이 장시간 멈춤 | 작업 큐·스케줄러 점검 알림 | + +중단 감지 시간·그룹별 반복 간격은 실제 poll/lease/처리 시간에 맞춰 #235에서 설정한다. +알림 경로가 있다는 사실과 새 규칙의 배포·수신 검증을 구분해서 보고한다. + +### Redis와 페이지 조회 + +- 초기 구현은 조회 ID별 키 하나와 TTL을 사용한다. 키에 식당 ID·추천 순서·정렬 기준값만 담고 + Google 좌표·이미지·개인 저장 상태는 복제하지 않는다. 후보를 여러 키로 나누거나 정렬별 자료구조를 + 따로 두는 방식은 실제 크기·정렬·전송 비용에서 병목이 확인될 때 검토한다. +- 초기 유휴 시간은 **5분**, 최대 수명은 **30분**이다. 정상 다음 페이지 조회·정렬 변경 때 + `min(현재 시각 + 유휴 시간, 최초 생성 + 최대 수명)`으로 갱신한다. 기존 만료 시각에 시간을 더하지 + 않는다. 수치는 설정으로 두고 사용 간격·만료 비율·메모리 측정에 따라 우선 5~10분 안에서 조정한다. +- 실패·잘못된 cursor는 연장하지 않고 자동 keepalive도 넣지 않는다. 갱신 도중 이미 만료된 키는 + 되살리지 않는다. 동시 요청에서 만료 시각이 뒤로 줄거나 최대 수명을 넘지 않도록 원자적으로 처리한다. + Redis TTL, 저장된 수명 검사, 응답 expiresAt을 맞춘다. +- 410이면 기존 목록과 아직 유효한 핀을 유지하고 새로 불러오기를 제공한다. 마지막 조회 범위·필터· + 현재 정렬로 새 조회하며 성공 후 첫 페이지로 교체한다. 503은 같은 요청 재시도, 호출자별 제한 429는 + 대기 후 재시도로 구분한다. Google 좌표 만료는 조회 세션 만료와 별개다. +- #234에서 고정 슬롯을 제거하고 조회 ID별 키·슬라이딩 TTL·호출 제한·메모리 예산을 적용했다. + [#252](https://github.com/TEAM-HASHI/HASHI-SERVER/issues/252)의 운영 예산 확정은 격리된 부하 검증과 함께 확인한다. 정상 조건의 전체 조회를 보장하면서 실제 과부하는 오류로 + 알리고 결과를 조용히 잘라내지 않는다. 제한값은 측정 없이 숫자만 키우지 않는다. +- 인증과 Redis를 공유하면 인증 키의 eviction·쓰기 실패가 발생하지 않도록 여유와 정책을 검증한다. + prefix나 논리 DB 구분은 메모리 격리가 아니다. 보호를 입증하기 어렵다면 인스턴스 분리를 검토한다. +- 각 페이지에서 남은 후보 전체를 DB 조회하지 않는다. 작은 묶음으로 유효한 10개+다음 1개를 찾으면 + 멈춘다. 탈락한 후보는 건너뛰고 11번째 유효 식당은 소비하지 않는다. DB 조회 개선과 별개로 Redis + 전체 읽기·역직렬화·정렬 비용도 측정한다. + +### 구현 순서와 검증 + +1. [#225](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/225): 이 문서·계약 갱신과 좌표 상태/DB 제약 수정. + [모델 문서](location-model.md)의 후속 SQL·정리 호환 조건까지 리뷰한다. +2. [#226](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/226): 공식 Google 프로젝트·계약·제한된 개발 키를 + 준비하고 소량 실제 호출을 확인한다. 이번 #225 작업은 실제 호출을 하지 않는다. +3. [#233](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/233)·[#235](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/235): + worker·갱신·정리·알림을 연결하고 재시도/주소 변경/만료/복구를 검증한다. +4. [#229](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/229)·[#234](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/234)·#252: + 좌표 조회 조건, 작은 묶음 조회와 Redis 수명·용량 처리를 함께 반영한다. +5. [#244](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/244)·[#236](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/236): + 컬렉션 전체 유효 핀과 일반 지도 페이지를 연결하고 전체 흐름을 검증한다. + +통합 테스트로 500·501·620개 전체 조회, 동점 순서, 삭제·만료·같은 cursor 재시도를 확인한다. +k6는 별도의 합성 데이터 환경에서 새 조회/다음 페이지/정렬/필터 변경을 구분해 응답 시간·오류율·처리량을 +측정한다. Google을 부하 테스트에 호출하지 않는다. TTL보다 긴 지속 부하와 부하 감소 후 복구에서 +세션 정리, Redis 메모리와 DB 연결이 안정되는지도 확인한다. 합격 기준을 먼저 정하고 실측 결과를 기록한다. +이 계획에는 아직 k6 측정 결과나 공개 활성화 승인이 없다. 최종 코드의 독립 리뷰·팀 리뷰 후 병합을 판단한다. diff --git a/docs/map/location-jobs.md b/docs/map/location-jobs.md new file mode 100644 index 00000000..87368bb2 --- /dev/null +++ b/docs/map/location-jobs.md @@ -0,0 +1,131 @@ +# 관리자 식당 위치 확인 (#224) + +## 관리자 계약 + +기존 등록은 201 `ADMIN-204`, 수정은 200 `ADMIN-205`다. 식당 정보와 위치 작업을 저장하면 성공한다. +응답에 `geocodingAddress`, `locationStatus`, `addressRevision`을 추가한다. +`geocodingAddress`는 저장된 명시 값이며 null이면 서버가 표시용 `address`를 위치 확인 입력으로 사용한다. +Google의 위치 확인 성공을 뜻하지 않는다. + +- `address`는 사용자에게 보여 줄 전체 주소를 원문 그대로 보존한다. +- 등록의 `geocodingAddress`는 선택이다. 표시 주소와 다른 provider 입력이 필요할 때 명시하며, + 서버는 저장 문자열에서 건물명이나 층을 추측해 제거하거나 일본어로 번역하지 않는다. +- 수정에서 `geocodingAddress` 미전송 또는 null은 유지, 공백 문자열은 명시 값 삭제다. + 명시 값은 앞뒤 공백을 제거해 저장한다. +- 표시 주소를 바꾸면서 `geocodingAddress`를 생략하면 과거 기본 주소를 자동으로 신뢰하지 않고 삭제한다. + 새 표시 주소가 fallback 입력이 되며, 이전 위치 작업과 결과는 새 revision에 적용되지 않는다. +- 표시 주소를 바꿔도 같은 기본 주소를 유지하려면 응답의 `geocodingAddress`를 수정 요청에 함께 보낸다. +- 이름·현지명·표시 주소 또는 실제 위치 확인 입력이 바뀌면 `addressRevision`이 증가하고 다시 확인한다. + 같은 기본 주소를 보내도 건물명·층 등 표시 주소가 바뀌면 기존 핀과 확인 기록을 지운다. + 같은 값을 다시 보내거나 가격·소개만 바꾸는 요청은 기존 좌표와 진행 중 작업을 보존한다. + +- `GET /api/v1/admin/restaurants/{id}/location`: 200 `COMMON-200`. +- `POST /api/v1/admin/restaurants/{id}/location/retry`: body `{"expectedAddressRevision":1}`, + 200 `COMMON-200`. 누락/음수 revision은 400, 현재 revision 불일치/READY는 409 `RESTAURANT-019`. +- `GET /api/v1/admin/restaurants/locations`: `status`(기본 `REVIEW_REQUIRED`)와 선택 `source`로 필터링하고 + `restaurantId ASC`로 조회한다. 관리자 목록 표준에 따라 0-based `page`(기본 0), `size`(기본 20, 최대 100)를 + 사용하며 응답은 `restaurants`, `page`, `size`, `totalCount`, `totalPages`다. 한 응답의 목록과 총건수는 같은 + DB snapshot을 사용한다. `page × size`가 32-bit offset 상한을 넘으면 400이며, 처리 중 상태가 바뀌면 페이지 + 사이 결과가 달라질 수 있으므로 처리 후 현재 페이지를 다시 조회한다. +- 세 경로 모두 기존 SecurityFilterChain의 ADMIN 권한을 사용한다. 삭제/없는 식당은 `RESTAURANT-004`. +- 시설 후보 검색과 선택, Place ID 결합, 별도 SEARCH/DETAILS 예산은 [Places 후보 선택](place-selection.md)을 따른다. +- 상태 응답: `restaurantId`, `locationStatus`, `addressRevision`, `source`, `verificationMode`, `validUntil`, `attempt`, + `nextAttemptAt`, `failureCode`, `canRetry`, `confirmationMethod`, `confirmedByAdminId`, `confirmedByAdminLoginId`, `confirmedAt`, + `confirmedRevision`. 시각은 UTC ISO-8601, Cache-Control은 no-store. + Places 선택의 `confirmedAt`은 관리자 선택 접수 시각이며 좌표 취득 완료를 뜻하지 않는다. 선택 직후 `source`는 null이고, + Places Details 검증에 성공해 좌표를 저장하면 `GOOGLE_PLACES`가 된다. +- 상태 조회·재시도·Places 선택 응답과 검토 목록에는 확인자 표시용 `confirmedByAdminLoginId`를 내려준다. + `confirmedByAdminId`가 기록의 기준이며 로그인 ID는 그 계정의 현재 값이다. 예를 들어 + `{"confirmedByAdminId":12,"confirmedByAdminLoginId":"hashi_admin"}`이면 12번 관리자가 확인했다는 뜻이다. + 자동 확인·과거 확인자 기록 없음·계정 삭제 시 로그인 ID는 null이다. 계정이 삭제돼도 기록된 내부 ID는 유지한다. + 클라이언트가 확인자 로그인 ID를 보내지 않으며 관리자 응답에만 표시한다. 목록은 페이지의 확인자 ID를 모아 + 한 번에 조회하므로 식당마다 계정을 추가 조회하지 않는다. +- UNRESOLVED의 revision은 0, attempt는 0이다. PENDING 재처리는 기존 작업을 반환한다. + READY는 갱신 API 범위이며 일반 재처리로 받지 않는다. 좌표/주소/provider 원문/lease는 반환하지 않는다. + +## 자동 채택 범위 + +v1은 언어·건물명·층수와 Google result type 이름으로 주소를 제한하지 않고, 다음 안전 조건을 모두 통과한 +**유일한 도쿄 rooftop 후보**를 자동 채택한다. 합성 fixture는 실제 식당 주소가 아니다. + +1. 위도·경도가 원본 상태에서 유효하고, `countryCode`가 존재하며 `JP`, 고정된 응답 언어에 따른 + `administrativeArea`가 `Tokyo` 또는 `東京都`이고 별도 설정된 지원 BBOX 안이어야 한다. + countryCode 누락은 다른 국가와 구분해 운영 코드로 남긴다. +2. `granularity=ROOFTOP`을 요구한다. `street_address`, `premise`, 쇼핑몰·POI 같은 result type과 + component allowlist는 채택 조건으로 사용하지 않는다. +3. 입력과 provider component 양쪽에서 우편번호 또는 본번을 하나로 확실히 추출할 수 있을 때만 충돌을 + 거절한다. 丁目/Chome/chōme, 番/番地, 号와 2~3구간 하이픈 표기를 비교하며 provider가 마지막 숫자만 확실히 + 준 경우에는 그 suffix만 비교한다. 같은 의미의 component에 서로 다른 값이 여러 개면 충돌로 거절한다. + 건물 층 표기와 `subpremise`는 비교에서 제외하되 저장 입력은 바꾸지 않는다. +4. 우편번호·본번이 없거나 애매하면 이를 일치한다고 단정하지 않고 conflict veto만 생략한다. 전체 주소 문자열 + 동등성, 세 숫자 강제, 번역·유사도·부분 포함 비교는 하지 않는다. +5. 위 조건을 통과한 후보가 하나면 채택한다. 반올림 전 위도·경도가 숫자로 정확히 같은 중복 응답만 + 하나의 위치로 취급하고, 서로 다른 좌표가 둘 이상이면 `AMBIGUOUS_RESULTS`다. 근접 좌표를 합치거나 첫 후보를 + 임의 선택하지 않는다. +6. 채택 후 HALF_UP으로 소수점 6자리까지 반올림한다. + +이는 [Google v4 결과 필드](https://developers.google.com/maps/documentation/geocoding/reference/rest/v4/GeocodeResult)를 +이용한 Hashi의 채택 정책이다. [v4 이전 문서](https://developers.google.com/maps/documentation/geocoding/geocoding-v4-migrate)의 +partial_match 제거와 국가/지역 입력의 편향 의미를 반영한다. ROOFTOP과 제한된 충돌 검사는 식당 POI 동일성을 +보장하지 않는다. 실제 운영 검증은 대표 주소와 독립적인 지도 POI 비교를 함께 본다. + +## 호출과 재시도 제한 + +| 항목 | 값/의미 | +|---|---| +| worker enabled | 기본 false (`hashi.map.location-job.enabled`) | +| Google enabled | 기본 false, 선행 adapter 설정을 별도로 사용 | +| lease | 2분; adapter의 최대 30초 deadline보다 길다 | +| polling | 기본 5초 (`hashi.map.location-job.poll-delay`, ms), cycle 후보 최대 50 | +| 실행 스레드 | 전용 단일 scheduler, cycle 중첩 없음; 기존 예약 작업의 scheduler 유지 | +| max-attempts | 기본 8(최초 요청 포함), 허용 1~8, 같은 작업 행에 누적 | +| 재시도 간격 | 1분 → 5분 → 30분 → 2시간 → 6시간 → 12시간 → 24시간, 각 간격에 0~10% 무작위 지연 | +| Google quota | 같은 재시도 간격을 DB blockedUntil에 기록해 전체 서버 대기; 마지막 실패도 24시간 + jitter 적용 | +| 일일 예산 | DB daily_limit, 초기 0; UTC 날짜가 앞으로 바뀔 때만 갱신 | +| 전역 동시 처리 | DB max_concurrent, 초기 0, 허용 0~4 | +| 중단 | application enabled 또는 DB enabled=false; 새 claim 중단 | +| retention | 활성 시 명시 필수, 5분~30일. 실제 계약보다 길게 설정하면 안 됨 | +| south/north/west/east | 활성 시 명시 필수. 운영 값 기본 제공 없음 | + +| 결과 | 관리자 상태/안전한 코드 | +|---|---| +| 유일한 도쿄 rooftop 후보이며 확실한 우편번호·본번 충돌 없음 | READY, failureCode null | +| 결과 없음/복수/국가 누락·다른 국가/영역/정확도/확실한 번호 충돌 | REVIEW_REQUIRED, NO_RESULTS / AMBIGUOUS_RESULTS / COUNTRY_MISSING / COUNTRY_MISMATCH / OUTSIDE_SUPPORTED_AREA / INSUFFICIENT_PRECISION / ADDRESS_MISMATCH | +| timeout/연결/5xx | RETRY_WAIT; TIMEOUT / CONNECTION_ERROR / TRANSIENT_ERROR | +| Google 429 | RETRY_WAIT / QUOTA_EXCEEDED, 공유 대기; 마지막 시도도 공유 대기를 기록하고 FAILED / ATTEMPTS_EXHAUSTED | +| 로컬 실행 슬롯 부족 | RETRY_WAIT / CAPACITY_EXCEEDED, Google quota와 구별 | +| 취소 | 한도 내 RETRY_WAIT / CANCELLED, 마지막 시도는 FAILED / ATTEMPTS_EXHAUSTED; 중단된 thread는 lease 만료 후 복구 | +| 비활성 provider/키권한/설정/잘못된 요청·응답 | FAILED / adapter의 안전한 FailureKind | +| 최대 시도 소진 | FAILED / ATTEMPTS_EXHAUSTED | +| 오래된 revision/request/job/lease 또는 삭제 | 성공/실패 모두 no-op | + +취소/timeout의 아직 남아 있을 수 있는 실행 슬롯은 lease 기한까지 보존한다. +일일 예약량은 전송 여부를 임의로 추측해 환급하지 않는다. worker만 재시도를 소유하고 adapter는 재시도하지 않는다. +관리자 재처리의 attempt는 새 이력에서 0으로 시작하나 전역 예산은 그대로 적용한다. + +## 운영 활성화 전에 확인할 사항 + +실제 Google 호출·운영 DB·운영 계정·backfill은 이 구현 테스트에 사용하지 않는다. +worker를 켰으나 지원 범위/보관 기간이 없거나 유효하지 않으면 식당 저장은 유지하고 +작업을 CONFIGURATION_ERROR/FAILED로 기록한다. provider 호출/예산 예약은 하지 않는다. +실제 프로젝트/키 제한, 청구 지역/계약, quota·비용 예산, 대표 주소와 지원 BBOX를 확인해야 한다. +[서비스 조항 §6.3](https://cloud.google.com/maps-platform/terms/maps-service-terms)의 기본 보관 상한을 넘기지 않으며, +공용 식당 DB에 최종 사용자별 격리 예외가 적용된다고 가정하지 않는다. +좌표 수명만 저장했다고 실제 제거/백업 정책까지 완료된 것은 아니다. +만료 전 갱신·DB/캐시/클라이언트·백업의 제거, attribution과 정책 QA가 확인돼야 운영 gate를 통과한다. + +승인된 운영 변경은 singleton 예산 행만 수정한다. 시작 시 자동 초기화/자동 enable은 없다. +제어 행 누락 시 호출을 차단하므로 누락 원인을 확인한 뒤 중단·한도 0 상태로 복원해야 한다. +문서/로그에 키·원문 주소·좌표·Google 응답·전체 URI를 남기지 않는다. +처리 이력에는 ID/revision/requestId/lease/시도/안전한 코드와 시각만 남는다. + +## 이번 변경 전후 + +- 기존에는 갱신 시작 시 좌표를 비웠다. 이제 같은 주소의 갱신 중에는 이전 좌표를 원래 만료 시각까지 사용한다. + 새 결과가 검증되면 교체하고, 주소가 바뀌면 즉시 제거한다. 재시도나 실패만으로 만료 시각을 늘리지 않는다. +- 자동 시도 기본값을 4회에서 최초 요청 포함 8회로 바꿨다. 일정은 위 표를 따르며 오류가 영구적이면 즉시 중단한다. +- 마지막 취소 응답도 재시도 대기에 남기지 않고 실패로 끝낸다. 전송 중인 요청의 실행 예약은 lease까지 남겨 중복 호출을 막는다. +- 정기 갱신 스케줄과 Grafana 알림은 #235에서 연결한다. 위치 V38, 작업 V39, 선택 위치 확인 주소 V39.1, + Places 결합 V39.2, 위치 확인 기록 V39.3 순서로 적용한다. develop에 병합된 V32·V36·V36.1·V37과 다른 담당자의 PR은 수정하지 않는다. +- 전역 호출 중단·일일 한도 소진·공유 대기·실행 슬롯 포화는 후보 조회 전에 읽기 전용으로 확인한다. 실제 예약 시에는 잠금 안에서 다시 검사한다. + 다만 이미 시도를 소진한 만료 작업과 설정 오류는 Google 호출 없이 실패로 정리한다. 호출 대기 작업이 많아도 소진 작업이 밀리지 않도록 별도로 조회한다. diff --git a/docs/map/location-maintenance-runbook.md b/docs/map/location-maintenance-runbook.md new file mode 100644 index 00000000..49b213e0 --- /dev/null +++ b/docs/map/location-maintenance-runbook.md @@ -0,0 +1,250 @@ +# 기존 식당 위치 보완과 보존 기한 관리 (#228) + +## 1. 먼저 알아둘 범위 + +이 도구는 기존 식당을 기존 [위치 worker](location-jobs.md)에 연결한다. 주소와 관광 지역을 +추측하지 않는다. 식당 이름·주소·관광 지역·메뉴·이미지·태그를 변경하지 않는다. +Google 호출과 자동 재시도는 #224 worker, HTTP는 #222 adapter 한 곳에서 처리한다. +BACKFILL은 기존 row의 `geocodingAddress`가 있으면 그 값을, 없으면 저장된 표시 `address` 전체를 +provider 입력으로 사용한다. 서버가 건물명·층을 제거하거나 일본어로 바꾸지 않는다. 자동 채택 조건을 +통과하지 못하면 REVIEW_REQUIRED로 남기고, 운영자가 표시 주소를 보존한 채 명시 override를 입력해 재처리한다. + +운영 Google 계정·계약·청구·키·허용 보존 기간·호출 예산·관광 지역 매핑·백업 정책은 +확정되지 않았다. 아래 값과 테스트는 합성 예시다. 실제 일괄 실행·삭제·배포·merge는 수행하지 않았다. +실행 전 대상 확인 결과, 실제 계약, 비용 한도, 보존 절차와 별도 운영 승인을 확인한다. + +## 2. 실행 파일과 연결 + +기존 `./gradlew bootJar`의 `build/libs/app.jar`에 CLI도 들어 있다. 일반 서버와 CLI의 시작점은 다르다. +CLI는 web, Flyway, worker, scheduler, Google adapter, Redis, SQS를 부트스트랩하지 않는다. +먼저 배포 절차로 V38 위치, V39 작업, V39.1 위치 확인 주소, V39.2 Places 선택, +V39.3 위치 확인 기록, V40 유지보수 migration을 순서대로 적용해야 한다. CLI는 schema를 `validate`만 한다. +`ddl-auto=create` 등의 입력은 validate로 덮어쓰며 SQL 초기화도 하지 않는다. + +접속값은 승인된 환경의 `SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, +`SPRING_DATASOURCE_PASSWORD`로 주입한다. 키나 DB 접속값을 명령 예시·출력·PR에 적지 않는다. +일반 서버의 `application.yml` 대신 `location-maintenance.yml`을 읽는다. +추가 설정 파일을 쓰면 접근 권한을 제한하고 저장소에 커밋하지 않는다. +확인용 DB 계정은 SELECT 권한만 부여한다. 변경용 계정은 별도로 승인한다. + +PowerShell에서 아래 함수를 만든 뒤 실행할 수 있다. Java 21을 사용한다. + +```powershell +function Invoke-LocationMaintenance { + & java '-Dloader.main=org.sopt.hashi.restaurant.migration.LocationMaintenanceCli' ` + -cp build/libs/app.jar org.springframework.boot.loader.launch.PropertiesLauncher @args + if ($LASTEXITCODE -ne 0) { throw '위치 관리 명령 실패: 승인된 설정과 DB 상태 확인 필요' } +} +``` + +CLI가 실패하면 안전한 고정 오류 문구와 exit code 1을 반환한다. SQL/주소/provider 원문을 출력하지 않는다. +중단된 실행을 재시도할 때 run ID를 새로 발급하지 않고 DB 상태부터 확인한다. + +## 3. 대상 확인 → 검토 + +```powershell +Invoke-LocationMaintenance --hashi.map.maintenance.command=DRY_RUN ` + --hashi.map.maintenance.after-id=0 --hashi.map.maintenance.batch-size=50 ` + --hashi.map.maintenance.max-batches=2 +``` + +이 명령은 DB 쓰기·enqueue·Google 호출을 하지 않는다. 출력에는 다음이 있다. + +- `asOfUtc`: 실제 MySQL UTC 기준 시각. JVM 시각이나 JDBC 세션의 현지 시각을 사용하지 않는다. +- `afterId`, `upperId`: `afterId < restaurant.id <= upperId`. 생략한 upperId는 시작 시 MAX(id)로 고정한다. +- `lastInspectedId`, `inspected`, `inspectionLimit`, `partial`: 읽은 범위와 제한. + partial=true이면 집계는 읽은 행만 의미한다. 이어 볼 때 after-id를 lastInspectedId로 지정한다. +- 분류: UNRESOLVED(위치 행 없음), VALID, REFRESH_DUE, EXPIRED, PENDING, RETRY_WAIT, + REVIEW_REQUIRED, FAILED, DELETED. 분류의 합은 inspected다. 좌표가 없는 활성 식당에는 + UNRESOLVED뿐 아니라 PENDING/RETRY_WAIT/REVIEW_REQUIRED/FAILED도 포함되지만 자동 보완 대상은 UNRESOLVED다. +- `googlePurgeDueInInspectedRows`, `deletedGoogleInInspectedRows`: 앞 분류와 겹치는 보존 확인 수다. + 합계에 다시 더하지 않는다. deleted 식당의 Google 결과도 보존 기한 대상이다. +- `mode`, `eligibleByLocationStateInInspectedRows`: 선택한 BACKFILL/REFRESH의 상태·수명 기준에 맞는 수다. + `registrationEstimateInInspectedRows`는 여기에 등록/호출 한도를 적용한 예상치이고, + `reservedCallCeilingForEstimate`는 예상 등록 수 × 8이다. 읽지 않은 행은 추정하지 않으며 + 실제 잠금 아래 처리 중 작업·상태를 다시 확인하면 등록 수가 더 줄어들 수 있다. + +주소·좌표는 조회 projection에 포함하지 않는다. ID keyset + 작은 scalar projection을 사용하며 +offset 증가나 JPA 연관관계 N+1을 만들지 않는다. bounded read-only snapshot이므로 확인 결과는 +그 시점의 정보다. 등록 직전에는 잠금 아래 삭제·주소·상태를 다시 확인한다. + +## 4. 제한 실행 + +검토한 upper ID를 반드시 명시한다. 예시의 200은 합성 값이다. + +```powershell +$locationRun = [guid]::NewGuid().ToString() +Invoke-LocationMaintenance --hashi.map.maintenance.command=START ` + --hashi.map.maintenance.execute=true --hashi.map.maintenance.mode=BACKFILL ` + "--hashi.map.maintenance.run-id=$locationRun" --hashi.map.maintenance.upper-id=200 ` + --hashi.map.maintenance.max-registrations=10 --hashi.map.maintenance.max-calls=80 ` + --hashi.map.maintenance.batch-size=50 --hashi.map.maintenance.max-batches=2 +``` + +START/RESUME/STOP/PURGE는 `execute=true`가 없으면 변경 전에 거부한다. START는 run ID, 범위, +기준 시각, 갱신 기준 시각, 등록/호출 한도를 DB에 저장한다. 동일 run ID의 START는 이어서 진행하되 +기존 범위·한도 변경은 거부하고 STOP을 해제하지 않는다. 신규 ID가 upper ID 밖에 생겨도 포함하지 않는다. + +한 CLI 실행은 최대 batch-size × max-batches 건을 검사한다(각각 최대 100). +식당 한 건마다 짧게 commit하므로 실제 쓰기 transaction에는 식당 한 건만 들어간다. +checkpoint·enqueue·run-job 연결은 같은 transaction이다. 실패하면 모두 rollback한다. +마지막 commit 직후 프로세스가 죽어도 다음 실행은 그 다음 ID부터 시작한다. +run 자체에 긴 lease가 없어 프로세스 종료 후 lease 만료를 기다릴 필요가 없다. +Google worker의 기존 2분 lease·복구·최대 attempt는 그대로 사용한다. + +### 호출 상한 계산 + +`등록 가능 수 = min(max-registrations, floor(max-calls / 8))`이다. +#224는 앱 설정의 max-attempts를 하드 최대 8로 제한한다. 자동 재시도는 같은 job 행의 attempt를 +이어가므로 run에 연결한 job N개의 예약 시도 합은 최대 8N이다. 기본 설정도 최초 요청을 포함해 8회다. 낮춰서 실행하더라도 재시작 후 8회가 될 수 있어 +항상 8회를 기준으로 보수적으로 등록한다. 0~7회 예산은 0건 등록이다. +예: 80회/10건이면 최대 10건, 15회/10건이면 최대 1건이며 남은 7회는 쓰지 않는다. + +전역 DB daily_limit·max_concurrent·blocked_until도 모든 claim에 별도로 적용된다. +예약 후 실제 전송이 없거나 HTTP 완료 전에 서버가 죽어도 예산을 환급하지 않는다. +`reservedAttempts`는 예약된 시도 수이며 실제 과금 호출 수라고 단정하지 않는다. +관리자가 따로 재처리하거나 정기 갱신으로 만든 새 job은 기존 run에 연결하지 않는다. +그 호출은 해당 run 상한 외이며 +전역 예산에 포함된다. 운영자는 동시에 실행하는 다른 run·관리자 변경의 총량도 확인해야 한다. + +BACKFILL은 위치 행이 없는 활성 식당만 등록한다. REFRESH는 갱신 범위의 GOOGLE_GEOCODING/READY만 +등록한다. PENDING/RETRY_WAIT은 기존 worker가 맡고 FAILED/REVIEW_REQUIRED는 자동 재등록하지 않는다. +재개하거나 새 run을 만들어 실패한 job의 시도 상한을 초기화하지 않는다. + +## 5. 상태 확인 → 중단/재개 + +```powershell +Invoke-LocationMaintenance --hashi.map.maintenance.command=STATUS "--hashi.map.maintenance.run-id=$locationRun" +Invoke-LocationMaintenance --hashi.map.maintenance.command=STOP --hashi.map.maintenance.execute=true ` + "--hashi.map.maintenance.run-id=$locationRun" +Invoke-LocationMaintenance --hashi.map.maintenance.command=RESUME --hashi.map.maintenance.execute=true ` + "--hashi.map.maintenance.run-id=$locationRun" +``` + +| 출력 | 의미 | +|---|---| +| ACTIVE | 등록 진행 가능. CLI 단위 한도에 도달하면 이 상태로 끝날 수 있다. 다음 RESUME이 같은 범위를 계속한다 | +| STOPPED | 운영자가 신규 등록을 중단함 | +| SCANNED | 고정 ID 범위의 검사가 끝남. Google 완료를 의미하지 않음 | +| LIMIT_REACHED | 등록/호출 한도 때문에 범위를 다 검사하지 못함. 재개로 한도를 늘리지 않음 | +| enqueued | 이 run이 commit한 job 수 | +| jobStates | 연결한 job별 PENDING/LEASED/RETRY_WAIT/SUCCEEDED/FAILED/REVIEW_REQUIRED/SUPERSEDED 수 | +| currentlyUsableLocations | 현재 같은 revision/request이고 삭제되지 않았으며 아직 유효한 좌표 수 | + +SUCCEEDED는 이력상 성공이다. 이후 주소가 바뀌거나 만료되면 currentlyUsableLocations에서는 제외한다. +연결한 job이 수동 DB 변경으로 없어졌으면 MISSING_JOB으로 드러나며 성공으로 세지 않는다. + +STOP은 run 잠금을 먼저 잡는다. 앞서 시작한 한 건의 commit을 기다릴 수 있으며 STOP commit 이후 +같은 run의 신규 등록은 없다. 이미 enqueue된 작업이나 claim된 HTTP는 취소하지 않는다. +전체 새 claim을 막으려면 별도로 승인된 DB 제어 절차에서 `restaurant_geocoding_budget.enabled=false`로 +변경한다. 이미 진행 중인 HTTP에는 소급 적용되지 않는다. 부모 잠금이나 transaction을 HTTP 동안 유지하지 않는다. + +## 6. 갱신과 실제 DB 정리 + +### 이전과 달라진 점 + +| 이전 | 현재 | +|---|---| +| 갱신 등록 즉시 좌표 제거 | 같은 주소의 좌표는 원래 validUntil까지 사용 | +| 갱신은 외부 CLI 주기 실행 필요 | 기존 retention 스케줄러가 갱신 등록과 만료 정리를 함께 수행 | +| 1일 전 갱신 대상으로 검사 | 기본 3일 전부터 검사 | +| READY만 정리 | 갱신·재시도·실패 중 남은 Google 좌표도 정리 | +| 정리하면서 requestId 변경 | 진행 중 작업의 requestId·attempt·다음 재시도 시각 보존 | +| 기본 1시간 전 좌표 제거 | 기본 만료 시각 이후 정리; 조회는 정확히 만료 시각부터 제외 | + +정기 실행은 `hashi.map.maintenance.retention-enabled=true`로 켠다. 기본 false다. +전용 `location-retention` executor 하나가 작은 묶음으로 갱신 작업을 등록한 뒤 정리한다. +Google 호출은 이 스케줄러가 하지 않고 기존 worker가 같은 job을 최대 8회 처리한다. +갱신 등록 단계가 실패해도 정리는 별도로 시도한다. 이 변경에서 실제 서버 설정은 켜지 않았다. + +기본값은 `refresh-ahead=3d`, `purge-ahead=0s`, `poll-delay=1m`, `batch-size=50`, `max-batches=1`이다. +갱신 대상은 미삭제 식당의 Google/READY 좌표 중 유효기한이 3일 안에 오는 항목이다. +짧은 잠금 안에서 현재 주소·요청·유효기한과 활성 작업 유무를 다시 확인한다. +PENDING/RETRY_WAIT/FAILED/REVIEW_REQUIRED는 새 작업을 자동 등록하지 않는다. +스케줄러가 반복 실행되거나 재시작해도 재시도 횟수를 초기화하지 않는다. +장애 후 이미 만료한 READY를 발견해도 최초 갱신 작업을 등록하고, 곧바로 이전 좌표를 정리한다. +새 결과가 오기 전에는 만료한 좌표를 다시 노출하지 않는다. + +`refresh-ahead`는 저장된 좌표의 전체 수명보다 짧아야 한다. 같거나 더 크면 해당 좌표의 자동 갱신을 +건너뛰어 성공 직후 반복 호출하는 것을 막는다. worker의 설정 수명도 3일 이하면 +`refresh_window_invalid` 알림을 보낸다. 짧은 수명의 개발 테스트에서는 예를 들어 +`retention=10m`, `refresh-ahead=3m`, `poll-delay=10s`처럼 함께 줄인다. +실제 운영은 계약상 허용 상한보다 짧은 retention과 충분한 정리·복구 여유를 설정해야 한다. +설정 검증은 `0 <= purge-ahead < refresh-ahead <= 30d`를 확인한다. + +성공하면 새 좌표와 취득·만료 시각을 함께 교체한다. 실패는 이전 validUntil을 연장하지 않는다. +식당명·현지명·표시 주소 또는 실제 provider 입력(`geocodingAddress`, 없으면 `address`)이 바뀌면 +선행 #233의 revision 처리로 이전 좌표와 확인 기록을 즉시 지우고 다시 확인한다. +같은 명시 override를 유지해도 표시 주소·층이 바뀌면 재확인한다. 동일한 입력의 수정 요청은 기존 좌표와 진행 중 작업을 보존한다. +유효기한이 되면 공개 조회는 즉시 제외하고, DB 값은 다음 정리 주기에 제거한다. +기본 정상 상태에서도 **DB 물리 제거는 최대 한 polling 주기와 처리 시간만큼 늦을 수 있다.** +조회 제외와 DB 삭제 완료는 다르다. 서버/DB 장애나 backlog가 있으면 더 늦어질 수 있다. +따라서 provider의 최종 허용 보관 상한을 validUntil과 똑같이 잡고 이 지연을 무시하면 안 된다. +계약상 상한 전에 복구·정리할 안전 여유를 retention에 반영하고, 알림과 정리 처리량을 검증한 뒤 켠다. + +PURGE는 작업 상태와 관계없이 Google 좌표·source·obtainedAt·validUntil을 NULL로 만든다. +READY였으면 REVIEW_REQUIRED로 전환하고, 진행 중이거나 실패한 작업은 상태와 requestId를 유지한다. +예를 들어 3번째 재시도 대기 중 만료되더라도 다음 호출은 같은 작업의 4번째 시도다. +삭제된 식당도 정리하지만 식당 원본과 자식 데이터는 보존한다. ADMIN 좌표에는 적용하지 않는다. +revision/request/obtainedAt/validUntil을 다시 비교하므로 오래된 정리 결과가 새 좌표를 지우지 않는다. + +수동 한정 실행은 기존 CLI를 그대로 사용한다. REFRESH `START`도 좌표를 유지하고, +`PURGE`는 아래처럼 실행한다. 실제 실행 전에 DRY_RUN의 범위와 호출 예산을 확인한다. + +```powershell +Invoke-LocationMaintenance --hashi.map.maintenance.command=PURGE --hashi.map.maintenance.execute=true ` + --hashi.map.maintenance.purge-ahead=0s --hashi.map.maintenance.batch-size=50 ` + --hashi.map.maintenance.max-batches=2 +``` + +### Grafana 알림 + +기존 Micrometer/Prometheus를 사용한다. 추가 저장소나 dependency는 없다. +스케줄러가 DB에서 집계한 `hashi_map_maintenance_issues{reason="..."}`와 마지막 집계 시각을 노출한다. +식당 ID·job ID·주소·키는 metric label과 알림에 넣지 않는다. +작업 집계는 현재 주소 revision/requestId만 보므로 재처리 후에도 과거 실패가 계속 알림으로 남지 않는다. + +| reason | 조건 / 알림 | +|---|---| +| access_denied, configuration_error | 해당 실패가 처음 집계되는 평가부터 우선 대응 | +| refresh_window_invalid | worker 수명보다 갱신 여유가 같거나 긴 설정이면 우선 대응 | +| retrying | 3회 이상 실패한 미해결 작업이 있는 상태가 15분 지속되면 경고 | +| attempts_exhausted | 현재 작업의 자동 시도 소진이면 우선 대응 | +| expiry_soon | Google 좌표가 24시간 이내 만료하거나 이미 만료했으면 우선 대응 | +| stalled | 호출 가능한 상태인데 이미 실행 시각이 지난 작업이 15분 이상 진행되지 않으면 경고 | +| observation_stale | 집계 시각이 3분 넘게 갱신되지 않으면 스케줄러·DB 점검 | + +retrying의 15분은 Grafana `for: 15m`으로 판정한다. 전체 장애 상태를 묶는 알림이며 개별 식당의 +실패 시작 시각을 별도 저장하지 않는다. 정상적인 미래 재시도 시각·전역 quota cooldown·꺼진 worker/ +예산은 stalled에서 제외한다. 쿼리 실패 시 이전 값을 0으로 덮지 않으므로 관측 중단을 정상으로 숨기지 않는다. + +[Grafana rule 예제](monitoring/grafana-alerts.example.json)는 자동 마운트하지 않으며 **전부 일시정지 상태**다. +운영자가 환경 label과 Prometheus UID를 확인하고 기존 Grafana에 불러온 뒤 테스트하고 활성화한다. +기존 Discord contact point와 notification policy를 덮어쓰는 파일은 제공하지 않는다. +현재 정책 아래 `service=hashi-map` 하위 경로를 추가하고 기존 Discord 수신처를 선택한다. +`environment, reason`으로 묶고 초기 `group_wait=30s`, `group_interval=5m`, `repeat_interval=4h`를 사용한다. +100개 식당이 같은 원인으로 실패해도 원인별 집계 메시지를 보낸다. 실제 전송 확인은 별도 개발 환경 적용 단계다. +파일 형식은 [Grafana 공식 provisioning 문서](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/file-provisioning/)를 따른다. + +## 7. 잠금·시각·운영 gate + +- 등록: `run → restaurant → job`. worker: `restaurant → job → budget`. worker/관리자는 run을 잠그지 않는다. + stop/resume은 run만 잠근다. 정리는 restaurant만 잠근다. run과 budget을 함께 잡거나 역순으로 잡지 않는다. +- 쓰기 단계는 READ_COMMITTED다. 부모 행을 먼저 잠그며 다른 식당 job 범위의 gap-lock 경합을 피한다. +- 새 run 시각/쿼리 기준은 DB `UTC_TIMESTAMP(6)` 문자열을 파싱한다. JDBC에는 UTC calendar 문자열을 + bind하고 DATETIME은 `getObject(LocalDateTime.class)`로 읽는다. 선행 entity의 direct LocalDateTime JDBC를 유지한다. +- 만료한 백업 값을 복구해도 `hasUsableMapLocation`과 후속 지도 조회의 validUntil 조건으로 노출을 차단한다. + 노출 차단은 DB/복제본/캐시/클라이언트/백업에서 제거했다는 증거가 아니다. +- 복구 환경은 공개 전에 만료 값 정리와 검증을 수행한다. 복제 지연, PITR/binlog, 스냅샷/백업 수명, + 클라이언트 좌표 제거와 실제 Google 계약 증거는 운영 gate에서 별도로 확인한다. + DB DELETE/NULL 처리만으로 이 경로들까지 제거되었다고 보고하지 않는다. + +## 8. 검증 범위 + +`LocationMaintenanceMySqlTest`는 실제 MySQL 8.4와 fake provider로 migration/validate, 재시작, +동시 run/worker, checkpoint rollback, stop 경합, 주소/삭제 경합, lease 복구, UTC/JDBC 교차 조건, +물리 정리와 백업 복구 방어, app.jar launcher의 SELECT 전용 dry-run과 START/RESUME을 검증한다. +실제 CLI가 START로 만든 위치 행의 created_at/updated_at이 채워지고 RESUME에서 기존 created_at이 +유지되는지도 DB에서 확인한다. CLI는 서버와 같은 `JpaAuditingConfig`를 명시적으로 불러온다. +합성 fixture의 EXPLAIN·쿼리 수는 쿼리 형태를 확인하는 자료이며 운영 데이터에서의 지연/처리량 보장이 아니다. +최종 실행 명령·XML 결과·독립 리뷰·CI와 미실행 운영 항목은 PR 검증 증거에 기록한다. diff --git a/docs/map/location-model.md b/docs/map/location-model.md new file mode 100644 index 00000000..3c68cb6c --- /dev/null +++ b/docs/map/location-model.md @@ -0,0 +1,161 @@ +# 식당 위치·관광 지역 모델 (#220) + +이 문서는 #225의 지도 데이터 저장·검증 기반을 설명한다. 관리자 API·Google 호출·worker 연결은 +아래 #233 통합 범위와 구분하며, 지도 조회·Redis 세션·실제 지역 데이터와 backfill은 후속 작업이다. +이번 #225 수정은 같은 주소의 갱신 중에도 기존 유효 좌표를 사용할 수 있게 한다. +전체 구현 방향과 후속 검증 기준은 [구현 계획](implementation-plan.md)에 함께 정리한다. + +2026-10-03 기준 통합 PR #233에는 관리자 등록·미삭제 식당의 주소 변경에 따른 job 생성, +worker/lease/provider 연결과 관리자 위치 상태 DTO가 구현돼 있다. 현재 흐름은 +[위치 작업 문서](location-jobs.md)를 따른다. 운영 활성화, 실제 지역·지원 영역 검증과 +Google 보관 정책 승인은 별도로 확인해야 한다. + +## 변경 범위와 소유권 + +- `Restaurant`가 선택적 `RestaurantLocation`을 단방향 LAZY 1:1로 소유한다. + `restaurant.location_id`는 unique FK이며 같은 Aggregate 안의 관계다. + location이 없는 기존 식당은 UNRESOLVED로 해석한다. PR #233의 관리자 신규 등록은 + 같은 transaction에서 PENDING 위치와 job을 생성한다. +- 부모가 FK를 가지므로 일반 조회에서 위치 행 존재 여부를 확인하는 추가 조회가 필요 없다. + [Hibernate의 optional inverse 1:1 설명](https://docs.hibernate.org/orm/6.6/introduction/html_single/#one-to-one) + 및 실제 MySQL의 query-count 회귀 테스트로 확인한다. +- `MapRegion`은 restaurant 모듈의 별도 Aggregate다. 식당은 nullable `map_region_id`만 보관한다. + 지역을 향한 FK/JPA 연관은 없다. 후속 운영 Service가 지역의 존재·활성을 검사하며 + 없는/비활성 지역의 ID는 지역 집계에서 제외한다. 일반 BBOX 탐색은 지역 유무와 무관하다. +- 기존 `area`는 표시 문자열, `placeType`은 음식점·카페·주점 분류다. 관광 지역 코드와 자동 변환하지 + 않는다. Google address components도 지역을 자동 확정하지 않는다. +- PR #225는 기존 Controller·Service·Port·요청/응답 DTO와 평점 통계 갱신 경로를 유지했다. + #233의 최신 등록·수정 계약에서는 이름·현지명·표시 주소 또는 실제 provider 입력이 바뀌면 기존 위치를 무효화한다. + 같은 기본 주소를 명시한 층수 수정도 재확인하며, 같은 입력이나 가격·소개 수정은 위치에 영향을 주지 않는다. + +## 값과 DB 제약 + +좌표는 WGS84 위도 [-90,90], 경도 [-180,180]의 `BigDecimal` 한 쌍이다. +위도 `DECIMAL(9,6)`, 경도 `DECIMAL(10,6)`로 저장한다. Java는 소수점 6자리로 정확히 표현할 수 +없는 값을 거절하고, 뒤에 붙은 0은 허용한다. PR #233의 `LocationAdoptionPolicy`는 채택할 후보를 +HALF_UP으로 소수점 6자리로 변환한다. HTTP adapter는 원래 정밀도의 후보를 반환한다. +MySQL DECIMAL은 직접 SQL의 초과 소수 자릿수를 반올림할 수 있으므로 +DB CHECK가 원래 입력의 정밀도까지 검증한다고 주장하지 않는다. +BigDecimal에는 NaN/Infinity가 없으며 HTTP adapter에서도 숫자 변환 실패를 거절한다. +`(0,0)`은 유효한 세계 좌표다. 변환 실패의 대체값으로 사용하지 않는다. + +`MapBounds`는 경계 포함, `south < north`, `west < east`와 같은 정밀도를 검증한다. +도쿄 1차에서는 날짜변경선 횡단을 지원하지 않는다. 1도 span 제한·supportedBounds 검사는 +후속 조회 Service의 정책이다. 기하 모델 자체를 도쿄의 가상 좌표로 제한하지 않는다. +관광 지역의 대표 좌표는 cameraBounds 안에 있어야 한다. 코드는 `[A-Z][A-Z0-9_]{0,39}`, +이름은 1~100자이며 Unicode 공백과 Java 공백 구분 문자만 있는 값은 Java·DB에서 모두 거절한다. +displayOrder는 0 이상이다. 지역은 명시적으로 활성화하기 전까지 비활성이다. +실제 코드·대표 위치·bounds를 migration에 넣지 않는다. + +`V38__add_restaurant_map_location.sql`은 새 두 테이블과 nullable 참조 두 개를 추가한다. 기존 데이터는 미분류/위치 없음으로 보존한다. +마이그레이션에는 HTTP, job 생성, 업무 seed가 없다. 적용된 이전 migration은 수정하지 않는다. +큰 restaurant 테이블의 DDL 소요·잠금 시간은 운영 대상 규모로 배포 전에 측정해야 한다. + +2026-10-08 기준 위치 migration은 아직 병합·배포되지 않았다. develop에 병합된 +`V32__add_user_anonymous_nickname.sql`, 공지 V36·V36.1, 약관 V37은 그대로 유지하고 +지도 파일을 V38로 옮긴다. 이 변경은 파일명만 바꾸며 SQL 본문과 제약의 의미는 유지한다. +운영 ledger 확인은 별도 배포 gate로 남기고, 기존 읽기 전용 증거상 지도 migration은 미적용이다. + +[DB 규칙](../conventions/database.md)에 따라 이미 적용된 migration은 수정하지 않는다. +앞서 미병합 지도 모델에서 정한 좌표 쌍·출처·취득/만료 시각 제약도 그대로 유지한다. +이 값들은 전부 있거나 전부 없어야 하며, READY에는 반드시 있어야 한다. +개인 로컬 DB에 예전 `V31__add_restaurant_map_location.sql` 또는 +`V32__add_restaurant_map_location.sql`을 적용했다면 새 V38을 바로 +적용하거나 checksum만 repair하지 않는다. 데이터 폐기가 허용된 환경은 재생성하고, +보존이 필요하면 해당 DB의 이력·기존 `OPERATOR` 값·CHECK를 확인해 전환 절차를 별도 결정한다. +다른 담당자의 미병합 PR은 수정하지 않으며, 실제 병합 전에 최신 develop과 번호를 다시 비교한다. + +## 상태와 수명 + +| 상태 | 의미 | 저장 규칙 | +|---|---|---| +| 행 없음 (UNRESOLVED) | 아직 위치 요청을 기록하지 않음 | 일반 식당 API에 그대로 노출 가능 | +| PENDING | 현재 주소의 위치 요청을 기록함 | 같은 주소의 이전 검증 좌표 보존 가능, 재시도 시각 없음 | +| READY | 검증된 위치 결과를 저장함 | 좌표 쌍·출처·obtainedAt·validUntil 필수, 취득 < 만료 | +| RETRY_WAIT | 일시 오류 뒤 재시도 대기 | nextAttemptAt 필수, 같은 주소의 이전 검증 좌표 보존 가능 | +| REVIEW_REQUIRED | 주소·후보·정확도를 운영자가 확인해야 함 | 같은 주소의 이전 검증 좌표 보존 가능, 자동 재시도 없음 | +| FAILED | 설정 문제 또는 재시도 소진으로 자동 처리 중단 | 같은 주소의 이전 검증 좌표 보존 가능, 자동 재시도 없음 | + +status는 현재 작업 상태다. 저장된 좌표의 사용 가능 여부는 별도로 판정한다. +처음 주소를 변환할 때는 기존 좌표가 없지만, 같은 주소를 갱신할 때는 마지막 검증 결과가 남는다. +갱신 시작·재시도·실패만으로 출처·취득/만료 시각을 바꾸지 않는다. 새 결과의 검증이 모두 끝나야 +좌표와 출처·수명을 함께 교체한다. 실패한 갱신 때문에 기존 validUntil을 연장하지 않는다. + +첫 위치 요청의 addressRevision은 1이다. 미삭제 식당의 이름·현지명·표시 주소·실제 provider 입력 변경은 같은 transaction에서 +revision을 증가시키고 새 requestId/PENDING으로 바꾸며 이전 좌표와 수명 정보를 제거한다. +기본 주소가 같아도 표시 주소·건물·층이 달라지면 재확인한다. 같은 입력의 PATCH만 revision과 작업을 보존한다. +삭제된 식당의 주소 편집은 위치와 job을 변경하지 않는다. +관광 지역 ID는 그대로 둔다. 같은 주소의 PENDING 재요청은 기존 requestId를 유지한다. +실패·확인 필요·재시도 대기에서 명시적 재요청하거나 READY를 갱신하면 새 requestId를 발급한다. +자동 재시도는 nextAttemptAt 이상일 때만 가능하다. READY는 일반 retry 대신 refresh를 사용한다. + +결과 반영은 PENDING 및 revision/requestId 일치 시에만 가능하다. 늦은 주소 결과, +같은 주소의 이전 요청, 중복 완료, 삭제 식당의 완료는 false로 종료한다. +Location의 낙관적 버전도 오래된 영속 객체의 덮어쓰기를 막는다. +이 모델의 검사와 별도로 PR #233의 worker는 완료 시 job과 유효한 lease도 재검사한다. + +현재 모델의 출처는 GOOGLE_GEOCODING / GOOGLE_PLACES / ADMIN이며 모두 명시적 수명을 가진다. `@Enumerated(STRING)`으로 +DB에도 같은 이름을 저장한다. 운영자가 Google 결과를 확인했다는 이유로 ADMIN으로 바꾸지 않는다. +GOOGLE_PLACES는 좌표와 Place ID를 함께 저장하며 후보 선택 계약은 [Places 후보 선택](place-selection.md)을 따른다. +출처를 포함한 새 결과는 새로운 요청으로만 저장한다. +독립적으로 확보한 자체 좌표에 Google 보관 조건을 그대로 적용하지 않는 것이 후속 정책이다. +ADMIN의 수명을 선택 사항으로 바꾸는 코드·DB·응답 변경은 이번 PR에 포함하지 않는다. +시각은 UTC `DATETIME(6)`이며 Clock의 zone과 관계없이 instant를 UTC로 해석한다. +DB 정밀도에 맞춰 취득/만료/재시도 시각의 나노초를 마이크로초로 내린 뒤 검증한다. +취득 시각은 미래일 수 없고 저장 시점에 이미 만료된 결과도 거절한다. + +`obtainedAt`, `validUntil`, `nextAttemptAt`은 `@JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME)`으로 +JDBC에서 `LocalDateTime`을 직접 전달하고 읽는다. UTC로 계산한 연월일·시분초를 그대로 보존하며, +중간 `Timestamp` 변환에서 JVM과 연결 시간대 차이만큼 값이 이동하는 것을 방지한다. +[Hibernate의 직접 매핑](https://docs.hibernate.org/orm/6.6/javadocs/org/hibernate/type/SqlTypes.html#LOCAL_DATE_TIME)을 +이 세 필드에만 적용한다. `BaseTimeEntity`, 일반 운영 시각과 전역 시간대 설정은 기존 동작을 유지한다. +실제 MySQL에서 JVM/JDBC 각각 UTC·Asia/Seoul의 네 조합을 검증하며, DB 원문 값과 ORM 왕복, +insert/update, 직접 SQL로 넣은 값의 로딩, 마이크로초 만료·재시도 경계를 확인한다. +후속 native SQL 조회의 시간 인자 바인딩은 별도로 UTC 계약을 지켜야 한다. + +도메인의 지도 사용 판정은 삭제 아님, 완전한 검증 좌표 묶음 존재, `now < validUntil`이다. +READY 여부는 판정 조건이 아니다. 주소가 바뀌면 좌표를 즉시 비우므로 보존된 좌표도 현재 주소의 결과다. +관광 지역이 없어도 이 조건을 만족하면 일반 BBOX 대상이다. +soft delete는 위치 행을 물리 삭제하지 않는다. 예약·리뷰의 과거 조회와 일반 공개 제외 규칙을 유지한다. +만료는 Clock 판정으로 즉시 제외하며, 만료 데이터의 실제 제거는 후속 수명 관리 작업이 담당한다. +조회 제외만으로 보관 정책 이행이 끝나지는 않는다. + +## PR #233 통합 구현과 남은 경계 + +1. 관리자 등록·미삭제 식당의 실제 provider 입력 변경 Service에서 식당 저장·위치 요청·durable job을 같은 transaction에 묶었다. + 관리자 신규 등록은 요청을 자동 생성하며 관리자 상세·위치 조회/재처리 응답에 상태 DTO를 추가했다. +2. 기존 `RestaurantRepository.findByIdForUpdate`로 부모를 먼저 잠근 뒤 위치를 변경한다. + 완료/재시도/삭제/주소 변경이 이 순서를 공유해야 한다. detached Restaurant를 받아 저장하지 않는다. +3. job ID·lease token/만료·attempt·안전한 failureCode·쿼터 gate를 PR #233의 worker에 구현했다. + requestId만으로 만료된 lease나 동일 요청의 중복 worker를 구분할 수 없다. +4. HTTP는 DB transaction 밖에서 수행하고 완료 transaction에서 부모 삭제 여부, + revision/requestId와 job/lease를 모두 재검사한다. 재시도 횟수·backoff는 이 모델에 만들지 않았다. +5. 지도 조회는 위치를 명시적으로 join/projection하고 삭제·좌표 묶음·만료를 SQL에서도 검사한다. + 지역 미분류를 inner join으로 누락하지 않는다. 일반 조회 DTO에 위치를 무조건 붙이지 않는다. +6. Google 계약에 따른 허용 수명·제거·백업 정책, 운영 지역 값, 지원 영역과 조회 크기는 + 실제 연동 전에 확인한다. 이 PR에는 유료 호출·운영 DB 조회/갱신·backfill·배포가 없다. +7. 관광 지역 등록·수정·활성화와 식당의 지역 소속을 설정하는 운영 API/Service는 아직 없다. + 실제 지역 데이터도 없으므로 지역 탐색을 활성화할 수 없다. 승인된 지역 데이터와 함께 + 지역 존재·활성 검증을 포함한 관리자 쓰기 경로를 별도 후속 범위로 구현해야 한다. + +PR #225 자체에는 새 dependency와 전역 Clock 설정 변경이 없다. 기존 japanClock도 instant를 통해 UTC 수명 판정에 +사용할 수 있으며 테스트는 고정 Clock을 사용한다. + +### 후속 PR을 연결하기 전 확인할 점 + +이 PR의 도메인 수정만으로 지도 API와 만료 정리가 완성되지는 않는다. 2026-10-06 확인한 +통합 브랜치에는 다음 READY 전제가 남아 있으므로 공개 활성화 전에 함께 수정·검증해야 한다. + +- [#229](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/229)의 `RestaurantMapQueryRepository`: + READY만 고르는 SQL을 바꾸고 일반 목록·선택 위치·지역 집계·컬렉션 Port의 판정을 맞춘다. +- [#233](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/233)에는 이 모델을 반영했다. + worker 실패·재시도에서도 기존 수명을 보존하며, 관리자 locationStatus는 작업 상태를 나타낸다. + RETRY_WAIT/FAILED여도 validUntil이 남을 수 있으므로 상태만으로 지도 노출 여부를 판단하지 않는다. +- [#235](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/235)의 `LocationRetentionReader`, + `RestaurantLocation.purgeGoogle`와 유지보수 reader/inspection/transactions: + 갱신 중·실패 상태의 Google 좌표도 만료 전에 제거한다. 제거하면서 진행 중 requestId·job·재시도를 + 취소하지 않아야 한다. 기존 `purgeGoogle`은 `beginPending`에 좌표 제거를 의존하므로 명시적으로 + 바꾸어야 한다. 제거와 새 결과 저장이 겹칠 때 새 좌표를 지우지 않는 검사도 유지한다. + +현재 PR의 테스트는 도메인 전이, 실제 MySQL CHECK·왕복·주소 PATCH·낙관적 잠금과 시간대 경계를 +확인한다. 후속 PR은 갱신 중 공개 조회·만료 정리·worker 복구를 연결한 통합 테스트가 추가로 필요하다. diff --git a/docs/map/map-contract-v1.md b/docs/map/map-contract-v1.md index 59ead944..c1731e2a 100644 --- a/docs/map/map-contract-v1.md +++ b/docs/map/map-contract-v1.md @@ -1,6 +1,7 @@ # Map Contract v1 - 작성: 2026-09-26, 최신 기획 대조: 2026-10-01, [#219](https://github.com/TEAM-HASHI/HASHI-SERVER/issues/219) +- 기술 정책 갱신: 2026-10-06, #225의 좌표 보존 모델과 후속 슬라이딩 만료 계획 - 상태: 지도 서버 구현 PR과 컬렉션 지도 연동의 기준 계약. 이 문서의 API를 모두 병합·배포했다고 뜻하지 않는다. - 구조 결정: [ADR 0002](../adr/0002-restaurant-map-query-and-location.md) - 구현 순서·테스트 인수 기준: [Implementation Plan](./implementation-plan.md) @@ -16,7 +17,7 @@ | [MAP_RESTAURANT_DETAIL](https://github.com/TEAM-HASHI/HASHI-PLAN/blob/6f2c99c3d089ddb65f015121312e19f66572ea03/02_PRODUCT_SPEC/MAP/MAP_RESTAURANT_DETAIL/MAP_RESTAURANT_DETAIL.md) | 상세 복귀 시 지도 상태 유지, 삭제·비노출 식당 선택 차단 | | [DEC-001](https://github.com/TEAM-HASHI/HASHI-PLAN/blob/6f2c99c3d089ddb65f015121312e19f66572ea03/07_DECISIONS/DEC-001_MAP_INITIAL_CLUSTERING.md) | 행정구가 아닌 관광 지역 초기 클러스터, 이후 동적 클러스터 없음 | | [SAVED_COLLECTION](https://github.com/TEAM-HASHI/HASHI-PLAN/blob/6f2c99c3d089ddb65f015121312e19f66572ea03/02_PRODUCT_SPEC/SAVED/SAVED_COLLECTION/SAVED_COLLECTION.md) | 유효 좌표만 핀, 좌표 없는 공개 식당은 목록 유지, 같은 사용자의 마지막 저장 관계 해제 때만 전체 저장 수 감소 | -| [SAVED_COLLECTION_SAVE](https://github.com/TEAM-HASHI/HASHI-PLAN/blob/6f2c99c3d089ddb65f015121312e19f66572ea03/02_PRODUCT_SPEC/SAVED/SAVED_COLLECTION_SAVE/SAVED_COLLECTION_SAVE.md) | 저장 버튼은 컬렉션 선택 모달을 열고, 비로그인 사용자는 로그인 후 원래 지도 상태로 돌아온다. 생성·수정 이름/설명의 앞뒤 공백은 보존하고 공백만인 이름은 거부한다. | +| [SAVED_COLLECTION_SAVE](https://github.com/TEAM-HASHI/HASHI-PLAN/blob/6f2c99c3d089ddb65f015121312e19f66572ea03/02_PRODUCT_SPEC/SAVED/SAVED_COLLECTION_SAVE/SAVED_COLLECTION_SAVE.md) | 저장 버튼은 컬렉션 선택 모달을 열고, 비로그인 사용자는 로그인 후 원래 지도 상태로 돌아온다. 이름·설명의 공백 처리는 아래의 2026-10-12 확인 규칙을 적용한다. | 선택 컬렉션의 **모든 유효 핀을 목록과 독립 로드**하는 것은 이번 작업의 명시 요구다. 지역 매핑·검색 진입 카메라·지역 필터 해제·세션 수명·전송 방식 등 아래의 세부 기술 규칙은 @@ -52,7 +53,7 @@ SERVER 기준은 `origin/develop`의 `053fdb3a050d48956241357934a43e191ef99ece` | 정렬 변경 | 기존 querySessionId에 새 sort만 전달, 같은 후보·추천 순서를 재정렬한 첫 10개로 **교체**. queryBounds·필터 유지, viewport로 새 검색하지 않음 | | 목록 추가 | 같은 cursor의 다음 10개와 그 핀만 추가. 중복 응답은 같은 페이지로 취급하고 ID 중복 추가 금지 | | 상세 열기·닫기/로그인·예약 후 복귀 | 카메라·검색·필터·정렬·목록 상태 복원. 세션이 만료됐으면 아래 만료 규칙 적용 | -| 세션 만료·유실 후 새로 조회 | 현재 viewport·keyword·genre·placeType·sort로 새 세션, mapRegionId 해제. 성공한 첫 페이지로 교체. 기존 cursor에 연결 금지 | +| 세션 만료·유실 후 새로 조회 | 마지막 조회의 queryBounds·필터·현재 sort로 새 세션. 성공한 첫 페이지로 교체하고 추천 순서는 다시 생성. 기존 cursor에 연결 금지. 현재 화면으로 검색하려는 별도 동작은 새 조회 규칙 적용 | 최초 지역 설정 조회 실패는 초기 지도 재시도로, 설정 조회 성공 후 첫 목록 실패는 클러스터를 유지한 목록 재시도로 처리한다. 목록 성공 전 가짜 식당을 표시하지 않는다. 최초 클러스터 count는 목록 10개의 @@ -61,8 +62,9 @@ SERVER 기준은 `origin/develop`의 `053fdb3a050d48956241357934a43e191ef99ece` ## 3. 위치와 관광 지역의 유효성 - WGS84 `latitude/longitude`를 한 쌍으로 취급한다. 지도 표시 가능은 현재 공개 조건, - 삭제 아님, `READY`, 현재 주소 revision과 일치, 유한한 좌표 쌍, `now < validUntil`을 모두 만족함이다. - 향후 공개 상태가 추가되면 이 공통 판정에 포함한다. `READY`만 검사하면 안 된다. + 삭제 아님, 현재 주소의 검증 좌표·출처·수명 존재, 유한한 좌표 쌍, `now < validUntil`을 모두 만족함이다. + 같은 주소의 갱신 중·실패 상태에서도 기존 유효 좌표를 사용한다. 작업 상태가 READY인지로 제한하지 않는다. + 향후 공개 상태가 추가되면 이 공통 판정에 포함한다. #225의 도메인과 후속 조회 SQL 판정을 함께 맞춰야 한다. - 식당당 대표 `mapRegionId`는 0~1개. 기존 `area` 표시 문자열·행정구와 동일시하지 않는다. 모호한 기존 area는 미분류로 남기고 운영자가 확인한다. 미분류도 BBOX 일반 조회에는 포함할 수 있다. - 지역의 `name`, `clusterPosition`, `cameraBounds`, `displayOrder`, 활성 여부는 restaurant 소유 설정/데이터다. @@ -143,7 +145,7 @@ rating·reviewCount는 rankingAsOf 기준, 다른 표시 값과 좌표는 현재 "content": [], "hasNext": false, "querySessionId": "opaque-session-id", - "expiresAt": "2026-09-26T01:15:00Z", + "expiresAt": "2026-09-26T01:05:00Z", "rankingAsOf": "2026-09-26T01:00:00Z", "query": {"south": 35.0, "north": 35.1, "west": 139.0, "east": 139.1, "sort": "recommend"} } @@ -153,10 +155,15 @@ rating·reviewCount는 rankingAsOf 기준, 다른 표시 값과 좌표는 현재 ### 4.3 세션·경합·부분 실패 1. 새 조회에서 MySQL의 모든 해당 후보 ID·별점·리뷰 수를 일관된 읽기로 확보하고 추천 순열을 만든다. - Redis에 조건·순열·순위 기준값·rankingAsOf·절대 expiresAt을 전부 저장한 뒤 첫 페이지를 반환한다. - 일부만 저장하거나 저장 실패를 성공으로 처리하지 않는다. 15분은 초기 설정값이며 용량 검증 결과가 아니다. + Redis에 조건·순열·순위 기준값·rankingAsOf·수명을 전부 저장한 뒤 첫 페이지를 반환한다. + 일부만 저장하거나 저장 실패를 성공으로 처리하지 않는다. 초기 유휴 시간은 5분, 최대 수명은 30분이다. + 정상적인 다음 페이지 조회·재정렬은 `min(현재 시각 + 유휴 시간, 최초 생성 + 최대 수명)`까지 연장한다. + 기존 만료 시각에 시간을 더하지 않는다. 실패 요청·잘못된 cursor·자동 keepalive로 연장하지 않고, + 만료·유실된 세션을 되살리지 않는다. 동시 갱신은 원자적으로 처리하며 응답 expiresAt은 적용된 만료 시각이다. + Redis TTL과 애플리케이션의 수명 검사도 일치시킨다. 수치는 설정으로 두고 측정 후 조정한다. 2. 다음 페이지/재정렬마다 현재 MySQL에서 공개 여부·좌표 수명·BBOX·지역·검색·분류를 bulk 재검사한다. - 탈락 후보는 건너뛰고 이후 후보로 10개를 채운다. cursor는 마지막 소비한 후보 위치를 가리킨다. + 작은 묶음씩 확인하고 탈락 후보는 건너뛰어 10개를 채운다. cursor는 마지막 소비한 후보 위치를 가리킨다. + 다음 페이지 존재를 확인한 11번째 식당은 소비하지 않고 다음 페이지에 남긴다. 다음 유효 후보의 존재를 확인해 hasNext를 정하되, 이후 삭제로 다음 페이지가 빈 결과가 될 수 있다. 3. 새 식당이나 이전에 탈락한 후보를 이전 cursor 앞에 끼워 넣지 않는다. 순위 수치는 같은 세션에서 고정한다. 상세의 최신 별점·리뷰 수와 목록이 다를 수 있음을 rankingAsOf로 구분한다. @@ -165,8 +172,12 @@ rating·reviewCount는 rankingAsOf 기준, 다른 표시 값과 좌표는 현재 5. 410은 만료·eviction 등 세션 유실이다. 기존 목록·아직 유효한 핀을 유지하고 새로 조회를 제공한다. 503은 저장소 장애/수용량 부족이다. 같은 요청 재시도를 허용하고, 재시도 때 410이면 새 조회로 전환한다. 첫 페이지 생성 실패도 기존 결과를 교체하지 않는다. 서버가 임의로 새 세션을 이어 주지 않는다. -6. 세션별 후보 수·bytes·전체 동시 세션 admission 한도는 M4b에서 측정 후 명시한다. - 초과 시 RESTAURANT-016으로 전체 요청 실패; 성공처럼 결과를 잘라내지 않는다. +6. 세션별 bytes·전체 메모리 예산·호출자별 새 조회 제한은 M4b/#252에서 측정 후 명시한다. + 정상 조회는 500개를 넘어도 전체 후보를 끝까지 제공한다. 실제 수용량 부족은 RESTAURANT-016으로 + 전체 요청 실패; 성공처럼 결과를 잘라내지 않는다. 호출자별 제한은 RESTAURANT-022 / 429로 구분한다. + +이 절의 슬라이딩 만료와 용량 보호는 #234에 반영했다. 설정과 저장 구조는 [페이지 조회 문서](map-pagination.md)를 따른다. +[구현 계획](implementation-plan.md)의 부하·복구 검증과 운영 예산 확인 후 활성화한다. ### 4.4 선택 식당 @@ -180,30 +191,38 @@ rating·reviewCount는 rankingAsOf 기준, 다른 표시 값과 좌표는 현재 ## 5. 주소 저장과 좌표 처리 기존 `POST /admin/restaurants`(201 ADMIN-204), `PATCH /admin/restaurants/{id}` -(200 ADMIN-205)의 저장 성공 의미를 유지한다. 후속 구현에서 응답에 `locationStatus`, +(200 ADMIN-205)의 저장 성공 의미를 유지한다. 응답에 `geocodingAddress`, `locationStatus`, `addressRevision`을 추가한다. “식당 정보 저장 완료 / 지도 위치 확인 중”을 분리해 표시한다. -기존 주소는 보존하며 Google 응답의 formatted address로 덮어쓰지 않는다. +`address`는 건물명·층·호실을 포함한 표시용 전체 주소로 보존하며 Google 응답으로 덮어쓰지 않는다. +운영자는 표시 주소와 다른 provider 입력이 필요한 경우 선택 `geocodingAddress`를 입력한다. 서버가 상세 주소에서 +건물·층을 추측해 제거하거나 일본어로 번역하지 않는다. null이면 `address`를 조회 fallback으로 사용한다. + +PATCH에서 `geocodingAddress` 미전송/null은 기존 값을 유지하고 공백은 명시 값 삭제다. 표시 주소를 +바꾸면서 이 필드를 생략하면 과거 기본 주소도 삭제한다. 기존 기본 주소를 유지하려면 응답 값을 함께 보낸다. | 내부/관리자 wire 상태 | 의미·지도 노출 | |---|---| | UNRESOLVED | 기존 데이터 등 아직 작업이 없음. 지도 제외 | -| PENDING | 주소와 durable 작업 저장 완료, 처리 대기/lease 수행 중. 제외 | +| PENDING | 처리 대기/lease 수행 중. 같은 주소의 기존 유효 좌표가 있으면 노출 가능 | | READY | 유효 좌표 준비. §3 조건까지 만족하면 노출 | -| RETRY_WAIT | 일시 장애로 제한 재시도 대기. 제외 | -| REVIEW_REQUIRED | 결과 없음·모호함·허용 정확도 미달로 주소 확인 필요. 제외 | -| FAILED | 설정·권한 문제 또는 재시도 소진으로 자동 처리 중단. 제외 | +| RETRY_WAIT | 일시 장애로 제한 재시도 대기. 같은 주소의 기존 유효 좌표가 있으면 노출 가능 | +| REVIEW_REQUIRED | 결과 없음·모호함·허용 정확도 미달로 주소 확인 필요. 기존 유효 좌표가 있으면 노출 가능 | +| FAILED | 설정·권한 문제 또는 재시도 소진으로 자동 처리 중단. 기존 유효 좌표가 있으면 노출 가능 | -- 신규 저장/주소 변경은 `addressRevision` 증가, 이전 좌표 무효화·제거, PENDING 작업 생성까지 - 한 transaction이다. 동일 주소의 다른 정보 수정은 revision·작업을 불필요하게 갱신하지 않는다. +- 신규 저장은 revision 1로 시작한다. 이름·현지명·표시 주소 또는 실제 provider 입력 변경은 + `addressRevision` 증가, 이전 좌표와 확인 기록 제거, PENDING 작업 생성까지 한 transaction이다. + 같은 기본 주소를 명시해도 표시 주소·건물·층 수정은 재확인한다. 동일 입력의 PATCH는 기존 상태를 보존한다. - 작업에는 restaurant ID·주소 revision·job ID·lease token/만료·attempt·nextAttemptAt·안전한 - failureCode를 남긴다. 원본 주소는 해당 revision에 맞게 읽는다. 완료 시 모든 식별자와 lease가 + failureCode를 남긴다. provider 호출 시 해당 revision의 명시 기본 주소 또는 표시 주소 fallback을 + claim snapshot으로 읽는다. 완료 시 모든 식별자와 lease가 여전히 유효하고 식당이 삭제되지 않았을 때만 CAS로 저장한다. 같은 주소 재처리도 새 job/token으로 이전 결과를 막는다. lease 만료 후 늦은 worker, 중복 완료는 no-op이다. - worker가 죽으면 lease 만료 후 재claim한다. DB 완료 실패는 재처리 가능하며 외부 호출은 정확히 한 번을 보장하지 않는다. 중복 과금 가능성을 quota·attempt 제한에 포함한다. -- Google timeout·일시 5xx·단기 quota 오류는 지수 backoff와 jitter로 제한 재시도한다. +- Google timeout·일시 5xx·단기 quota 오류는 backoff와 jitter로 제한 재시도한다. 결제/권한/설정 오류는 FAILED로 중단하고, 애매한 여러 결과의 첫 항목을 임의 채택하지 않는다. - timeout·lease 길이·최대 attempt·일일 예산 수치는 M3 설정/테스트에서 정하고 활성화 전에 검증한다. + 갱신 재시도의 간격·최대 횟수는 [구현 계획](implementation-plan.md)의 정책을 따른다. + timeout·lease 길이·일일 예산은 M3 설정/테스트에서 정하고 활성화 전에 검증한다. - `GET /admin/restaurants/{id}/location`(ADMIN)은 상태·revision·validUntil·attempt·nextAttemptAt· failureCode·canRetry를 반환한다. Google 원문·키·요청 URL·lease token을 노출하지 않는다. - `POST /admin/restaurants/{id}/location/retry`, body `{"expectedAddressRevision":3}`(ADMIN)는 @@ -236,9 +255,12 @@ saved는 현재 사용자의 소유 컬렉션에 하나 이상 저장됐는지 지도 카드와 상세의 저장 버튼은 즉시 boolean을 뒤집는 명령이 아니다. 로그인 사용자에게 `SAVED_COLLECTION_SAVE`를 열어 대상 컬렉션 하나를 선택하게 한다. 비로그인 사용자는 로그인 완료 뒤 저장 직전의 지도·선택 식당·목록 상태로 돌아오며 자동 저장이나 자동 모달 재열기는 하지 않는다. -새 컬렉션 생성·수정에서 이름과 설명의 앞뒤 공백은 보존하고, 공백만인 이름은 거부한다. -현재 develop의 #216 쓰기 서비스는 공백을 제거한다. 이 계약 차이는 #242의 컬렉션 쓰기 수정과 -생성·수정 인수 테스트로 해소해야 하며, #242 병합 전에는 공백 보존을 현재 동작으로 보지 않는다. +컬렉션 생성·수정에서 이름과 설명의 앞뒤 공백은 제거하고 중간 공백은 유지한다. +공백만 있는 이름은 거부하고, 이름 20자·설명 100자 제한은 제거 전 입력값에 적용한다. +공백 제거 후 이름으로 중복을 검사하며 기존 DB collation은 유지한다. +PATCH에서 null은 유지하고 설명에 빈 문자열·공백만 보내면 삭제한다. +이 규칙은 2026-10-12 수정 화면 확인과 사용자 결정으로 이전 공백 보존 결정을 대체한 것이다. +위에 연결한 고정 PLAN 문서에 소급 적용된 것으로 해석하지 않는다. 공유 링크를 복사해도 공개 범위는 바뀌지 않으며, 명시적인 소유자 PATCH만 공개 상태를 변경한다. ### 전체 마커 응답과 실패 @@ -282,7 +304,7 @@ timestamp는 기존 LocalDateTime 형식이며, 새 data의 UTC 시각과 혼동 | 410 RESTAURANT-013 | MAP_SESSION_EXPIRED; 만료·유실·읽을 수 없는 구버전 세션 | 기존 결과 유지, 새로 조회 | | 503 RESTAURANT-014 | MAP_SESSION_UNAVAILABLE; Redis 연결/읽기/쓰기 장애 | 같은 요청 재시도 | | 503 RESTAURANT-015 | MAP_QUERY_UNAVAILABLE; DB 조회 실패 | 같은 요청 재시도 | -| 503 RESTAURANT-016 | MAP_CAPACITY_EXCEEDED; 온전한 세션 수용 불가 | 범위 축소/나중에 재시도 | +| 503 RESTAURANT-016 | MAP_CAPACITY_EXCEEDED; 온전한 세션 수용 불가 | 기존 결과 유지, 나중에 같은 요청 재시도 | | 503 RESTAURANT-017 | MAP_CONFIGURATION_UNAVAILABLE | 지역·초기 화면 설정 오류 안내 | | 404 RESTAURANT-004 | 기존 NOT_FOUND; 삭제·비노출 식당 | 해당 핀·지도 목록 제거 | | 409 RESTAURANT-018 | MAP_LOCATION_UNAVAILABLE | 좌표·선택 해제; 컬렉션 목록 유지 | @@ -312,7 +334,7 @@ timestamp는 기존 LocalDateTime 형식이며, 새 data의 UTC 시각과 혼동 ``` 좌표를 반환하는 API와 개인/컬렉션 응답은 `Cache-Control: no-store`를 사용한다. -FE는 좌표를 영구 저장소에 남기지 않고 각 validUntil에 폐기한다. 세션 15분 TTL이 좌표의 +FE는 좌표를 영구 저장소에 남기지 않고 각 validUntil에 폐기한다. 조회 세션 TTL이 좌표의 보관 허가/수명을 연장하지 않는다. 정상/실패와 무관하게 로그에 좌표·검색어·Google 원문을 남기지 않는다. ## 8. Google 출처·보존과 운영 전환 @@ -348,11 +370,13 @@ backfill을 활성화하지 않고 fake adapter로 개발한다. MySQL에 넣어 | Google 위치 | source·obtainedAt·validUntil·addressRevision 기록. 계약상 허용 기간 이하로 만료, 원본 응답 저장 금지 | | 만료 위치 | 조회에서 즉시 제외하고 보존 기한 전에 DB·캐시·클라이언트에서 제거. 갱신 실패로 옛 validUntil 연장 금지 | | 변환 작업 | 완료 후 원본 응답/주소 복제 없이 ID·revision·안전한 결과 코드·attempt 등 최소 이력만 유지 | -| Redis 조회 세션 | 최초 생성 후 15분, 접근 시 연장 없음. Google 좌표/원문·개인 상태 저장 없음 | +| Redis 조회 세션 | 초기 유휴 5분·최대 수명 30분, 정상 조회 시 유휴 수명 갱신. Google 좌표/원문·개인 상태 저장 없음 | | 로그·백업·복구본 | Google 결과가 남는 모든 경로를 보존 정책에 포함. 만료 데이터 복원 후 공개 금지; 제거·백업 분리/보존 증거가 없으면 운영 gate 미충족 | -만료 전에 갱신할 작업을 예약한다. 갱신을 시작하면 PENDING으로 전환해 기존 좌표를 비노출·제거하며, -실패 중에도 식당 목록의 원본 데이터를 삭제하지 않는다. 만료 스케줄러가 늦어도 조회의 시간 검사로 +만료 3일 전부터 갱신할 작업을 예약한다. 같은 주소의 갱신은 PENDING으로 전환하되 기존 유효 좌표를 +유지한다. 주소 변경 시에는 바로 제거하고 새 결과가 검증을 통과할 때 교체한다. 갱신 실패도 기존 +validUntil을 연장하지 않는다. 작업 상태와 관계없이 보관 기한 내 제거하고, 진행 중 갱신은 이어간다. +식당 목록의 원본 데이터는 삭제하지 않는다. 만료 스케줄러가 늦어도 조회의 시간 검사로 노출은 막지만 그것만으로 저장 데이터 제거를 완료했다고 보지 않는다. [Geocoding 정책](https://developers.google.com/maps/documentation/geocoding/policies)에 따라 @@ -363,4 +387,4 @@ backfill을 활성화하지 않고 fake adapter로 개발한다. MySQL에 넣어 허용 보존 기간, 키 제한, timeout·quota· 재시도 한도, Redis 인증 데이터와의 용량 경쟁을 확인한다. backfill은 대상 수·주소 품질·예상 호출량을 **조회 전용 dry-run**으로 확인한 뒤 checkpoint·중단·복구 기준과 별도 운영 승인을 갖춘다. -이 PR은 Java·dependency·migration·유료 호출·운영 데이터 변경·배포를 포함하지 않는다. +이 계약 문서는 기능 구현·유료 호출·운영 데이터 변경·배포 완료의 증거가 아니다. diff --git a/docs/map/map-integration-verification.md b/docs/map/map-integration-verification.md new file mode 100644 index 00000000..667ef901 --- /dev/null +++ b/docs/map/map-integration-verification.md @@ -0,0 +1,163 @@ +# 식당 저장부터 지도 조회까지 서버 통합 검증 (#231) + +## 검증 범위 + +이 브랜치는 관리자 식당 저장부터 위치 작업, Google adapter, MySQL 위치, 공개 BBOX 조회, Redis 조회 세션, 컬렉션 핀까지 서버 호출 경로를 결합한다. 실제 화면, 운영 배포, 운영 backfill은 범위에 포함하지 않는다. + +| 결합 기능 | 확인 범위 | +|---|---| +| 위치 모델·Google adapter·worker·유지보수 | 관리자 저장/PATCH, durable job, 채택 정책, 갱신·만료 | +| 공개 지도 조회·Redis 세션 | 10개 페이지, cursor 재시도, 동점 순서, compact session payload, TTL·용량 guard | +| 컬렉션 지도 | 공개 핀, 저장 수, 본인 저장 여부, 위치 유효성 반영 | + +JUnit 통합 테스트는 새 Testcontainers MySQL 8.4와 Redis를 사용한다. 합성 주소·좌표·사용자만 저장하며 개발·운영 DB/Redis에 쓰지 않는다. 일반 통합 테스트는 Google provider를 대체하고 worker를 직접 실행한다. `map-live` 태그 테스트만 명시적 승인 아래 실제 Google을 호출하며 Geocoding 흐름은 최대 2회, Places 흐름은 최대 3회다. 별도 공유 Redis probe의 실제 환경 범위는 아래에 구분한다. + +## 자동 통합 흐름 + +`RestaurantMapFlowIntegrationTest`는 실제 Security filter, 관리자 Controller/Service, 위치 worker와 저장소, 공개 Controller/Service/Repository, Flyway/JPA, MySQL/Redis를 사용한다. 핵심 지도 서비스와 저장소는 mock으로 바꾸지 않는다. + +1. 관리자 POST가 `201 ADMIN-204`, 위치 `PENDING`, 주소 revision 1, durable job 1개를 만든다. worker 전 공개 현재 위치는 409다. +2. worker가 transaction 밖에서 provider를 호출하고 후보 채택 정책을 통과하면 위치가 `READY`가 된다. 관리자 상태, 공개 현재 위치, 지역 수, BBOX 첫 페이지와 RestaurantPort가 같은 식당과 좌표를 반환한다. +3. 이름·현지명·표시 주소·기본 주소가 모두 그대로인 PATCH는 `READY`, revision 1과 job 1개를 유지한다. 표시 주소의 층수만 바꾸고 같은 `geocodingAddress`를 함께 PATCH하면 기존 좌표와 확인 기록을 지우고 `PENDING`, revision 2와 job 2개가 된다. 현재 위치·기존 Redis 조회 세션·컬렉션 핀에서 제외되며 worker가 다시 확인한 뒤 `READY`와 핀이 복구된다. +4. 실제 geocoding 기준 주소가 바뀌면 revision이 증가하고 새 job이 등록된다. 기존 위치는 공개 조회와 핀에서 제외되고, 변경 전 Redis 조회 세션을 다시 읽어도 오래된 카드가 반환되지 않는다. +5. 이전 revision의 늦은 성공과 삭제 뒤 늦은 실패는 현재 주소나 삭제 상태를 덮어쓰지 못한다. +6. 유효기간 전 갱신 등록은 기존 좌표를 유지한 채 `PENDING`으로 전환한다. worker 성공 뒤 새 유효기간을 저장한다. 실제 만료 정리는 공개 현재 위치, Port, BBOX와 핀에서 좌표를 제거한다. +7. 합성 READY 식당 23·500·501·620개를 10개씩 끝까지 조회해 중복·누락 없음, 같은 cursor 재시도, 10 valid + 미소비 lookahead, 동점 추천 순서를 확인한다. 임의 500개 전체 제한은 두지 않는다. +8. 기존 컬렉션 저장 관계를 통해 전체 핀, 공개 저장 사용자 수, 본인 저장 여부를 확인한다. 위치가 무효해져도 저장 관계는 남고 핀만 제외된다. + +## 로컬 검증 명령 + +Java 21과 Docker가 필요하다. 일반 검증과 부하는 동시에 실행하지 않는다. + +```powershell +.\gradlew.bat test ` + --tests '*RestaurantMapFlowIntegrationTest' ` + --tests '*LocationMaintenanceMySqlTest' ` + --tests '*CollectionMapPortMySqlIntegrationTest' ` + --tests '*RedisMapSessionStoreIntegrationTest' ` + bootJar --no-daemon --console=plain --max-workers=1 '-Dorg.gradle.jvmargs=-Xmx384m' +``` + +전체 build와 원격 CI는 위 focused 검증과 별도 결과로 기록한다. + +2026-10-11, #233 `da20fd8f`와 공유 Redis `f61a3a95`를 결합하고 위치 변경 계약을 정렬한 코드에서 +위 명령이 통과했다. 지도 흐름 8건, 유지보수 26건, 컬렉션 Port 6건, Redis 저장소 22건으로 +총 62건이며 실패·오류·skip은 0이다. `bootJar`도 성공했다. 이 실행은 실제 Google이나 k6를 포함하지 않는다. + +## 실제 Google opt-in 검증 + +`MapGoogleLiveFlowTest`는 일반 `test`와 CI에서 제외된다. `-PmapLive=true`와 환경 변수의 API key가 모두 있어야 실행되며, key를 Gradle 인자·파일·로그에 적지 않는다. + +```powershell +.\gradlew.bat mapLiveTest -PmapLive=true --tests '*MapGoogleLiveFlowTest' --no-daemon --console=plain --max-workers=1 +``` + +승인된 loopback TCP 터널을 사용할 때도 제품 HTTPS endpoint, SNI와 인증서 검증은 유지한다. 자동 scheduler는 대체하고 worker만 수동 실행한다. DB 일일 예산과 테스트 provider counter의 상한은 두 번이다. + +한 실행의 확인 순서는 다음과 같다. + +1. 관리자 저장 뒤 실제 Google 1회 호출로 `READY`, BBOX 카드와 컬렉션 핀까지 확인한다. +2. 이름·현지명·표시 주소·기본 주소를 저장된 값 그대로 PATCH한다. 값이 바뀌지 않았으므로 revision·job·핀은 유지되고 provider counter도 1에 머문다. 층수 변경은 일반 통합 테스트에서 별도 검증한다. +3. 위치 유효기간을 갱신 창 안으로 옮겨 refresh job을 등록한다. 기존 핀을 유지한 채 실제 Google 두 번째 호출로 새 유효기간을 저장한다. + +`languageCode=en`은 provider 응답 언어를 고정하기 위한 값이며 입력 주소나 UI 표시 주소의 언어를 강제하지 않는다. JP 지역 정보와 후보의 국가·행정구역·정밀도·주소 구성요소를 채택 정책에서 확인한다. Google 응답 언어와 관리자가 저장한 표시 주소 언어는 서로 다른 계약이다. 통과를 위해 실제 응답이나 기대 좌표를 임의로 바꾸지 않는다. Google 원문, 좌표 원문, 운영 주소와 key는 저장소에 남기지 않는다. + +2026-10-08 코드 `39266dd`에서는 당시 계약에 따라 층수 변경에도 기존 revision·job·핀을 유지했고, Google 호출은 최초 등록과 갱신 각 한 번으로 통과했다. 이는 과거 구현의 실호출 기록이다. 현재 계약은 층수 변경도 무효화하므로 2회 상한을 유지하도록 위 2단계를 값이 그대로인 PATCH로 바꿨다. 변경된 테스트의 실제 Google 호출은 다시 실행하지 않았다. 당시에도 별도 Testcontainers DB/Redis를 사용했고 운영 데이터나 자동 호출 설정은 변경하지 않았다. + +### Places 매장 선택과 갱신 + +`MapPlacesLiveFlowTest`는 별도 opt-in 검증이다. 위 Geocoding 테스트와 한꺼번에 실행하지 않고 `--tests`로 대상을 지정한다. + +```powershell +.\gradlew.bat mapLiveTest -PmapLive=true --tests '*MapPlacesLiveFlowTest' --no-daemon --console=plain --max-workers=1 +``` + +격리된 MySQL·Redis에서 관리자 식당 등록 후 `REVIEW_REQUIRED`를 만들고, 후보 검색·관리자 선택·Details 저장·같은 매장 갱신을 실제 API 경로로 확인한다. 처음 확인 대상 상태를 만드는 Geocoding 응답만 mock이며, Places는 Text Search 1회와 동일 Place ID의 Details 2회로 제한한다. 지도 카드와 컬렉션 핀이 갱신 전·중·후 모두 조회되는지 확인한다. + +실제 Google 통신만 개발 서버의 승인된 SSH 터널을 거친다. 애플리케이션과 DB는 개발 EC2의 실행 중 서비스가 아닌 로컬 테스트 컨테이너다. 주소·응답 원문·Place ID·키를 결과 로그에 출력하지 않고, 테스트가 끝나면 자체 터널과 컨테이너를 정리한다. + +2026-10-09 위 Places 흐름을 실제 실행해 통과했다. Text Search 1회와 Details 2회가 요청 제한·예산 집계와 일치했으며, 최초 선택과 정기 갱신 모두 `READY` 및 지도·컬렉션 핀을 확인했다. 운영 DB 쓰기나 자동 호출 활성화는 하지 않았다. + +## Redis session과 용량 결과 + +세션은 query ID별 단일 Redis key와 단일 JSON payload를 유지한다. 후보는 `[restaurantId,rating,reviewCount]` tuple로 직렬화하고 format version을 검사한다. 이전 장문 JSON은 혼합 배포에서 fail-closed 410으로 처리한다. 유휴 TTL 5분과 최대 수명 30분, 추천 순서, 10 valid + 미소비 lookahead 계약은 바꾸지 않았다. + +620개 serializer 비교는 다음과 같다. + +| 항목 | 기존 object JSON | compact tuple JSON | +|---|---:|---:| +| payload | 31,188 B | 7,646 B | +| admission 예약 | 63,400 B | 16,316 B | + +예약값은 74.3% 감소했다. 16 MiB 예산을 올리거나 Redis 자료구조를 여러 key로 나누지 않았다. + +실제 TTL 300초, distinct caller, 620개, 16 MiB의 로컬 비교 결과는 다음과 같다. + +| 설정 | 요청/iteration | 503 | 최대 ledger | DB pool | 성공 응답 전체 p95 | 판정 | +|---|---:|---:|---:|---|---:|---| +| 운영 기본 동시 요청 4 | 1,230 / 236 | 6 | 7,977,700 B (47.55%) | active 4, wait 0 | 229.76 ms | k6 FAIL | +| test-only 동시 요청 8 | 1,148 / 223 | 3 | 7,488,384 B (44.64%) | max 10, active 7, wait 0 | 1,087.88 ms | k6 FAIL | + +두 실행의 workload에서 `total_bytes`, `snapshot_bytes`, `session_count`, `redis_memory_guard` 거절은 0이었다. 모든 503은 내부 `concurrent_requests` counter로 확인했다. 동시 요청 8은 거절을 6개에서 3개로 줄였지만 threshold를 통과하지 못했고, 처리량이 줄면서 성공 응답 전체 p95가 4.73배 증가했다. 운영 기본값 4를 유지한다. + +두 실행 모두 실제 300초 TTL 뒤 session key가 정리됐다. 별도 2-session probe는 세 번째 요청을 503으로 거절하고 만료 뒤 회복했으며 auth sentinel을 보존했다. 이 결과는 작은 로컬 합성 부하이며 운영 동시 사용자 수나 SLO를 증명하지 않는다. + +`MapQueryLoadTest`는 opt-in `map-load` 태그다. k6 설치 환경에서만 실행한다. + +현재 부하 fixture는 인증 Redis와 지도 Redis를 별도 컨테이너로 실행한다. 인증은 +maxmemory 64MiB/volatile-lru/컨테이너 128MiB, 지도는 기존 비교 조건인 +maxmemory 128MiB/noeviction/컨테이너 256MiB를 사용한다. 지도 메모리·세션·ledger는 지도 +연결에서 측정하고, 인증 보존용 합성 키는 인증 연결에서 확인한다. 이 합성 키 검사는 로그인 전체 +검증이 아니며, 실제 RefreshTokenStore 회전 중 장애 격리는 `MapRedisIsolationIntegrationTest`가 담당한다. +일반 HTTP/실제 Google 흐름 fixture에도 지도 endpoint를 명시한다. 실제 Google 테스트 실행은 계속 opt-in이다. + +```powershell +.\gradlew.bat mapLoadTest ` + -Pk6Executable= ` + -PmapLoadProfile=ttl -PmapLoadTraffic=distinct ` + -PmapLoadRestaurants=620 -PmapLoadBudgetMiB=16 ` + --no-daemon --console=plain --max-workers=1 +``` + +테스트 전용 비교에는 `-PmapLoadConcurrentRequests=8`을 추가할 수 있지만 운영 기본값은 바뀌지 않는다. 결과는 `build/reports/map-load/`의 k6 summary, log, resource samples, recovery JSON에 기록한다. k6 threshold가 실패해도 TTL 정리·회복·auth sentinel을 확인한 뒤 최종 실패한다. + +### 단계별 시간 측정 후 실행 + +2026-10-09, 단계별 지표가 포함된 코드 `c38601d3`에서 같은 크기의 합성 식당 620개와 서로 다른 호출자를 사용해 5→10→20 VU, 5분 부하를 실행했다. 요청 1,265건과 검증 2,783건이 실패 없이 통과했다. 추가 5분 뒤 TTL 정리, 별도 제한 초과 probe 후 복구와 인증용 테스트 키 보존도 확인했다. + +전체 요청 p95는 111.12ms였다. 단계별 평균은 새 조회 admission 4.88ms, 후보 조회 22.47ms, 세션 저장 5.94ms, 페이지 구성 41.97ms, TTL 갱신 3.64ms였다. 동시 요청 한도 4와 16MiB 예산은 그대로 유지했다. + +앞선 실패 실행과 호스트 부하를 통제한 A/B 비교가 아니다. 이 성공을 특정 수정의 인과적 성능 효과, 운영 처리량 또는 동시 사용자 20명 보장으로 해석하지 않는다. 현재 측정만으로 Redis List나 Sorted Set을 추가할 근거는 없다. 이후 Places 기능이 합쳐진 HEAD의 부하를 새로 측정한 결과도 아니다. + +### 실제 공유 Redis의 제한된 probe — 2026-10-11 + +후보 `f61a3a95869e8c4fbbda4bc5464cc053770c9536`의 공유 모드 Lua를 실제 운영 Redis 7.1의 +기존 `volatile-lru` 정책에서 확인했다. 빈 고유 테스트 namespace에 합성 후보 620개로 구성한 세션을 +최대 32개 저장하고 32개 읽기·TTL 갱신을 확인했다. 지도 메모리·세션 수·호출자별 신규 조회 제한, +query 소실 뒤 재생성 금지, ledger 소실 뒤 쓰기·TTL 갱신 차단도 통과했다. + +최초 30분 격리는 거절 응답으로 확인했다. 정상 경로를 확인하기 위해 다른 데이터가 없는 테스트 namespace의 +`readyAt`만 앞당겼으므로 실제 30분 경과 후 회복을 검증한 것은 아니다. 별도 HTTP/k6 부하나 운영 용량 검증도 아니다. +테스트가 만든 키 35개를 모두 제거했고 남은 자체 키와 정리 실패는 0이었다. 전후 eviction은 0, +애플리케이션 health는 HTTP 200이었고 앱을 재시작하지 않았다. 인증 키를 읽거나 변경하지 않았으며 +실제 애플리케이션 배포나 지도 활성화를 수행하지 않았다. 따라서 로그인 연속성이나 인증 영향 전체를 증명하지 않는다. + +근거는 저장소 밖의 로컬 `evidence/map-shared-redis-20261011/shared-result.json`이다. +접속 정보·키·운영 메모리 설정값·원문 응답은 이 문서에 복제하지 않는다. + +## 현재 데이터 확인과 남은 gate + +운영 식당 주소 71개에 대한 실제 provider 응답을 별도 환경에서 제품 parser·정책으로 재생한 결과는 자동 채택 62건, 확인 대상 9건이다. 자동 채택 62건은 모두 선택 후보와 Maps 기준 거리를 계산했다. 위치 참조 정정 후 잘못 다른 지점을 가리키던 표본은 기준점과 거리 0.0m로 다시 확인됐다. 자동 채택 표본 중 기준점에서 50m 이상 떨어진 항목은 한 건이며 역 시설 약 208m 사례다. 이는 운영 DB에 좌표를 쓰거나 `READY` 상태를 만든 결과가 아니다. 저장소 문서에는 구체 식당 ID, 주소와 좌표를 기록하지 않는다. +이 62/9 수치는 component의 동일 숫자를 서로 다른 역할로 오해하던 문제를 수정하기 전 선행 정책 HEAD에서 측정했으며, 현재 정책 71건의 최종 재검증 결과로 해석하지 않는다. + +공개 활성화는 아직 NO-GO다. + +- 이전 `dedicated` 모드의 `noeviction` 요구는 기존 공유 Redis 정책과 맞지 않았다. 현재 `shared` 모드는 + 영속 ledger와 제한·격리로 `volatile-lru`를 지원하며 위 실제 probe는 그 제한된 근거다. 인증 데이터와 공유하므로 + runtime `CONFIG SET`으로 정책을 바꾸지 않는다. +- 실제 앱 설정으로 연결한 HTTP 부하·30분 격리 후 회복·로그인 연속성과 장애 영향을 추가로 검증해야 한다. + 지도 연결을 따로 두는 것만으로 공유 Redis의 메모리·장애가 격리되지는 않는다. 이 변경만으로 추가 ElastiCache를 만들거나 서버에서 활성화하지 않는다. +- 실제 데이터 aggregate와 새 fixed HEAD의 focused/full test, 실제 Google opt-in 결과, CI를 각각 확인해야 한다. +- merge, 배포, live backfill과 운영 설정 변경은 별도 승인 대상이다. + +프런트 계약과 화면 인계는 [프런트 연동 인계](frontend-integration-handoff.md), 위치 유지보수 반례와 명령은 [위치 유지보수 runbook](location-maintenance-runbook.md)을 따른다. diff --git a/docs/map/map-pagination.md b/docs/map/map-pagination.md new file mode 100644 index 00000000..0bb9d20e --- /dev/null +++ b/docs/map/map-pagination.md @@ -0,0 +1,307 @@ +# 지도 목록과 조회 세션 (#227) + +기준 계약은 #219 / PR #221의 Map Contract v1이다. #220 위치 모델과 #223 조회를 사용하며, +일반 `/restaurants`의 cursor·정렬과 컬렉션 전체 핀 계약은 바꾸지 않는다. + +## 요청과 응답 + +`GET /api/v1/restaurants/map`은 활성화 시 익명 공개이며 기본 비활성화 상태다. 다음 세 모드만 허용한다. + +- 새 조회: 필수 south/north/west/east, 선택 mapRegionId/keyword/genre/placeType/sort. +- 같은 조회의 정렬 변경: querySessionId와 sort만. +- 다음 페이지: cursor만. + +size, 중복 파라미터, 모드 혼합, 알 수 없는 파라미터는 COMMON-400이다. 지도 정렬은 +recommend/rating/reviews이며 기본은 recommend다. BBOX·분류·지역·검색어는 #223의 +MapQueryBounds/MapSearchCriteria 검증과 SQL을 공유한다. + +공개 BBOX의 각 숫자는 문자열 128자, precision 128자리, 절대 scale 128까지 허용한다. +큰 지수 때문에 BigDecimal 산술이 자원을 과도하게 사용하지 않도록 산술 전에 거절하며 오류는 +RESTAURANT-011이다. 이 범위 안의 SDK 소수는 그대로 비교하고 반올림/절삭하지 않는다. + +content는 최대 10개다. 마지막/빈 페이지의 hasNext는 false이고 nextCursor는 생략한다. +query에는 적용한 BBOX·정규화 필터·정렬이 들어간다. rankingAsOf/expiresAt/location.validUntil은 UTC Z다. +기존 카드의 이미지와 오늘 영업시간 규칙을 공유하며 placeType/reviewCount/priceRange/location을 추가한다. +금액은 기존 store-information과 같이 Restaurant의 통화·최소·최대 금액을 정수 Long으로 반환한다. +rating/reviewCount만 rankingAsOf 기준이고 나머지 카드는 현재 DB 값이다. 개인 저장 정보는 포함하지 않는다. + +## DB와 페이지 위치 + +RestaurantMapPageService는 NEVER로 상위 transaction이 열린 호출을 Redis 접근 전에 거절한다. 후보 확보는 +RestaurantMapService의 짧은 읽기 transaction, 세션 저장은 Redis, 페이지 재검사/카드는 +RestaurantMapPageReader의 REPEATABLE_READ transaction으로 나뉜다. transaction을 일시 중단해도 상위 호출의 +DB 연결은 남을 수 있으므로 이 경계를 명시한다. + +기본 OSIV도 요청의 EntityManager와 물리 연결을 transaction 종료 뒤까지 보유할 수 있다. +RestaurantMapWebConfig가 표준 OSIV interceptor를 등록하면서 새 `/api/v1/restaurants/map` 경로만 제외한다. +다른 경로의 기존 OSIV는 유지하며 명시적인 `spring.jpa.open-in-view=false` 설정도 존중한다. +따라서 지도 목록의 각 DB transaction이 자체 EntityManager를 닫고 Redis 접근 전에 연결을 반환한다. + +최초 후보 ID·평점·리뷰 수를 한 SQL에서 읽고 추천 순열을 한 번 만든다. Redis 저장 성공 후에만 +첫 페이지를 구성한다. 별점/리뷰 정렬은 최초 값의 안정 정렬이므로 동점은 처음 추천 순서다. +매 페이지에서 현재 공개·위치 수명·BBOX·지역·검색·분류를 다시 조회하고 탈락 후보를 건너뛴다. +한 페이지의 조건/좌표/카드에는 같은 DB snapshot을 사용한다. 반환 후 발생한 변경까지 보장하지 않는다. + +cursor 위치는 실제 검사한 후보 위치다. 후보를 32개씩 재검사하고 유효한 10개와 +hasNext 확인용 1개가 확보되면 멈춘다. 11번째 후보는 소비하지 않아 다음 페이지에 포함된다. +동일 cursor는 전역 위치를 바꾸지 않으며 복구/신규 후보가 지나간 위치 앞에 삽입되지 않는다. +DB 공개 조건이 바뀌면 같은 cursor의 표시 항목은 달라질 수 있다. + +## 저장소와 한도 + +조회 UUID별 String 키에 JSON 하나를 저장한다. dedicated 모드는 `hashi:restaurant:map:{sessions-v2}:query:`, shared 모드는 `hashi:restaurant:map:{sessions-shared-v3}:query:`를 사용한다. 모드 전환 시 이전 조회 세션은 410으로 끝나므로 새 조회를 시작한다. +최초 추천 순서와 별점·리뷰 수를 보관한다. 후보는 `[restaurantId, rating, reviewCount]` tuple 배열로 +직렬화해 세 값의 관계와 추천 순서를 함께 유지하면서 후보마다 반복되던 필드명을 제거한다. +별도 정렬 자료구조나 payload 분할은 사용하지 않는다. +검색 조건, `rankingAsOf`, 최대 보관 시각도 포함한다. keyword 조회는 전체 일치 건수와 모든 결과를 포함하는 +`searchResult.bounds`, 그 좌표 중 가장 이른 `validUntil`만 집계해 보관한다. 개별 식당 좌표·이미지·Google 원문·개인 상태는 없다. +JSON을 Lua에서 변환하지 않으므로 큰 Long ID도 그대로 보존된다. + +저장 형식은 domain의 `schemaVersion`과 별도로 `formatVersion`을 검사한다. 이번 compact tuple 형식은 +`formatVersion=2`, `schemaVersion=2`이며 누락·불일치·tuple 길이·검색 결과 집계 오류는 부분 복원하지 않고 +RESTAURANT-013 / 410으로 끝난다. 이전 v1과 long-form JSON도 배포 뒤에는 410이므로 기존 cursor가 만료된다. +공개 기본 비활성 상태에서 전환하며, +향후 활성화 뒤 형식을 바꿀 때는 rolling 배포의 양방향 읽기 호환을 별도로 설계해야 한다. + +세션은 **마지막 정상 조회부터 5분**, **첫 조회 admission부터 최대 30분** 유지한다. +keyword 조회는 집계 경계에 포함된 좌표 중 가장 이른 `validUntil`을 절대 만료 상한으로 추가 적용한다. +검색 결과가 0건이면 좌표 만료 상한은 없다. +생성 시각과 TTL 판단은 Redis `TIME`으로 통일한다. 기존 admission 호출에서 받은 시각을 재사용하므로 추가 왕복은 없다. +다음 페이지와 정렬 변경이 성공하면 그 시점부터 5분으로 TTL을 갱신한다. 기존 TTL에 5분을 더하지 않는다. +조회만 시작했거나 잘못된 cursor, DB 실패인 경우 연장하지 않는다. Redis Lua에서 현재 TTL을 확인해 +만료한 키를 다시 만들지 않으며 동시 요청도 이미 연장한 시각을 앞당기거나 최대 수명을 넘지 않는다. +앱 시각과 Redis 시각을 비교해 세션을 만료시키지 않는다. `rankingAsOf`는 DB 조회 조건에 사용한 앱 시각 메타데이터이며 +세션 수명 계산에 사용하지 않는다. DB 조회·좌표 유효성에는 앱 시계를 사용하므로 서버 시각 동기화는 계속 필요하다. +응답 `expiresAt`은 해당 성공 요청에서 확정한 실제 만료 시각이다. `rankingAsOf`는 변하지 않는다. + +설정 접두사는 `hashi.restaurant.map.session.limits`다. 다음은 측정 후 조정할 초기값이며 운영 최적값이 아니다. + +| 설정 | 초기값 | 목적 | +| --- | --- | --- | +| `concurrent-requests` | 인스턴스별 4 | DB·역직렬화·정렬의 동시 작업 수 | +| `idle-timeout` / `max-lifetime` | 5m / 30m | 유휴 만료와 최대 보관 시간 | +| `snapshot-bytes` | 1MiB | 조회 하나의 JSON 크기 | +| `total-bytes` | 16MiB | 지도 세션 예약 예산 | +| `sessions` | 1,024 | 작은 세션과 ledger 처리량 제한 | +| `new-queries-per-caller` | 분당 12 | 신규 DB 조회 남용 제한 | +| `requests-per-caller` / `requests-per-minute` | 분당 120 / 600 | 페이지·정렬 포함 호출 제한 | +| `callers-per-minute` | 2,048 | 익명 호출자 관리 정보 크기 제한 | +| `redis-memory-ceiling` / `redis-headroom` | 128MiB / 32MiB | 지도 Redis 사용량 상한과 여유분 | + +500개 고정 제한은 제거했다. 500·501·620개도 마지막 페이지까지 조회한다. +무한 목록을 메모리에 올리지는 않는다. 후보당 32 byte를 보수적 계획 단위로 사용해 +`max snapshot bytes / 32`로 DB 조회 안전 상한을 정하고, compact tuple의 실제 직렬화 크기도 다시 검사한다. +32 byte는 저장 형식의 최소 크기 주장이 아니라 DB 결과를 무제한 적재하지 않기 위한 메모리 경계다. +결과를 잘라서 성공으로 반환하지 않고 계획 상한이나 실제 byte 예산을 넘으면 전체 요청을 503으로 끝낸다. +운영 데이터가 byte 예산을 초과하면 503으로 끝나므로 실제 후보 규모 측정과 예산 조정은 공개 전 필요하다. + +`:admission` hash는 세션별 예약 바이트와 만료 시각을 보관한다. shared에는 복구 준비 시각도 포함한다. 생성 시 만료한 예약을 회수하고, +기존 예약 합계와 세션 수를 확인한다. 예약은 `JSON bytes × 2 + 1,024`로 잡아 key/allocator/ledger 여유를 둔다. +세션 생성·TTL 갱신·예약 변경은 같은 hash slot의 Lua로 처리한다. 예약 후 payload를 저장하므로 쓰기가 +실패해도 예산에 잡히지 않은 payload를 만들지 않는다. 예약이 남으면 유휴 기한 뒤 회수한다. +전체 hash 스캔은 설정 최대 4,096개로 제한한다. 이 hash는 용량 관리용이며 정렬 데이터 구조가 아니다. + +`:requests` hash 하나에 최근 분의 호출 수를 보관한다. 다음 분 첫 요청에서 초기화한다. +dedicated에는 120초 TTL을 두고, shared에서는 TTL 없이 bounded hash를 유지한다. +원문 IP는 저장하지 않고 서명키로 HMAC한 호출자 식별자를 사용한다. 임의의 caller마다 Redis 키를 늘리지 않는다. +카운터 검사는 DB 후보 조회와 JSON 생성보다 먼저 실행한다. +인스턴스별 Semaphore는 지도 작업을 기본 4개로 제한하고 대기열 없이 초과 요청을 503으로 돌려준다. +Redis뿐 아니라 Java heap과 DB 연결도 보호하며, 실패해도 finally에서 실행 자리를 반환한다. +`concurrent-requests`는 기동 때 적용되며 변경하려면 인스턴스를 다시 시작한다. 분 경계에서는 두 분의 한도가 연속 사용될 수 있다. + +내부 counter `hashi.restaurant.map.capacity.rejected`는 사용자 입력 없이 고정된 `reason`만 기록한다. +`concurrent_requests`, `candidate_count`, `snapshot_bytes`, `total_bytes`, `session_count`, +`caller_rate`, `caller_cardinality`, `global_requests`, `redis_memory_guard`, `redis_recovery`, +`redis_accounting`을 구분하며 공개 상태·오류 코드는 바꾸지 않는다. +Prometheus에서는 `hashi_restaurant_map_capacity_rejected_total`로 조회한다. + +Redis `INFO memory`로 실제 메모리와 eviction 정책을 확인한다. dedicated 모드는 `noeviction`, +shared 모드는 유한한 `maxmemory`가 있는 `volatile-lru`만 허용한다. 서버의 Redis 설정은 변경하지 않는다. +설정 ceiling과 Redis maxmemory 중 작은 값에서 headroom을 뺀 값이 입장 차단 기준이다. +세션 생성은 앞으로 저장할 예약 크기까지 더해서 검사한다. 기본 설정은 ceiling 128MiB, headroom 32MiB이므로 +전체 Redis 사용량 96MiB 전에 지도 요청을 제한한다. 인증용 데이터가 증가해도 같은 기준을 적용한다. +이 수치는 초기 보호 설정이며 Redis 전체 용량이나 정상 사용자 수를 보장하지 않는다. + +shared에서 세션 String은 TTL로 만료되거나 Redis에 의해 삭제될 수 있다. 조회가 사라지면 410으로 +새 조회를 안내한다. 기존 `requests`, `admission` Hash 두 개는 TTL 없이 유지하여 `volatile-lru`로 +요청 제한·용량 예약이 먼저 사라지지 않게 한다. 호출자 Hash는 현재 1분 창만 보관하고 최대 4096명, +예약 Hash는 최대 4096개이며 생성 시 만료 예약을 정리한다. 사라진 세션의 예약은 원래 만료까지 +보수적으로 남긴다. 필드마다 별도 키를 만들거나 인증 키를 SCAN하지 않는다. + +shared의 두 회계 키 중 하나가 없으면 두 키를 복구하고 **30분간 지도 입장·생성·연장을 차단**한다. +사라진 회계에 연결된 세션의 최장 수명 30분이 지나기 전에 예산을 0으로 오인하지 않기 위한 절차다. +최초 shared 활성화도 동일하게 30분 준비 시간이 필요하다. 아래 운영자 준비 명령으로 공개 기능을 +꺼 둔 상태에서 대기를 시작한다. 재시작해도 회계 키가 보존됐다면 이 대기는 생기지 않는다. +잘못된 TTL·값·키 타입은 덮어써서 복구하지 않고 503으로 막는다. 운영자가 원인을 확인한 다음 지도 +기능을 비활성화하고 정확한 지도 키만 정리해야 한다. 인증 키·서버 정책을 바꾸거나 전체 FLUSH를 하지 않는다. +영구 회계 키는 기능을 폐기할 때 별도로 정리해야 하며, 이를 지우면 복구 대기가 다시 시작된다. + +### 공유 Redis를 공개 전에 준비하기 + +`HASHI_RESTAURANT_MAP_SESSION_ENABLED=false`를 유지하고 저장소 루트에서 다음을 실행한다. +명령은 Redis에 접근 가능한 관리 환경의 Bash/redis-cli 예시다. 접속값은 비밀 환경 설정에서 주입한다. +비밀번호가 필요하면 `REDISCLI_AUTH` 환경변수를 사용하고 명령 인수나 터미널 기록에 직접 적지 않는다. +운영처럼 TLS가 필수인 환경은 `--tls`와 SNI를 유지한다. 로컬 비 TLS 검증에서만 두 옵션을 생략한다. + +```bash +redis-cli --tls --sni "$HASHI_RESTAURANT_MAP_REDIS_HOST" \ + -h "$HASHI_RESTAURANT_MAP_REDIS_HOST" -p "$HASHI_RESTAURANT_MAP_REDIS_PORT" \ + EVAL "$(cat src/main/resources/map/shared.lua) $(cat src/main/resources/map/prepare-shared.lua)" 2 \ + 'hashi:restaurant:map:{sessions-shared-v3}:requests' \ + 'hashi:restaurant:map:{sessions-shared-v3}:admission' 134217728 33554432 +``` + +ACL 사용자가 있으면 외부 설정의 사용자명으로 `--user`도 지정한다. 마지막 두 숫자는 앱과 동일한 +ceiling/headroom 초기값이므로 설정을 조정했다면 함께 맞춘다. 결과는 `[상태, 남은 대기 밀리초]`다. +`-6`은 준비 중, `0`은 준비 완료, `-5`는 정책/메모리 확인 필요, `-7`은 회계 손상/잘못된 키다. +30분 뒤 같은 명령으로 `0`을 확인한 후 mode=shared·연결·알림 설정을 확인하고 공개를 켠다. +이 명령은 대기 시각을 앞당기지 않으며 반복 실행해도 정상 회계나 기존 요청 수를 초기화하지 않는다. +준비를 생략하고 공개부터 켜면 첫 요청이 준비를 시작하고 최대 30분간 503을 반환한다. + +공유 Redis는 물리적으로 격리된 환경이 아니다. headroom 검사 이후 다른 요청이 메모리를 사용할 수 있고, +인증 키도 TTL이 있으면 eviction 대상이 될 수 있다. 지도 예산·차단 기준과 사전 알림을 함께 운영한다. + +공개 전에는 실제 후보 수·초당 신규 조회·p95·TTL 회수·인증 키 보존을 격리된 k6 환경에서 검증한다. +`:admission`의 `HLEN`, `MEMORY USAGE`, 전체 Redis used_memory를 함께 측정한다. local 합성 테스트가 +운영 트래픽을 보장하지 않는다. 운영 QPS/메모리 예산과 ingress 제한은 별도 확인한다. + +## 설정·오류와 운영 경계 + +`hashi.restaurant.map.session.enabled`는 기본 `false`이며 환경 변수 이름은 +`HASHI_RESTAURANT_MAP_SESSION_ENABLED`다. 키 준비와 공개 활성화는 별개다. +비활성화 상태에서는 유효한 키가 있어도 신규 조회·정렬 변경·다음 페이지 모두 DB/Redis 접근 전에 +RESTAURANT-014 / 503을 반환한다. 기존 입력 검증은 유지한다. + +`hashi.restaurant.map.session.signing-key`에 모든 서버가 공유하는 Base64 형식 32~64 byte 비밀값을 +설정한다. 환경 변수 이름은 `HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY`다. 키 기본값·랜덤 생성·로그 +출력은 없다. 활성화 상태에서 키 누락/형식 오류는 지도 세션만 503이며 부팅·관광 안내·기존 API는 유지된다. +이 경우에만 시작 시 고정 WARN을 한 번 남기고, 키 값·예외 원문·Throwable/cause는 기록하지 않는다. +비활성화 상태의 키 누락/오류와 활성화 상태의 유효한 키에는 WARN을 남기지 않는다. + +dev/prod compose의 기존 `env_file`은 이미 해당 환경변수를 전달할 수 있다. 명시적인 `environment` +매핑은 enabled 기본 `false`와 빈 키 기본값을 보여준다. 기존 배포처럼 `--env-file`로 동일 런타임 +파일을 Compose 변수 치환에도 사용해야 하며, [EC2 런타임 설정](../infra/dev-deploy.md#5-ec2-런타임-환경변수)을 따른다. +유효한 키와 `enabled=true`만으로 ingress·용량 검증이 완료되는 것은 아니다. +호출자 식별은 Servlet `remoteAddr`를 사용하며 원문 X-Forwarded-For를 직접 읽지 않는다. +현재 `forward-headers-strategy=native`이므로 ingress가 외부 Forwarded/XFF를 제거·재설정하고 +Tomcat이 신뢰하는 프록시 범위를 확인해야 한다. 이 경계가 없으면 IP 제한을 우회할 수 있다. +같은 NAT 사용자는 IP 한도를 공유하므로 실제 트래픽을 보고 조정한다. 애플리케이션 검사만으로 DDoS를 막지는 못한다. +키 교체 때 기존 cursor는 검증 실패한다. 회전 기간 복수 키 지원은 이 변경에 포함하지 않는다. + +커서는 최대 512자 계약 안에서 72자 Base64url 토큰이며 version/UUID/sort/후보 위치에 HMAC-SHA256을 +검증한다. 다음 페이지는 직전 정상 응답의 `nextCursor`를 그대로 사용하며 임의로 만들거나 수정하지 않는다. +전체 cursor·검색어·서명키를 오류나 로그에 넣지 않는다. Redis serializer/parser 예외의 +payload 포함 가능성 때문에 cause도 외부 로그에 전달하지 않는다. + +- RESTAURANT-013 / 410: 만료·유실·UUID 불일치·구버전/손상 세션. +- RESTAURANT-014 / 503: 지도 세션 비활성화·서명 설정 누락/오류·Redis 읽기/쓰기/연결 장애·메모리 보호 조건 미충족. +- RESTAURANT-015 / 503: DB 조회/transaction 장애. +- RESTAURANT-016 / 503: 인스턴스 동시 실행·후보 수·snapshot bytes·전체 예약·세션 수·호출자 cardinality·전역 호출 한도 초과. +- RESTAURANT-022 / 429: 호출자별 신규/전체 요청 한도 초과. 분 단위로 회복하므로 최대 60초 기다린 뒤 재시도한다. +- 기존 011/012/017/018은 #223 의미를 유지한다. + +### 지도 Redis 연결과 공유 운영 + +인증과 기존 캐시는 Spring Boot의 RedisConnectionFactory·RedisTemplate·CacheManager를 그대로 사용한다. +지도 조회는 `hashi.restaurant.map.redis` 설정의 별도 연결을 사용한다. 이번 운영 방향은 기존 ElastiCache를 +명시적으로 shared 모드로 재사용하는 것이다. 연결은 별개지만 같은 인스턴스의 메모리·장애는 공유한다. +후에 분리가 필요해지면 지도 endpoint와 mode를 바꾸며 기존 인증 배선은 유지한다. + +| 설정 | 기본값 / 의미 | +| --- | --- | +| `mode` | dedicated(기본값): noeviction 전용 / shared: 기존 volatile-lru ElastiCache 공유 | +| `host`, `port` | 호스트 기본값 없음, 포트 6379. 호스트가 없으면 인증 Redis로 대체하지 않고 지도만 503 | +| `ssl` | false. TLS가 필요한 서버는 true, 인증서 검증은 유지 | +| `username`, `password` | 기본값 없음. 해당 Redis에서 ACL/비밀번호를 사용하는 경우 입력 | +| `connect-timeout`, `timeout` | 연결 2초, 명령 3초. 각각 1ms~30s 범위 | + +환경 변수는 `HASHI_RESTAURANT_MAP_REDIS_MODE`, `..._HOST`, `..._PORT`, `..._SSL`, `..._USERNAME`, +`..._PASSWORD`, `..._CONNECTTIMEOUT`, `..._TIMEOUT`이다. 예시는 `.env.dev.example`에 있다. +enabled=true일 때 누락/잘못된 연결 설정은 값이나 예외 원문 없는 WARN을 남기며 앱 시작은 유지한다. +첫 유효 지도 요청 또는 활성 상태의 주기 관측에서 전용 연결과 제한된 Lettuce 자원을 생성하고, +앱 종료 때 함께 정리한다. +지도 factory/template은 Spring 빈으로 노출하지 않아 Boot의 기존 Redis 자동 구성을 끄지 않는다. +비활성 상태에서는 지도 연결과 추가 Lettuce 자원을 만들지 않는다. + +다음 구성은 dedicated 모드의 로컬 회귀 검증용이다. 운영 EC2에 컨테이너를 추가하는 계획은 보류한다. + +```shell +docker compose -p hashi-map-local -f docker/docker-compose.map-redis.local.yml up -d +# 로컬 앱: hashi.restaurant.map.redis.host=127.0.0.1, hashi.restaurant.map.redis.port=16379 +# 검증 후 이 프로젝트의 임시 컨테이너만 정리 +docker compose -p hashi-map-local -f docker/docker-compose.map-redis.local.yml down +``` + +이 파일은 루프백만 열며 컨테이너 한도 128MiB, Redis maxmemory 64MiB, noeviction을 사용한다. +세션 예약 16MiB와 headroom 32MiB 기본값을 유지하고, Lua는 설정 ceiling과 Redis maxmemory 중 +작은 값으로 검사한다. AOF/RDB는 사용하지 않으므로 재시작하면 기존 조회는 410으로 끝나고 새로 +조회해야 한다. 식당 원본과 인증 토큰은 다른 저장소에 남는다. 이 크기는 로컬 검증 시작값이며 운영 용량 보장이 아니다. +이 파일은 로컬 실험용이며 dev/prod compose에 Redis 컨테이너를 자동 추가하지 않는다. + +지도 연결 무응답/쓰기 거절은 해당 지도 요청을 503으로 처리한다. 다만 shared Redis 자체가 멈추면 +기존 인증도 같은 인스턴스를 사용하므로 영향을 받는다. 별도 연결이 장애 격리까지 보장하는 것은 아니다. +배포·공개 활성화·알림 규칙 등록은 이 변경만으로 실행되지 않는다. + +### 사전 알림과 재검토 기준 + +활성 상태에서 30초마다 집계 정보만 조회해 다음 Prometheus gauge를 제공한다. 원문 키·주소·사용자는 +태그로 넣지 않는다. 실패하면 probe=0과 NaN으로 바꿔 이전 수치가 정상 관측처럼 보이지 않게 한다. + +| 지표 | 의미 | +| --- | --- | +| `hashi_restaurant_map_redis_enabled` | 지도 세션 활성 상태 1, 비활성 0. 비활성 환경의 probe 알림 제외 | +| `hashi_restaurant_map_redis_used_bytes` | 해당 Redis 인스턴스 전체 사용 메모리 | +| `hashi_restaurant_map_redis_admission_limit_bytes` | ceiling/maxmemory 중 작은 값에서 headroom을 뺀 차단선 | +| `hashi_restaurant_map_redis_warning_limit_bytes` | 차단선의 75%, 기본 설정에서 72MiB | +| `hashi_restaurant_map_redis_evicted_keys` | 인스턴스 시작 이후 강제 삭제 수, 지도 전용 수치가 아님 | +| `hashi_restaurant_map_redis_probe_success` | 마지막 조회 성공 1, 비활성/실패 0 | + +기존 Grafana에서 환경별로 다음 규칙을 연결한다. 숫자는 보수적인 초기 운영값이며 실제 추이에 따라 조정한다. + +- 메모리가 warning_limit을 5분 넘으면 경고한다. 차단선보다 먼저 대응하기 위한 기준이다. +- 메모리가 admission_limit의 90%를 1분 넘거나 Evictions가 증가하면 우선 확인한다. +- 지도 활성 환경에서 probe_success=0이 2분 지속되면 연결·ACL을 확인한다. +- `hashi_restaurant_map_capacity_rejected_total`의 `redis_memory_guard`, `redis_recovery`, + `redis_accounting`을 구분한다. 복구 대기는 분당 최대 한 번 고정 WARN으로도 남긴다. + +재사용할 PromQL 규칙은 [map-redis-alerts.example.yml](../../docker/prometheus/rules/map-redis-alerts.example.yml)에 있다. +기존 Prometheus에는 자동 로드하지 않으므로 Grafana에서 식을 복사하고 환경별 notification policy를 선택한다. +기존 Discord contact point에 연결할 수 있다. 이 PR은 지표와 기준을 제공하며 실제 Grafana 규칙 생성이나 +AWS CloudWatch 알림 등록은 별도 운영 설정이다. ElastiCache 자체의 메모리·Evictions·CPU·FreeableMemory도 +함께 확인한다. 정상 이용이 지도 제한에 반복해서 막히거나 인증 지연이 측정되면 전용 자원/증설을 재검토한다. + +2026-10-11 운영 ElastiCache 사전 점검에서는 620개 후보의 합성 조회 데이터 256개를 저장·조회·TTL +갱신했고, 메모리는 약 6.57→9.16MiB였다. 강제 삭제는 발생하지 않았고 임시 키는 모두 정리했다. +이 결과는 추가 Redis 자원보다 기존 ElastiCache 재사용을 먼저 검증하기로 한 근거다. 소규모 저장 실험이며 +현재 공유 모드 제품 코드의 HTTP 처리량·최대 동시 사용자·장기 운영을 검증한 결과는 아니다. + +## 검증 구성 + +HTTP 통합 테스트는 restaurant 모듈에 실제 공통 응답/로그와 SecurityFilterChain을 포함한다. +MySQL 8.4에서 전체 Flyway 적용 후 validate, JVM UTC/JDBC Asia-Seoul, 실제 Redis 7.4.11을 사용한다. +OSIV를 테스트에서 끄지 않으며, 지도 경로의 request-bound EntityManager 부재와 Redis 저장 시 +실제 Hikari active connection 0개를 관측한다. 일반 목록에서는 기존 OSIV가 활성인 것도 확인한다. +모듈 테스트에서 제외되는 root Redis 설정은 같은 production factory 메서드로 연결한다. +미사용 이미지 backfill attachment 빈 하나만 제외하고 Restaurant Repository/Service/Port/세션 adapter는 실제다. +다른 모듈의 MediaPort, 외부 FileStorage, 이 공개 조회에서 사용하지 않는 OnboardingTokenStore는 +모듈 격리를 위해 대체한다. 지도 Repository/Service/Port/Redis 저장소를 mock으로 바꾸지는 않는다. +지역 필터 없는 BBOX 조회에서 실제 카드 SQL은 식당 1곳과 10곳 모두 7회, MediaPort bulk 호출 1회였다. +메뉴를 식당별로 조회하지 않는다. + +현재 검증 명령: + +```text +./gradlew.bat test --tests '*Map*' --tests '*ModularityTests' bootJar --no-daemon --max-workers=2 +``` + +고정 슬롯 제거, 500개 초과 전량 페이지 조회, lookahead, 같은 cursor 재시도, 실패 시 미연장, +절대 수명, 동시 TTL 갱신, 호출자/전체 용량 제한, 인증 키 보존을 검증한다. +`MapRedisIsolationIntegrationTest`는 실제 Boot 인증 배선과 메모리 제한이 있는 Redis 두 개로 +지도 연결 분리·무응답·재시작·쓰기 거절·TTL 회수를 검사한다. 지도 장애 중에도 실제 +RefreshTokenStore의 저장·토큰 회전이 성공하는지 확인한다. 이는 로그인 HTTP 전체 흐름이나 +EC2 운영 부하 검증과는 구분한다. +620개 합성 후보의 serializer 비교에서 기존 객체 배열은 UTF-8 31,188 bytes, compact tuple은 +7,646 bytes였다. ledger 예약식까지 적용하면 63,400 bytes에서 16,316 bytes로 74.3% 줄었다. +큰 Long ID·리뷰 수와 긴 조건을 넣은 실제 Redis 500개 snapshot은 UTF-8 24,250 bytes, +MEMORY USAGE 24,712 bytes였다. 이는 한 payload 측정이며 전체 운영 용량을 보장하지 않는다. +부하·지속 부하·회복은 #236의 격리된 HTTP/MySQL/Redis+k6 시나리오에서 별도로 확인한다. diff --git a/docs/map/map-query.md b/docs/map/map-query.md new file mode 100644 index 00000000..6e27c012 --- /dev/null +++ b/docs/map/map-query.md @@ -0,0 +1,127 @@ +# 지도 범위와 관광 지역 조회 + +#223은 [지도 계약 PR #221](https://github.com/TEAM-HASHI/HASHI-SERVER/pull/221)의 +`d2df3100fcfe693956662aae9ab1845275e163d5`를 기준으로 위치 모델을 읽는 기능을 제공한다. + +## 제공하는 기능 + +- `GET /api/v1/restaurants/map/regions`: 초기 범위, 지원 범위와 1도 제한, 활성 관광 지역의 대표 좌표·화면·식당 수. +- `GET /api/v1/restaurants/{restaurantId}/map-location`: 삭제되지 않은 식당의 현재 유효 위치. +- `RestaurantMapService.findCandidates(criteria, capacity)`: 후보 ID·평점·리뷰 수와 UTC `rankingAsOf`. + 검색어가 있으면 현재 화면 안의 전체 결과 수·좌표 경계·가장 이른 좌표 만료 시각도 함께 제공한다. +- `RestaurantMapService.findMatchingCandidates(criteria, ids)`: 같은 조건을 현재 DB에 다시 적용한 후보 값. +- `RestaurantPort.findActiveMapInfos(ids)`: 컬렉션에서 사용할 이름·분류·장르·선택적 위치. + +`GET /restaurants/map` 페이지 API, Redis 세션·추천 순서·정렬·cursor·10개 카드·이미지/가격대 조합은 +후속 [#227](https://github.com/TEAM-HASHI/HASHI-SERVER/issues/227)에서 구현한다. +기존 일반 식당 목록의 정렬·cursor와 검색 구현은 유지한다. 지도 검색은 최신 PLAN의 검색 입력 계약을 +별도로 적용하며, 두 API의 남은 차이는 아래에 명시한다. + +## 조회 조건과 데이터 + +모든 지도 SQL은 삭제 아님, 유효한 위경도 쌍, 출처·취득 시각·유효기한, +UTC `now < validUntil`을 함께 검사한다. 좌표 갱신 작업 상태는 조회 조건으로 사용하지 않는다. +기존에는 `READY`만 조회해 같은 주소의 갱신이 시작되면 핀이 사라졌다. 이제 갱신 대기·재시도·실패·확인 필요 +상태에서도 마지막으로 검증한 좌표가 아직 유효하면 후보, 지역 집계, 단건 위치와 컬렉션 Port에 포함한다. +주소 변경 시에는 좌표를 지우므로 이전 주소의 핀이 나오지 않는다. 만료 시각부터는 모든 지도 조회에서 제외하며, +컬렉션 Port는 식당을 삭제하지 않고 `location=null`로 반환한다. 일반 식당 목록은 좌표 유무와 관계없이 유지한다. +`(0, 0)`은 정상 좌표다. 관광 지역 미분류 식당도 일반 범위 조회에는 포함한다. +native SQL의 기준 시각은 UTC 문자열을 `DATETIME(6)`으로 명시 변환하고, 응답 시각도 UTC DATETIME +필드에서 읽는다. JVM과 MySQL 연결 시간대가 달라도 JDBC Timestamp의 시간대 변환에 의존하지 않는다. + +조회용 `MapQueryBounds`는 저장용 `MapBounds`와 별개다. SDK의 긴 소수점 입력을 반올림·절삭하지 않고 +MySQL DECIMAL 좌표와 비교한다. 경계는 포함하고 역전·날짜변경선 횡단·유효 범위·1도 초과·지원 영역을 검증한다. +`MapSearchCriteria.of`는 wire 분류 값과 양의 지역 ID, 검색어를 검증한다. 지도 검색어가 있으면 +Unicode 공백을 포함한 앞뒤 공백을 제거한 뒤 최대 30 코드포인트이며 개행·제어문자는 거절한다. +일반 검색어는 내부 Unicode 공백으로 단어를 나누고, 각 단어 중 하나라도 식당명·메뉴명·해시태그에 +대소문자 구분 없이 부분 일치하면 포함한다. 메뉴와 해시태그는 `EXISTS`로 검사해 한 식당을 한 번만 반환한다. +검색어가 `#`으로 시작하면 뒤의 공백 없는 단일 단어를 해시태그에서만 부분 검색한다. `#`만 있거나 +`#` 뒤에 공백 또는 여러 단어가 있는 입력은 400으로 거절한다. +미지정(null)은 두 API 모두 필터를 생략한다. 명시적 빈 문자열·공백만 있는 입력은 일반 목록에서 +필터를 생략하지만, 지도에서는 기존 `MAP-03` 계약대로 400으로 거절한다. +`!`를 SQL LIKE escape 문자로 지정해 `%`, `_`, `!`를 모두 리터럴로 검색한다. +현재 일반 `/restaurants` 목록은 앞뒤 공백을 제거한 전체 문구를 식당명·메뉴명에서만 검색한다. +여러 단어 OR, 해시태그, `#` 전용 검색과 30자 제한은 아직 일반 목록에 적용되지 않았다. 따라서 이 변경은 +지도 검색 계약만 충족하며, 최신 공통 검색 계약을 일반 목록까지 맞추는 작업은 정렬·cursor와 함께 별도 범위다. + +후보 조회는 한 SQL에서 순위 값만 가져온다. `capacity + 1`개를 SQL 상한으로 사용하고 초과하면 +`RESTAURANT-016`으로 전체 실패한다. 최종 후보 수·bytes·동시 세션 수용 한도는 후속 Redis 담당자가 측정해 정한다. +이 조회 계층에는 후보 500개 고정 상한이 없다. 아래 ID 배치의 500은 SQL 한 번에 넘기는 묶음 크기이며, +전체 결과를 자르는 제한이 아니다. #234는 기존 호출자의 500개 제한을 byte 예산 기반으로 바꾸고 조회 ID별 Redis 키, +유휴 5분·최대 30분 만료, 메모리 예산과 호출 제한을 적용한다. 상세 계약은 [페이지 조회](map-pagination.md)를 따른다. +`rankingAsOf`는 조회 직전에 캡처한 기준 시각이며 DB commit 시각을 뜻하지 않는다. + +검색어가 있는 새 조회는 후보 상한을 통과한 뒤 같은 read-only transaction과 `rankingAsOf`로 집계 SQL을 한 번 더 +실행한다. 후보 SQL과 집계 SQL은 삭제·좌표 수명·현재 화면 BBOX·지역·장르·음식점 분류·검색 predicate와 +parameter binding을 공유한다. 집계는 페이지 크기와 무관한 `totalCount`, 모든 결과 좌표의 min/max 경계, +`earliestValidUntil`을 반환한다. 0건은 `totalCount=0`, `bounds=null`, `earliestValidUntil=null`이고, +1건은 남북·동서가 같은 경계를 허용한다. 검색어가 없는 일반 탐색은 추가 집계 SQL을 실행하지 않는다. +후속 Redis 세션은 집계 경계를 좌표 수명보다 오래 보관하지 않도록 hard expiry를 `earliestValidUntil`으로 제한해야 한다. + +지역 집계는 한 SQL에서 지역 ID와 그 지역의 cameraBounds를 함께 적용한다. 활성 지역 0건도 포함하고 +displayOrder·ID 순으로 반환한다. 유효 위치가 cameraBounds 밖에 매핑된 지역은 count에서 그 식당을 제외하며, +`/map/regions`는 유효한 count와 다른 지역을 정상 반환하고 서버에 지역 ID·이상 건수만 기록한다. +이는 계약의 운영 검증을 공개 조회와 구분한 구현 해석이다. 주소 변경 후 지역 ID가 유지되는 경우에도 +전체 첫 화면이 막히지 않으며 운영 매핑 확인은 별도로 필요하다. 조회 중 매핑을 자동 수정하지 않는다. + +## 설정 + +운영 좌표와 지역 seed는 포함하지 않았다. 다음 설정 키의 `south`, `north`, `west`, `east`를 준비한다. + +현재 스택에는 관광 지역을 등록·활성화하고 식당의 소속을 지정하는 production 쓰기 경로가 없다. +`MapRegion`과 `Restaurant.assignMapRegion` 모델·조회만으로는 PLAN `MAP_MAIN` §4.1의 +초기 관광 지역 클러스터를 활성화할 수 없다. 후속 작업에서 승인된 실제 지역 데이터, +관리자 쓰기 경로의 권한·존재·활성·범위 검증, 식당 소속 지정과 운영 검증을 함께 제공해야 한다. +실제 지역 값 없이 가상 업무 seed나 공개 쓰기 API를 이 조회 변경에 추가하지 않는다. +후속 범위는 [#251](https://github.com/TEAM-HASHI/HASHI-SERVER/issues/251)에서 추적한다. + +| 설정 | 의미 | +| --- | --- | +| `hashi.restaurant.map.initial-bounds.*` | 최초 조회 화면. 각 span 1도 이하이며 지원 영역 안 | +| `hashi.restaurant.map.supported-bounds.*` | 지원 영역 전체. 각 조회 화면의 1도 제한과 구분 | + +문자열로 바인딩한 뒤 요청 시점에 파싱하므로 누락·형식 오류가 서버 부팅을 막지 않는다. +지역 안내는 설정 누락·비정상 값·활성 지역 없음·잘못된 지역 화면에서 503 `RESTAURANT-017`을 반환한다. +지역 필터는 없거나 비활성인 ID에 400 `RESTAURANT-012`를 반환한다. +선택 식당과 Port 조회는 초기 화면 설정 없이도 동작한다. + +## Port와 재검사 + +첫 입력 ID 순서를 유지하고 중복을 제거한다. null/빈 컬렉션은 쿼리 없이 빈 목록을 반환한다. +컬렉션 안의 null·0·음수 ID는 잘못된 내부 호출로 거절한다. 없는/삭제된 식당은 생략한다. +Port는 위치가 미준비·만료된 공개 식당을 `location=null`로 남기고, 유효하지 않은 좌표는 SQL JOIN에서 차단한다. + +ID 조회는 내부 500개 단위이며 모두 모은 뒤 반환한다. 중간 조회 실패는 부분 결과 없이 전파한다. +식당 수에 따라 엔티티·메뉴·사진·media 단건 조회가 늘어나지 않는다. 모듈 루트의 공개 record에는 +문자열 분류와 좌표/UTC 시각 값만 있고 domain enum·Entity·Repository는 없다. + +재검사에서 반환한 현재 평점·리뷰 수를 Redis의 고정 순위 값에 덮어쓰면 안 된다. 후속 호출자는 +재검사한 ID를 기존 세션 순서에 맞추고 탈락한 후보만 건너뛴다. 컬렉션 소유 모듈은 응답 직전 +자신의 열람 권한·멤버십 버전을 재검사하고, 클라이언트는 `validUntil`부터 핀을 폐기한다. + +## 오류와 검증 + +기존 SuccessResponse/ErrorResponse를 유지한다. 좌표 응답은 `Cache-Control: no-store`, data의 만료 시각은 UTC `Z`다. +선택 식당 ID의 양수 제약·정수 형식 위반은 공통 `GlobalExceptionHandler`에 위임해 +400 `COMMON-400`, `data: null`을 반환하고 현재 다른 API처럼 `errors`를 생략한다. +이 오류는 Controller 본문 실행 전 발생하므로 기존 Spring Security cache header writer가 +`Cache-Control: no-cache, no-store, max-age=0, must-revalidate`를 설정한다. +정상 응답과 본문 실행 이후 오류의 `no-store`는 Controller 또는 DB/transaction advice가 설정한다. +식당 삭제/없음은 404 `RESTAURANT-004`, 위치만 무효하면 409 `RESTAURANT-018`, DB 장애는 503 `RESTAURANT-015`다. +SecurityFilterChain과 기존 공개 경로 정책은 유지한다. + +DB 조회 실패와 Service 본문 밖 transaction 실패는 고정 operation `restaurant-map-query`와 +예외 클래스명만 WARN으로 남긴다. SQL·검색어·좌표·예외 메시지·cause·stack trace는 기록하지 않는다. + +- `RestaurantMapQueryIntegrationTest`: MySQL 8.4, Flyway 전체 migration 후 validate, 실제 후보·검색 결과 경계·Port 조회. +- `RestaurantMapControllerTest`: 실제 SecurityFilterChain, HTTP wrapper·오류·UTC 직렬화·no-store. +- `MapSearchCriteriaTest`, `MapQueryPropertiesTest`, `RestaurantMapServiceTest`: 입력·설정 격리·부분 실패. +- 기존 식당/관리자 회귀와 `ModularityTests`를 함께 검증한다. + +시간대 반례 검증에서는 명령 단위로 `JAVA_TOOL_OPTIONS=-Duser.timezone=UTC`를 지정한다. +지도 MySQL 테스트의 JDBC 연결은 `serverTimezone=Asia/Seoul`이며 SQL로 직접 저장한 UTC DATETIME도 검사한다. + +MySQL 모듈 테스트는 기존에 무조건 등록되는 이미지 backfill attachment 빈 하나만 테스트 설정에서 제외한다. +일반 지도 조회에서 migration 전용 Port를 모킹하거나 사용하지 않는다. +EXPLAIN은 테스트가 실행한 후보 SQL에 대해 합성 데이터로 기록한다. 작은 fixture의 접근 경로와 +쿼리 수는 운영 데이터 분포·처리 용량·응답 시간의 보장이 아니다. diff --git a/docs/map/monitoring/grafana-alerts.example.json b/docs/map/monitoring/grafana-alerts.example.json new file mode 100644 index 00000000..d30fd7a5 --- /dev/null +++ b/docs/map/monitoring/grafana-alerts.example.json @@ -0,0 +1,501 @@ +{ + "apiVersion": 1, + "groups": [ + { + "orgId": 1, + "name": "hashi-map-maintenance", + "folder": "Hashi", + "interval": "1m", + "rules": [ + { + "uid": "hashi-map-access-denied", + "title": "Hashi map access_denied", + "condition": "B", + "for": "0s", + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": true, + "labels": { + "service": "hashi-map", + "environment": "REPLACE_WITH_ENVIRONMENT", + "reason": "access_denied", + "severity": "critical" + }, + "annotations": { + "summary": "Location maintenance requires inspection: access_denied", + "description": "See docs/map/location-maintenance-runbook.md. Do not include addresses or API keys in alerts." + }, + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "prometheus", + "model": { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "max by (reason) (hashi_map_maintenance_issues{reason=\"access_denied\"})", + "instant": true, + "range": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "$A > 0", + "type": "math", + "refId": "B", + "intervalMs": 1000, + "maxDataPoints": 43200 + } + } + ] + }, + { + "uid": "hashi-map-configuration-error", + "title": "Hashi map configuration_error", + "condition": "B", + "for": "0s", + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": true, + "labels": { + "service": "hashi-map", + "environment": "REPLACE_WITH_ENVIRONMENT", + "reason": "configuration_error", + "severity": "critical" + }, + "annotations": { + "summary": "Location maintenance requires inspection: configuration_error", + "description": "See docs/map/location-maintenance-runbook.md. Do not include addresses or API keys in alerts." + }, + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "prometheus", + "model": { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "max by (reason) (hashi_map_maintenance_issues{reason=\"configuration_error\"})", + "instant": true, + "range": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "$A > 0", + "type": "math", + "refId": "B", + "intervalMs": 1000, + "maxDataPoints": 43200 + } + } + ] + }, + { + "uid": "hashi-map-refresh-window-invalid", + "title": "Hashi map refresh_window_invalid", + "condition": "B", + "for": "0s", + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": true, + "labels": { + "service": "hashi-map", + "environment": "REPLACE_WITH_ENVIRONMENT", + "reason": "refresh_window_invalid", + "severity": "critical" + }, + "annotations": { + "summary": "Location maintenance requires inspection: refresh_window_invalid", + "description": "See docs/map/location-maintenance-runbook.md. Do not include addresses or API keys in alerts." + }, + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "prometheus", + "model": { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "max by (reason) (hashi_map_maintenance_issues{reason=\"refresh_window_invalid\"})", + "instant": true, + "range": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "$A > 0", + "type": "math", + "refId": "B", + "intervalMs": 1000, + "maxDataPoints": 43200 + } + } + ] + }, + { + "uid": "hashi-map-retrying", + "title": "Hashi map retrying", + "condition": "B", + "for": "15m", + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": true, + "labels": { + "service": "hashi-map", + "environment": "REPLACE_WITH_ENVIRONMENT", + "reason": "retrying", + "severity": "warning" + }, + "annotations": { + "summary": "Location maintenance requires inspection: retrying", + "description": "See docs/map/location-maintenance-runbook.md. Do not include addresses or API keys in alerts." + }, + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "prometheus", + "model": { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "max by (reason) (hashi_map_maintenance_issues{reason=\"retrying\"})", + "instant": true, + "range": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "$A > 0", + "type": "math", + "refId": "B", + "intervalMs": 1000, + "maxDataPoints": 43200 + } + } + ] + }, + { + "uid": "hashi-map-attempts-exhausted", + "title": "Hashi map attempts_exhausted", + "condition": "B", + "for": "0s", + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": true, + "labels": { + "service": "hashi-map", + "environment": "REPLACE_WITH_ENVIRONMENT", + "reason": "attempts_exhausted", + "severity": "critical" + }, + "annotations": { + "summary": "Location maintenance requires inspection: attempts_exhausted", + "description": "See docs/map/location-maintenance-runbook.md. Do not include addresses or API keys in alerts." + }, + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "prometheus", + "model": { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "max by (reason) (hashi_map_maintenance_issues{reason=\"attempts_exhausted\"})", + "instant": true, + "range": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "$A > 0", + "type": "math", + "refId": "B", + "intervalMs": 1000, + "maxDataPoints": 43200 + } + } + ] + }, + { + "uid": "hashi-map-expiry-soon", + "title": "Hashi map expiry_soon", + "condition": "B", + "for": "0s", + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": true, + "labels": { + "service": "hashi-map", + "environment": "REPLACE_WITH_ENVIRONMENT", + "reason": "expiry_soon", + "severity": "critical" + }, + "annotations": { + "summary": "Location maintenance requires inspection: expiry_soon", + "description": "See docs/map/location-maintenance-runbook.md. Do not include addresses or API keys in alerts." + }, + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "prometheus", + "model": { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "max by (reason) (hashi_map_maintenance_issues{reason=\"expiry_soon\"})", + "instant": true, + "range": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "$A > 0", + "type": "math", + "refId": "B", + "intervalMs": 1000, + "maxDataPoints": 43200 + } + } + ] + }, + { + "uid": "hashi-map-stalled", + "title": "Hashi map stalled", + "condition": "B", + "for": "0s", + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": true, + "labels": { + "service": "hashi-map", + "environment": "REPLACE_WITH_ENVIRONMENT", + "reason": "stalled", + "severity": "warning" + }, + "annotations": { + "summary": "Location maintenance requires inspection: stalled", + "description": "See docs/map/location-maintenance-runbook.md. Do not include addresses or API keys in alerts." + }, + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "prometheus", + "model": { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "max by (reason) (hashi_map_maintenance_issues{reason=\"stalled\"})", + "instant": true, + "range": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "$A > 0", + "type": "math", + "refId": "B", + "intervalMs": 1000, + "maxDataPoints": 43200 + } + } + ] + }, + { + "uid": "hashi-map-observation-stale", + "title": "Hashi map observation_stale", + "condition": "B", + "for": "0s", + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": true, + "labels": { + "service": "hashi-map", + "environment": "REPLACE_WITH_ENVIRONMENT", + "reason": "observation_stale", + "severity": "warning" + }, + "annotations": { + "summary": "Location maintenance requires inspection: observation_stale", + "description": "See docs/map/location-maintenance-runbook.md. Do not include addresses or API keys in alerts." + }, + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "prometheus", + "model": { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "time() - max(hashi_map_maintenance_observed_timestamp)", + "instant": true, + "range": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "$A > 180", + "type": "math", + "refId": "B", + "intervalMs": 1000, + "maxDataPoints": 43200 + } + } + ] + } + ] + } + ] +} diff --git a/docs/map/place-review-proposal.md b/docs/map/place-review-proposal.md new file mode 100644 index 00000000..24891481 --- /dev/null +++ b/docs/map/place-review-proposal.md @@ -0,0 +1,63 @@ +# 시설 내부 식당의 장소 확인 + +관리자 후보 검색·선택과 선택한 장소의 갱신을 이번 지도 범위에 포함한다. +정확한 요청·응답과 설정은 [Places 선택 계약](./place-selection.md)을 따른다. 운영 활성화와 화면 연결은 별도다. + +## 필요한 이유 + +Geocoding은 주소의 위치를 찾는다. 역이나 쇼핑몰에서는 같은 주소에 여러 매장이 있어, +주소 조회가 성공해도 시설 대표 위치와 실제 매장 위치가 다를 수 있다. +2026-10-09 개발 프로젝트 실호출에서 도쿄역 이카루가는 약 208m 차이가 났다. +키와미야 도쿄역점은 약 11m, 마구로마트는 약 4m로 차이가 작았다. +따라서 모든 정상 식당을 Places로 다시 조회하기보다 확인이 필요한 식당에 집중한다. + +## 권장 사용자 흐름 + +1. 관리자가 위치 검토 목록이나 식당 수정 화면에서 대상 식당을 연다. +2. 서버가 식당명·현지명·지점명과 위치 확인용 주소로 후보를 찾는다. +3. 관리자가 매장명·주소·지도 위치를 비교해 같은 지점인지 확인한다. +4. 확정 요청에는 후보 응답의 `selectionToken`과 현재 주소 변경 번호를 보낸다. 클라이언트 좌표나 임의 장소 ID는 받지 않는다. +5. 서버가 Place Details를 다시 조회하고 검증한 뒤 좌표를 저장한다. + +자동 Geocoding 결과가 `READY`여도 시설 내부 매장은 검토할 수 있어야 한다. +서로 다른 주소가 함께 적혔거나 같은 이름의 지점이 여러 개면 첫 후보를 자동 확정하지 않는다. +관리자가 이전 핀이 잘못됐다고 확인해 교체를 요청하면 기존 핀을 숨긴다. +이후 같은 장소의 정기 갱신에서는 아직 유효한 좌표를 유지한다. + +## API와 저장 범위 + +| API | 역할 | +|---|---| +| `POST /api/v1/admin/restaurants/{id}/location/place-candidates` | 관리자 전용 소량 후보 검색. 호출 비용이 발생하므로 횟수·동시 실행을 제한 | +| `POST /api/v1/admin/restaurants/{id}/location/place-selection` | `expectedAddressRevision`, `selectionToken`으로 선택 요청. 새 작업 ID로 검증하며 이전 주소의 결과를 차단 | + +장소 ID와 `GOOGLE_PLACES` 출처, 필요한 제3자 출처 표기, 검색·상세 호출 예산을 저장한다. +후보 이름·주소·응답 원문을 식당 데이터로 복제해 쌓지 않는다. 기존 식당의 표시 주소는 유지한다. +Places 좌표를 관리자가 확인했다고 `ADMIN` 자체 좌표로 바꿔 저장하지 않는다. + +기존 작업 처리·재시도 구조를 재사용하며 다음 규칙을 따른다. + +- Places 좌표는 저장한 place ID로 Details를 재조회한다. 주소 Geocoding으로 덮어쓰지 않는다. +- 실제 위치 확인 주소가 바뀌면 이전 장소 ID와 좌표를 해제한다. +- 만료 정리 대상에 Places 출처를 포함하며 실패했다고 유효기간만 늘리지 않는다. +- 검색과 상세 조회의 호출 예산을 구분한다. 현재 Geocoding 예산에 조용히 섞지 않는다. +- 후보 선택 후 같은 주소에서 두 관리자가 다르게 확정하는 경우에도 새 요청 ID와 잠금으로 이전 결과를 차단한다. + +## 기획·운영과 맞출 항목 + +- 관리자 식당 수정 화면과 위치 확인 목록에서 후보 검색·선택으로 이동하는 위치 +- 후보 화면의 매장명·주소·지도·Google 표시와 선택 확인 동작 +- 실제 활성화 시점과 검색·상세 호출 예산, 삭제·백업 관리 +- 주소 수준의 위치와 매장 위치 중 서비스 공개 시 요구하는 정확도 + +권장안은 시설 내부 매장을 공개 전에 따로 점검하고, 확인된 Places 장소를 사용하는 것이다. +현재 개발 프로젝트의 Places SearchText/GetPlace 호출은 각각 일 100회·분 10회로 준비했고 실호출도 확인했다. +이 설정 확인을 제품 저장·갱신 흐름 검증 완료로 취급하지 않는다. + +공식 참고: +- [Text Search](https://developers.google.com/maps/documentation/places/web-service/text-search) +- [Place Details](https://developers.google.com/maps/documentation/places/web-service/place-details) +- [Places 표시·보관 정책](https://developers.google.com/maps/documentation/places/web-service/policies) +- [서비스별 약관](https://cloud.google.com/maps-platform/terms/maps-service-terms) + +Places 정책은 place ID의 장기 보관 예외를 명시한다. 좌표와 다른 응답 데이터까지 같은 예외라고 간주하지 않는다. diff --git a/docs/map/place-selection.md b/docs/map/place-selection.md new file mode 100644 index 00000000..782d19d1 --- /dev/null +++ b/docs/map/place-selection.md @@ -0,0 +1,117 @@ +# 관리자 Places 후보 선택 (#224) + +주소 Geocoding만으로 식당 시설을 확정하기 어려운 `REVIEW_REQUIRED`와 잘못 채택된 `READY` 위치를 +관리자가 Google Places 후보로 교정하는 계약이다. 후보 검색과 선택은 기존 ADMIN 권한을 사용하며, +클라이언트가 검색어, Place ID 또는 저장 좌표를 직접 지정하지 않는다. + +## 등록 전 미리보기 + +`POST /api/v1/admin/restaurants/location-preview`는 식당을 저장하지 않고 Places 후보를 반환한다. +`name`, `localName`, `address`, 선택적인 `geocodingAddress`를 보낸다. 검색에는 현지명과 기본 주소를 +사용하며 기본 주소가 없으면 표시 주소를 사용한다. 기존 SEARCH 예산·후보 검증을 그대로 적용한다. +후보를 고른 뒤 `selectionToken`을 식당 등록 요청의 `locationSelectionToken`으로 보낸다. + +- 네 입력과 인증 관리자를 서명으로 묶는다. 검색 후 입력을 바꾸거나 관리자가 달라지면 409다. +- 변조·만료 토큰은 400이다. 실패하면 식당과 위치 작업을 모두 저장하지 않는다. +- 등록 전후 토큰은 서로 사용할 수 없다. 원문 Place ID나 좌표를 직접 보내서 저장할 수 없다. +- 토큰을 생략하면 기존 등록처럼 식당을 먼저 저장하고 주소로 자동 확인한다. +- 토큰이 있으면 식당과 선택 장소의 `PLACE_DETAILS` 작업을 같은 transaction에 저장한다. + 등록 transaction에서 Google을 호출하지 않는다. 이후 worker가 상세 확인을 완료해야 핀이 보인다. +- 미리보기 결과를 클라이언트 영구 저장소에 저장하지 않는다. Google 지도 및 필요한 attribution을 표시한다. + +## API와 상태 전이 + +- `POST /api/v1/admin/restaurants/{restaurantId}/location/place-candidates` + - body: `{"expectedAddressRevision":2}` + - 서버가 저장된 현지 식당명과 `geocodingAddress`(없으면 `address`)로 검색한다. + - `READY`, `REVIEW_REQUIRED`, `FAILED`, `RETRY_WAIT`에서만 허용한다. `PENDING`, 주소 revision 변경, + 현재 request 변경은 409다. + - 결과 없음과 도쿄 범위를 통과한 후보 없음은 200과 빈 `candidates`다. provider 장애는 빈 결과로 바꾸지 않는다. + - 후보는 표시명·주소·좌표·국가·행정구역·유형·영업 상태·attribution·Google Maps URI와 + `selectionToken`, `selectionExpiresAt`을 반환한다. 원본 Place ID는 응답 필드로 내리지 않는다. +- `POST /api/v1/admin/restaurants/{restaurantId}/location/place-selection` + - body: `{"expectedAddressRevision":2,"selectionToken":"..."}` + - 성공은 200과 `PENDING`이다. 저장 성공일 뿐 Places Details 완료를 뜻하지 않는다. + - token 변조·만료는 400, token의 관리자/restaurant/revision/request와 현재 값 불일치 또는 `PENDING`은 409다. + - 상태 조회의 `verificationMode=PLACE_DETAILS`와 이후 `READY`, `validUntil`을 확인한다. + +검색 후보는 `countryCode=JP`, 행정구역 `Tokyo` 또는 `東京都`, 설정된 지도 BBOX 안의 좌표를 모두 +통과해야 token을 받는다. Details 결과도 같은 조건과 token의 Place ID 일치를 다시 검사한다. +Places Details에는 Geocoding의 `ROOFTOP` 개념을 적용하지 않는다. +Details 성공 시 제3자 attribution의 표시명과 HTTPS URI를 좌표와 같은 수명으로 저장한다. 후보 표시명·주소와 +달리 공개 지도·컬렉션에서 좌표와 함께 전달해야 하는 최소 metadata다. + +## 후보 결합과 저장 범위 + +`selectionToken`은 HMAC-SHA256으로 인증 관리자, restaurantId, addressRevision, 현재 requestId, Place ID, 발급·만료 시각을 +묶으며 유효 시간은 10분이다. 등록 전 토큰에는 식당 ID 대신 네 입력의 SHA-256을 넣는다. +토큰 형식 변경 후 이전 토큰은 400으로 거절하므로 다시 검색해야 한다. 키는 후보 DB 테이블을 만들지 않고도 검색 결과를 현재 위치 상태에 결합하기 +위해 필요하다. 검색 응답의 표시명·주소·유형·attribution은 즉시 응답에만 사용하고 DB에 저장하지 않는다. + +선택 시 새 requestId와 `PLACE_DETAILS` job을 만들고 job에만 선택 Place ID를 보관한다. 잘못된 핀을 +교정하는 동작이므로 수동으로 다른 후보를 선택하면 이전 승인 좌표·source·location Place ID를 즉시 지운다. +Details가 성공하면 좌표, `source=GOOGLE_PLACES`, Place ID, 취득 시각과 30일 유효 기한을 한 번에 저장한다. + +같은 Place ID의 정기 갱신은 수동 교체와 다르다. 아직 유효한 좌표를 유지한 채 Details job을 만들고, +새 결과가 검증된 뒤 교체한다. 만료 정리가 accepted location의 Place ID를 비워도 현재 request의 job에 +`operation=PLACE_DETAILS`와 Place ID가 남으므로 재시도는 Geocoding으로 바뀌지 않는다. job이 없거나 +operation과 Place ID가 모순되면 자동 fallback하지 않고 충돌로 끝낸다. +주소 변경·수동 후보 교체·만료 제거는 좌표와 attribution metadata를 함께 지운다. + +## 예산과 실행 + +`restaurant_places_budget`은 `SEARCH`, `DETAILS` 두 행을 별도로 잠근다. 각 행은 `enabled`, UTC 일일 한도, +UTC 분당 한도, 사용량과 `blocked_until`을 가진다. 초기값은 두 행 모두 disabled, 일 100, 분 10이다. +행 누락, disabled, 0 이하 한도, 일·분 한도 소진, 공유 차단 시간은 fail closed다. + +restaurant와 job의 현재 tuple을 먼저 확인하고 operation budget을 잠근 뒤 실제 provider 호출 전에 사용량을 +증가시킨다. timeout, 알 수 없는 전송 결과, 빈 결과, provider 오류, 호출 중 주소 변경도 환불하지 않는다. +SEARCH 소진은 DETAILS를, DETAILS 소진은 Geocoding을 막지 않는다. 기존 Geocoding 동시 실행 카운트는 +`operation=GEOCODING` job만 센다. worker는 두 operation 후보를 교차 처리한다. + +## 설정과 활성화 + +- `hashi.map.google-places.enabled=false`가 기본이며 API key, timeout, 응답 크기 제한을 별도로 설정한다. +- `hashi.map.places-selection.enabled=false`가 기본이다. +- `hashi.map.places-selection.signing-key`는 Base64 디코딩 후 32바이트 이상인 전용 비밀키여야 한다. +- `hashi.map.places-selection.token-ttl`은 10분이어야 한다. +- provider, 서명 또는 지도 bounds 설정이 없으면 SEARCH 예산을 쓰기 전에 503으로 fail closed한다. +- 애플리케이션 설정을 켜도 DB의 SEARCH/DETAILS 행은 자동 활성화하지 않는다. + +Docker 환경 변수는 `HASHI_MAP_GOOGLE_PLACES_ENABLED`, `HASHI_MAP_GOOGLE_PLACES_API_KEY`, +`HASHI_MAP_PLACES_SELECTION_ENABLED`, `HASHI_MAP_PLACES_SELECTION_SIGNING_KEY`, +`HASHI_MAP_PLACES_SELECTION_TOKEN_TTL`로 전달한다. 기존 Google Geocoding key에 Places API (New) 권한도 +설정했다면 같은 비밀값을 Places API key 환경 변수에 별도로 주입할 수 있다. selection 서명키에는 +provider API key를 재사용하지 않는다. + +키, selectionToken, Place ID, 검색어, provider 원문과 전체 요청 URI는 로그에 남기지 않는다. + +## 변경 전후 + +| 상황 | 변경 전 | 변경 후 | +|---|---|---| +| 주소 Geocoding이 시설을 특정하지 못함 | REVIEW_REQUIRED에서 주소 재시도만 가능 | 저장 정보 기반 Places 후보 검색 후 관리자 선택 가능 | +| 잘못된 READY 핀 교정 | READY 일반 retry 불가 | 후보 선택 즉시 기존 좌표 제거 후 Details 검증 | +| Places 재시도 | 없음 | current job의 operation과 Place ID를 그대로 복제 | +| provider 예산 | Geocoding 단일 일일·동시 한도 | SEARCH·DETAILS 일일/분당 예산을 서로 분리 | +| provider 콘텐츠 저장 | Geocoding 좌표와 안전 코드만 저장 | 성공 좌표·Place ID·필수 attribution만 저장하고 후보명·주소는 저장하지 않음 | + +## 위치 확인 기록과 변경 + +좌표 제공자인 `source`와 확인 방식인 `confirmationMethod`는 별개다. 관리자 선택 직후 `source`는 null이고, +Places Details 검증에 성공해 좌표를 저장하면 `GOOGLE_PLACES`가 된다. 관리자 확인으로 자체 좌표로 바뀌지는 않는다. +`confirmationMethod=ADMIN_PLACE_SELECTION`, `confirmedByAdminId`(인증 관리자 ID), +`confirmedAt`(선택 접수 시각), `confirmedRevision`(당시 revision)을 기록한다. +접수 시각은 Google 좌표 취득 완료 시각과 다르다. +현재 위치 상태 조회에서 이 값과 `locationStatus`를 함께 확인한다. PENDING이면 지도에 새 핀을 표시하지 않는다. +관리자 응답의 `confirmedByAdminLoginId`는 `confirmedByAdminId`에 해당하는 계정의 현재 로그인 ID다. +계정을 찾지 못하면 null이며 확인 당시 로그인 ID를 복원하는 값은 아니다. 이 표시값은 요청으로 받거나 +위치 테이블에 중복 저장하지 않는다. 일반 사용자에게 공개하는 식당 응답에는 포함하지 않는다. + +Geocoding의 자동 채택은 `AUTOMATIC`으로 기록한다. 기존 데이터에서 알 수 없는 관리자나 확인 시각을 +채우지 않으므로 과거 기록은 null일 수 있다. 같은 매장의 정기 갱신은 관리자 선택 기록을 보존한다. +이름·현지명·표시 주소·위치 확인 입력이 달라지면 기존 확인을 지우고 revision을 올려 다시 확인한다. + +지도에서 직접 옮긴 핀을 저장하는 API는 이번 계약에 포함하지 않는다. Google 결과를 확인·보정한 좌표가 +독립적으로 확보한 자체 데이터가 되는 것은 아니므로, 해당 좌표의 보관·갱신 정책을 확인한 뒤 추가한다. +Places 선택은 Place ID로 갱신하고, 재조회 없이 validUntil만 늘리지 않는다. diff --git a/scripts/load/map-query.js b/scripts/load/map-query.js new file mode 100644 index 00000000..27539903 --- /dev/null +++ b/scripts/load/map-query.js @@ -0,0 +1,123 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; + +// This script is launched by MapQueryLoadTest against its disposable loopback server only. +const base = __ENV.BASE_URL; +if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(base || '')) { + throw new Error('BASE_URL must identify the local test server'); +} +const profile = __ENV.MAP_LOAD_PROFILE || 'smoke'; +if (!['smoke', 'normal', 'staged', 'ttl'].includes(profile)) throw new Error('Invalid load profile'); +const traffic = __ENV.MAP_LOAD_TRAFFIC || 'shared'; +if (!['shared', 'distinct'].includes(traffic)) throw new Error('Invalid load traffic'); +const rejected429 = new Counter('map_rejected_429'); +const rejected503 = new Counter('map_rejected_503'); +const responseCodes = new Counter('map_response_codes'); +const errorCodes = Object.fromEntries(['013', '014', '015', '016', '017', '022'] + .map(code => [`RESTAURANT-${code}`, new Counter(`map_error_RESTAURANT_${code}`)])); +const unknownErrors = new Counter('map_error_unknown'); +const newQueryMs = new Trend('map_new_query_ms', true); +const nextPageMs = new Trend('map_next_page_ms', true); +const successfulOperationMs = { + new: new Trend('map_success_new_query_ms', true), + next: new Trend('map_success_next_page_ms', true), +}; +const sortMs = new Trend('map_sort_ms', true); +const filterMs = new Trend('map_filter_ms', true); +const semanticFailures = new Rate('map_semantic_failures'); + +export const options = { + scenarios: { + browsing: { + executor: 'ramping-vus', startVUs: 0, + stages: profile === 'smoke' + ? [{ duration: '15s', target: 2 }, { duration: '60s', target: 2 }, { duration: '15s', target: 0 }] + : [{ duration: '30s', target: 5 }, { duration: '60s', target: 5 }, + { duration: '30s', target: 10 }, { duration: '60s', target: 10 }, + { duration: '30s', target: 20 }, { duration: '60s', target: 20 }, { duration: '30s', target: 0 }], gracefulRampDown: '5s', + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], checks: ['rate>0.99'], + map_semantic_failures: ['rate==0'], + map_new_query_ms: ['p(95)<3000'], map_next_page_ms: ['p(95)<3000'], + map_success_new_query_ms: ['p(95)<3000'], map_success_next_page_ms: ['p(95)<3000'], + map_sort_ms: ['p(95)<3000'], map_filter_ms: ['p(95)<3000'], + }, +}; + +function page(query, operation, metric) { + const requestOptions = { + tags: { name: `map_${operation}`, operation, traffic }, timeout: '10s', + }; + if (traffic === 'distinct') { + // The Java harness accepts this only through its explicitly trusted loopback proxy. + const offset = __VU - 1; + requestOptions.headers = { + 'X-Forwarded-For': `198.18.${Math.floor(offset / 254)}.${(offset % 254) + 1}`, + }; + } + const response = http.get(`${base}/api/v1/restaurants/map?${query}`, requestOptions); + metric.add(response.timings.duration); + responseCodes.add(1, {status: String(response.status), operation}); + if (response.status === 429) rejected429.add(1); + if (response.status === 503) rejected503.add(1); + if (!check(response, { 'map response succeeds': r => r.status === 200 })) { + let code; + try { code = response.json('code'); } catch (_) { /* malformed failure body */ } + (errorCodes[code] || unknownErrors).add(1); + semanticFailures.add(true); + return null; + } + successfulOperationMs[operation]?.add(response.timings.duration); + let data; + try { + data = response.json('data'); + } catch (_) { + recordFailure('map response contains valid JSON'); + return null; + } + const valid = Boolean(data && Array.isArray(data.content) && data.content.length === 10 + && data.content.every(item => item !== null && typeof item === 'object' + && Number.isSafeInteger(item.restaurantId) && item.restaurantId > 0) + && data.hasNext === true + && typeof data.nextCursor === 'string' && data.nextCursor.trim().length > 0 + && typeof data.querySessionId === 'string' && data.querySessionId.trim().length > 0); + check(valid, { 'page has ten cards and a next page': value => value }); + semanticFailures.add(!valid); + return valid ? data : null; +} + +function recordFailure(name) { + check(false, { [name]: value => value }); + semanticFailures.add(true); +} + +export default function () { + try { + const bounds = 'south=10&north=11&west=20&east=21'; + const first = page(bounds, 'new', newQueryMs); + if (!first) return; + const second = page(`cursor=${encodeURIComponent(first.nextCursor)}`, 'next', nextPageMs); + const replay = page(`cursor=${encodeURIComponent(first.nextCursor)}`, 'next', nextPageMs); + const sorted = page(`querySessionId=${encodeURIComponent(first.querySessionId)}&sort=rating`, 'sort', sortMs); + page(`${bounds}&genre=sushi`, 'filter', filterMs); + if (second && replay && sorted) { + const ids = first.content.map(item => item.restaurantId); + const nextIds = second.content.map(item => item.restaurantId); + const valid = new Set([...ids, ...nextIds]).size === 20 + && JSON.stringify(nextIds) === JSON.stringify(replay.content.map(item => item.restaurantId)) + && JSON.stringify(ids) === JSON.stringify(sorted.content.map(item => item.restaurantId)); + check(valid, { 'no duplicates, cursor replay and tied sort retain order': value => value }); + semanticFailures.add(!valid); + } + } catch (_) { + // k6 does not fail its process for an iteration exception unless a threshold records it. + recordFailure('map iteration completes without an unexpected exception'); + } finally { + // 5 requests and 2 new queries per iteration: paced callers stay below production budgets. + const pacing = 12 + (__VU % 5) * 0.25; + sleep(profile === 'normal' || profile === 'ttl' ? pacing : 2); + } +} diff --git a/scripts/load/map-query.test.cjs b/scripts/load/map-query.test.cjs new file mode 100644 index 00000000..a9511420 --- /dev/null +++ b/scripts/load/map-query.test.cjs @@ -0,0 +1,68 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const sourcePath = process.argv[2] || require('node:path').join(__dirname, 'map-query.js'); +const source = fs.readFileSync(sourcePath, 'utf8').replace(/^import .*;\r?\n/gm, '') + .replace('export const options', 'const options') + .replace('export default function ()', 'function runIteration()') + '\nrunIteration();'; +function valid(start = 1) { + return { content: Array.from({ length: 10 }, (_, i) => ({ restaurantId: start + i })), + hasNext: true, nextCursor: 'cursor', querySessionId: 'session' }; +} +function run(name, response, expectedFailure) { + const failures = [], checks = []; + let calls = 0, sleeps = 0; + vm.runInNewContext(source, { + __ENV: { BASE_URL: 'http://127.0.0.1:12345' }, + __VU: 1, + http: { get() { return response(calls++); } }, + check(value, rules) { + const passed = Object.values(rules).every(rule => rule(value)); checks.push(passed); return passed; + }, + sleep() { sleeps++; }, + Counter: class { add() {} }, + Trend: class { add() {} }, + Rate: class { add(value) { failures.push(value); } }, + }); + assert.equal(failures.includes(true), expectedFailure, name + ' failure metric'); + assert.equal(checks.includes(false), expectedFailure, name + ' failed check'); + assert.equal(sleeps, 1, name + ' bounded request pacing'); + console.log('PASS ' + name); +} +function response(data) { return { status: 200, timings: { duration: 1 }, json: () => data }; } +run('valid mixed iteration', call => response(valid(call === 1 || call === 2 ? 11 : 1)), false); +for (const [name, data] of [ + ['ten null cards', { ...valid(), content: Array(10).fill(null) }], + ['array-like content', { ...valid(), content: { length: 10 } }], + ['null data', null], + ['missing cursor', { ...valid(), nextCursor: undefined }], + ['missing session', { ...valid(), querySessionId: undefined }], + ['blank cursor', { ...valid(), nextCursor: ' ' }], + ['truthy non-boolean hasNext', { ...valid(), hasNext: 'true' }], + ['string ID', { ...valid(), content: Array(10).fill({ restaurantId: '1' }) }], + ['nonpositive ID', { ...valid(), content: Array(10).fill({ restaurantId: 0 }) }], + ['unsafe integer ID', { ...valid(), content: Array(10).fill({ restaurantId: Number.MAX_SAFE_INTEGER + 1 }) }], +]) run(name, () => response(data), true); +run('invalid JSON', () => ({ status: 200, timings: { duration: 1 }, json() { throw new SyntaxError(); } }), true); +run('unexpected request exception', () => { throw new TypeError(); }, true); +run('HTTP failure', () => ({ status: 503, timings: { duration: 1 } }), true); +run('HTTP caller throttled', () => ({ status: 429, timings: { duration: 1 } }), true); +const configure = fs.readFileSync(sourcePath, 'utf8').replace(/^import .*;\r?\n/gm, '') + .replace('export const options', 'globalThis.options') + .replace('export default function ()', 'function runIteration()'); +for (const profile of ['smoke', 'staged', 'ttl']) { + const context = { __ENV: { BASE_URL: 'http://127.0.0.1:12345', MAP_LOAD_PROFILE: profile }, + Trend: class {}, Rate: class {}, Counter: class {} }; + vm.runInNewContext(configure, context); + const stages = context.options.scenarios.browsing.stages; + assert.equal(stages.reduce((seconds, stage) => seconds + parseInt(stage.duration), 0), profile === 'smoke' ? 90 : 300); + assert.equal(Math.max(...stages.map(stage => stage.target)), profile === 'smoke' ? 2 : 20); + assert.equal(context.options.thresholds.http_req_failed[0], 'rate<0.01'); + assert.equal(context.options.thresholds.map_success_new_query_ms[0], 'p(95)<3000'); + assert.equal(context.options.thresholds.map_success_next_page_ms[0], 'p(95)<3000'); +} +for (const env of [{ BASE_URL: 'https://production.example' }, + { BASE_URL: 'http://127.0.0.1:12345', MAP_LOAD_PROFILE: 'unbounded' }]) { + assert.throws(() => vm.runInNewContext(configure, {__ENV: env})); +} +console.log('15 response cases + 3 bounded profiles + 2 configuration rejections passed'); diff --git a/src/main/java/org/sopt/hashi/admin/dto/AdminRestaurantResponse.java b/src/main/java/org/sopt/hashi/admin/dto/AdminRestaurantResponse.java index a7787a37..4b1a8ae2 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/AdminRestaurantResponse.java +++ b/src/main/java/org/sopt/hashi/admin/dto/AdminRestaurantResponse.java @@ -1,5 +1,6 @@ package org.sopt.hashi.admin.dto; +import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; @@ -12,13 +13,17 @@ * 신규 이미지 필드는 상태와 반응형 후보를 전달한다. genre·curationTypes는 사용자 API와 같은 * 소문자 케밥 값이고, placeType(음식점 분류, #211)은 restaurant·cafe·bar다. */ +@Schema(description = "어드민 식당 저장 결과. 저장 성공과 Google 위치 확인 완료는 별개이며 locationStatus로 후속 상태를 확인") public record AdminRestaurantResponse( Long restaurantId, String name, String localName, String summary, String description, + @Schema(description = "사용자 화면에 표시할 전체 주소") String address, + @Schema(description = "Google 위치 확인에만 쓰는 별도 지정 주소. null이면 address를 위치 확인 기준으로 사용") + String geocodingAddress, String area, String genre, String foodCategory, @@ -35,7 +40,14 @@ public record AdminRestaurantResponse( List hashtags, List curationTypes, List businessHours, - LocalDateTime createdAt) { + LocalDateTime createdAt, + @Schema(description = "비동기 위치 처리 상태. PENDING 응답은 식당 저장과 작업 등록 성공이며 Google 위치 확인 완료가 아님", + allowableValues = {"UNRESOLVED", "PENDING", "READY", "RETRY_WAIT", "REVIEW_REQUIRED", "FAILED"}, + example = "PENDING") + String locationStatus, + @Schema(description = "위치 확인 기준 정보 변경 번호(addressRevision). 이름·현지명·표시주소·위치조회주소 변경에 따라 증가. 위치 재시도 전 상태 조회 API에서 최신 값을 확인", + example = "1") + long addressRevision) { public static AdminRestaurantResponse from(AdminRestaurantInfo info) { return new AdminRestaurantResponse( @@ -45,6 +57,7 @@ public static AdminRestaurantResponse from(AdminRestaurantInfo info) { info.summary(), info.description(), info.address(), + info.geocodingAddress(), info.area(), info.genre(), info.foodCategory(), @@ -65,7 +78,9 @@ public static AdminRestaurantResponse from(AdminRestaurantInfo info) { info.businessHours().stream() .map(AdminRestaurantBusinessHourResponse::from) .toList(), - info.createdAt()); + info.createdAt(), + info.locationStatus(), + info.addressRevision()); } public record AdminRestaurantMenuResponse( diff --git a/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java b/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java index e7daf723..c299e6bf 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java +++ b/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java @@ -36,9 +36,15 @@ public record CreateRestaurantRequest( @Schema(description = "매장 상세 설명", example = "엄선된 고기와 다양한 코스를 제공합니다.") @NotBlank(message = "상세 설명은 필수입니다") @Size(max = 500, message = "상세 설명은 500자 이내입니다") String description, - @Schema(description = "주소", example = "도쿄도 도시마구 히가시이케부쿠로 1-1-1") + @Schema(description = "사용자 화면에 표시할 전체 주소. 건물명·층 정보를 보존하며, geocodingAddress를 생략하면 Google 위치 확인에도 이 값을 사용", + example = "도쿄도 도시마구 히가시이케부쿠로 1-1-1") @NotBlank(message = "주소는 필수입니다") @Size(max = 255, message = "주소는 255자 이내입니다") String address, + @Schema(description = "Google 위치 확인에만 사용할 별도 지정 주소(선택). 화면에는 address의 건물명·층 정보를 그대로 보여주면서 검색에는 정제한 주소를 쓰기 위한 필드. 생략/null이면 address를 사용하고 공백은 허용하지 않음", + example = "東京都豊島区東池袋1-1-1") + @Pattern(regexp = "(?s).*[^\\p{javaWhitespace}\\p{Z}].*", + message = "위치 확인용 주소는 공백일 수 없습니다") + @Size(max = 255, message = "위치 확인용 주소는 255자 이내입니다") String geocodingAddress, @Schema(description = "지역", example = "이케부쿠로") @NotBlank(message = "지역은 필수입니다") @Size(max = 20, message = "지역은 20자 이내입니다") String area, @@ -78,7 +84,36 @@ public record CreateRestaurantRequest( List<@NotBlank(message = "큐레이션 유형은 비어 있을 수 없습니다") String> curationTypes, @NotNull(message = "영업시간은 필수입니다") @Size(min = 7, max = 7, message = "영업시간은 모든 요일(7개)을 포함해야 합니다") - List<@NotNull(message = "영업시간 항목은 null일 수 없습니다") @Valid BusinessHourRequest> businessHours) { + List<@NotNull(message = "영업시간 항목은 null일 수 없습니다") @Valid BusinessHourRequest> businessHours, + @Schema(description = "등록 전 위치 미리보기에서 관리자가 고른 후보의 selectionToken(선택). " + + "같은 관리자가 검색 후 10분 안에 이름·현지명·표시주소·기본주소를 그대로 보내야 함. " + + "생략/null이면 식당을 먼저 저장한 뒤 주소로 자동 위치 확인. " + + "토큰을 보냈더라도 저장 직후는 PENDING이며 Places 상세 검증이 끝나야 핀 노출") + @Pattern(regexp = ".*\\S.*", message = "위치 선택 토큰은 공백일 수 없습니다") + @Size(max = 2048) String locationSelectionToken) { + + public CreateRestaurantRequest( + String name, String localName, String summary, String description, String address, + String geocodingAddress, String area, String genre, String foodCategory, String placeType, + String priceCurrency, BigDecimal minPrice, BigDecimal maxPrice, List imageKeys, + List imageAssetIds, JsonNode unsupportedImages, List menus, + List hashtags, List curationTypes, List businessHours) { + this(name, localName, summary, description, address, geocodingAddress, area, genre, foodCategory, + placeType, priceCurrency, minPrice, maxPrice, imageKeys, imageAssetIds, unsupportedImages, + menus, hashtags, curationTypes, businessHours, null); + } + + /** geocodingAddress 도입 전 Java 호출부의 source compatibility를 유지한다. */ + public CreateRestaurantRequest( + String name, String localName, String summary, String description, String address, + String area, String genre, String foodCategory, String placeType, String priceCurrency, + BigDecimal minPrice, BigDecimal maxPrice, List imageKeys, List imageAssetIds, + JsonNode unsupportedImages, List menus, List hashtags, + List curationTypes, List businessHours) { + this(name, localName, summary, description, address, null, area, genre, foodCategory, placeType, + priceCurrency, minPrice, maxPrice, imageKeys, imageAssetIds, unsupportedImages, menus, + hashtags, curationTypes, businessHours); + } @AssertTrue(message = "식당 이미지는 imageKeys 또는 imageAssetIds 중 하나만 필요합니다") @JsonIgnore diff --git a/src/main/java/org/sopt/hashi/admin/dto/PreviewRestaurantLocationRequest.java b/src/main/java/org/sopt/hashi/admin/dto/PreviewRestaurantLocationRequest.java new file mode 100644 index 00000000..cb7e7698 --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/PreviewRestaurantLocationRequest.java @@ -0,0 +1,22 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.sopt.hashi.restaurant.RestaurantLocationPreviewCommand; + +@Schema(description = "등록 전에 Google Places에서 매장을 찾을 입력. 검색 후 네 필드 중 하나라도 수정하면 다시 검색하세요") +public record PreviewRestaurantLocationRequest( + @Schema(description = "최종 등록에 사용할 식당명", example = "도쿄역 이카루가") + @NotBlank @Size(max = 100) String name, + @Schema(description = "최종 등록에 사용할 현지 식당명. Places 검색에는 이 이름을 사용", example = "東京駅 斑鳩") + @NotBlank @Size(max = 100) String localName, + @Schema(description = "건물명·층을 포함한 표시 주소", example = "東京都千代田区丸の内1-9-1 東京駅一番街 B1F") + @NotBlank @Size(max = 255) String address, + @Schema(description = "검색용 별도 주소(선택). 생략/null이면 address를 사용하고 공백은 허용하지 않음") + @Pattern(regexp = "(?s).*[^\\p{javaWhitespace}\\p{Z}].*") @Size(max = 255) String geocodingAddress) { + public RestaurantLocationPreviewCommand toCommand() { + return new RestaurantLocationPreviewCommand(name, localName, address, geocodingAddress); + } +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationPreviewResponse.java b/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationPreviewResponse.java new file mode 100644 index 00000000..d9fb3189 --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationPreviewResponse.java @@ -0,0 +1,15 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import org.sopt.hashi.restaurant.RestaurantPlacesCandidateInfo; + +@Schema(description = "아직 식당을 저장하지 않은 Places 미리보기. 후보를 선택해 locationSelectionToken으로 등록 요청에 전달") +public record RestaurantLocationPreviewResponse( + @Schema(description = "서비스 범위를 통과한 후보. 빈 배열은 정상 검색 결과 0건이며 오류와 다름") + List candidates) { + public static RestaurantLocationPreviewResponse from(List candidates) { + return new RestaurantLocationPreviewResponse(candidates.stream() + .map(RestaurantPlacesSearchResponse.Candidate::from).toList()); + } +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationResponse.java b/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationResponse.java new file mode 100644 index 00000000..9812149a --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationResponse.java @@ -0,0 +1,55 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import org.sopt.hashi.restaurant.RestaurantLocationInfo; + +@Schema(description = "식당 저장과 별도로 진행되는 지도 위치 확인 상태. 좌표나 내부 작업 식별자는 노출하지 않음") +public record RestaurantLocationResponse( + @Schema(description = "식당 ID", example = "1001") + Long restaurantId, + @Schema(description = "위치 처리 상태: UNRESOLVED(위치 행 없음), PENDING(처리 중), READY(위치 확인 완료), " + + "RETRY_WAIT(자동 재시도 대기), REVIEW_REQUIRED(관리자 확인 필요), FAILED(자동 처리 중단)", + allowableValues = {"UNRESOLVED", "PENDING", "READY", "RETRY_WAIT", "REVIEW_REQUIRED", "FAILED"}, + example = "PENDING") + String locationStatus, + @Schema(description = "위치 확인 기준 정보 변경 번호(addressRevision, 필드명은 호환 유지). 이름·현지명·표시주소·위치조회주소가 바뀔 때 증가하며 재시도 요청의 expectedAddressRevision으로 사용", + example = "1") + long addressRevision, + @Schema(description = "마지막 승인 위치의 출처. 승인 위치가 없으면 null", + allowableValues = {"GOOGLE_GEOCODING", "GOOGLE_PLACES", "ADMIN"}, nullable = true) + String source, + @Schema(description = "현재 requestId 작업의 검증 방식. 작업이 없으면 null", + allowableValues = {"GEOCODING", "PLACE_DETAILS"}, nullable = true) + String verificationMode, + @Schema(description = "마지막으로 승인된 좌표의 유효 기한(UTC). 현재 시각과 같거나 과거면 READY여도 지도에서 사용할 수 없으며, 승인 좌표가 없으면 null") + Instant validUntil, + @Schema(description = "현재 위치 작업의 provider 호출 시도 횟수. 아직 호출하지 않았거나 작업이 없으면 0", + example = "0") + int attempt, + @Schema(description = "RETRY_WAIT 상태의 다음 자동 재시도 예정 시각(UTC). 해당하지 않으면 null") + Instant nextAttemptAt, + @Schema(description = "현재 작업의 최근 실패 사유 코드. 실패 기록이 없으면 null", + example = "TRANSIENT_ERROR") + String failureCode, + @Schema(description = "관리자 재시도 버튼 노출 여부. UNRESOLVED·RETRY_WAIT·REVIEW_REQUIRED·FAILED는 true, PENDING·READY는 false", + example = "false") + boolean canRetry, + @Schema(description = "확인 방식. AUTOMATIC은 자동 검증, ADMIN_PLACE_SELECTION은 관리자의 매장 후보 선택. 과거 기록이 없으면 null") + String confirmationMethod, + @Schema(description = "후보를 선택한 관리자 ID. 서버 인증 정보에서 기록하며 자동 검증 또는 과거 기록이 없으면 null") + Long confirmedByAdminId, + @Schema(description = "확인한 관리자 계정의 현재 로그인 ID. 관리자 화면 표시용이며 확인 당시 값의 사본이 아님. 자동 검증·과거 기록 없음·계정 삭제 시 null", + example = "hashi_admin", nullable = true) + String confirmedByAdminLoginId, + @Schema(description = "위치 확인 시각(UTC). 관리자 선택은 선택 접수 시각이며 좌표 취득 완료 시각과 다름. 이 값만으로 핀 노출을 판단하지 않음. 새 후보는 READY 완료 후 표시하며 같은 매장 갱신 중의 기존 유효 핀은 유지") + Instant confirmedAt, + @Schema(description = "확인 당시 식당 정보 revision. 이름·주소가 바뀌면 이전 확인 기록을 초기화") + Long confirmedRevision) { + public static RestaurantLocationResponse from(RestaurantLocationInfo info, String confirmedByAdminLoginId) { + return new RestaurantLocationResponse(info.restaurantId(), info.locationStatus(), info.addressRevision(), + info.source(), info.verificationMode(), info.validUntil(), info.attempt(), info.nextAttemptAt(), + info.failureCode(), info.canRetry(), info.confirmationMethod(), info.confirmedByAdminId(), + confirmedByAdminLoginId, info.confirmedAt(), info.confirmedRevision()); + } +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationReviewListResponse.java b/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationReviewListResponse.java new file mode 100644 index 00000000..a83d94e7 --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationReviewListResponse.java @@ -0,0 +1,30 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; +import org.sopt.hashi.restaurant.RestaurantLocationReviewPage; + +@Schema(description = "식당 ID 오름차순 관리자 위치 검토 목록. page는 0부터 시작") +public record RestaurantLocationReviewListResponse( + @Schema(description = "위치 검토 대상 식당") + List restaurants, + @Schema(description = "현재 페이지 번호(0부터 시작)", example = "0") + int page, + @Schema(description = "요청한 페이지 크기", example = "20") + int size, + @Schema(description = "필터에 맞는 전체 식당 수", example = "42") + long totalCount, + @Schema(description = "전체 페이지 수", example = "3") + int totalPages) { + + public static RestaurantLocationReviewListResponse from(RestaurantLocationReviewPage page, Map loginIds) { + return new RestaurantLocationReviewListResponse( + page.restaurants().stream().map(info -> RestaurantLocationReviewResponse.from(info, + info.confirmedByAdminId() == null ? null : loginIds.get(info.confirmedByAdminId()))).toList(), + page.page(), + page.size(), + page.totalCount(), + page.totalPages()); + } +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationReviewResponse.java b/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationReviewResponse.java new file mode 100644 index 00000000..d766308b --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/RestaurantLocationReviewResponse.java @@ -0,0 +1,55 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import org.sopt.hashi.restaurant.RestaurantLocationReviewInfo; + +@Schema(description = "관리자 위치 검토 목록의 식당. 좌표와 내부 작업 식별자는 노출하지 않음") +public record RestaurantLocationReviewResponse( + @Schema(description = "식당 ID", example = "1001") + Long restaurantId, + @Schema(description = "식당명", example = "야키니쿠 리키마루 이케부쿠로점") + String name, + @Schema(description = "화면에 표시하는 전체 주소", example = "東京都豊島区東池袋1-1-1 架空ビル1F") + String address, + @Schema(description = "Google 위치 확인에만 쓰는 별도 지정 주소. null이면 address를 사용", + example = "東京都豊島区東池袋1-1-1", nullable = true) + String geocodingAddress, + @Schema(description = "위치 처리 상태", + allowableValues = {"UNRESOLVED", "PENDING", "READY", "RETRY_WAIT", "REVIEW_REQUIRED", "FAILED"}, + example = "REVIEW_REQUIRED") + String locationStatus, + @Schema(description = "마지막 승인 위치의 출처. 승인 위치가 없으면 null", + allowableValues = {"GOOGLE_GEOCODING", "GOOGLE_PLACES", "ADMIN"}, example = "GOOGLE_PLACES", nullable = true) + String source, + @Schema(description = "현재 requestId 작업의 검증 방식", + allowableValues = {"GEOCODING", "PLACE_DETAILS"}, example = "PLACE_DETAILS", nullable = true) + String verificationMode, + @Schema(description = "위치 확인 기준 정보 변경 번호(addressRevision). 이름·현지명·표시주소·위치조회주소 변경에 따라 증가. 재시도 요청의 expectedAddressRevision으로 사용", + example = "2") + long addressRevision, + @Schema(description = "승인 좌표의 유효 기한(UTC). 승인 좌표가 없으면 null", nullable = true) + Instant validUntil, + @Schema(description = "현재 requestId 작업의 provider 호출 시도 횟수", example = "1") + int attempt, + @Schema(description = "다음 자동 재시도 예정 시각(UTC). 해당하지 않으면 null", nullable = true) + Instant nextAttemptAt, + @Schema(description = "현재 requestId 작업의 최근 실패 사유 코드. 없으면 null", + example = "NO_RESULTS", nullable = true) + String failureCode, + @Schema(description = "관리자 재시도 가능 여부", example = "true") + boolean canRetry, + @Schema(description = "후보를 선택한 관리자 내부 ID. 자동 검증 또는 과거 기록이 없으면 null", nullable = true) + Long confirmedByAdminId, + @Schema(description = "확인한 관리자 계정의 현재 로그인 ID. 관리자 화면 표시용이며 확인 당시 값의 사본이 아님. 자동 검증·과거 기록 없음·계정 삭제 시 null", + example = "hashi_admin", nullable = true) + String confirmedByAdminLoginId) { + + public static RestaurantLocationReviewResponse from(RestaurantLocationReviewInfo info, String confirmedByAdminLoginId) { + return new RestaurantLocationReviewResponse( + info.restaurantId(), info.name(), info.address(), info.geocodingAddress(), + info.locationStatus(), info.source(), info.verificationMode(), info.addressRevision(), info.validUntil(), + info.attempt(), info.nextAttemptAt(), info.failureCode(), info.canRetry(), + info.confirmedByAdminId(), confirmedByAdminLoginId); + } +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/RestaurantPlacesSearchResponse.java b/src/main/java/org/sopt/hashi/admin/dto/RestaurantPlacesSearchResponse.java new file mode 100644 index 00000000..f2b2df65 --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/RestaurantPlacesSearchResponse.java @@ -0,0 +1,51 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import org.sopt.hashi.restaurant.RestaurantPlacesCandidateInfo; +import org.sopt.hashi.restaurant.RestaurantPlacesSearchInfo; + +@Schema(description = "저장된 식당명과 위치 확인 주소로 조회한 Google Places 후보") +public record RestaurantPlacesSearchResponse( + @Schema(example = "1001") Long restaurantId, + @Schema(description = "검색 시점의 위치 확인 기준 정보 변경 번호. 이름·현지명·표시주소·위치조회주소 변경 시 달라짐", example = "2") long addressRevision, + @Schema(description = "도쿄 서비스 범위를 통과한 후보. 검색 결과가 없으면 빈 배열") + List candidates) { + + public static RestaurantPlacesSearchResponse from(RestaurantPlacesSearchInfo info) { + return new RestaurantPlacesSearchResponse(info.restaurantId(), info.addressRevision(), + info.candidates().stream().map(Candidate::from).toList()); + } + + public record Candidate( + @Schema(example = "焼肉 力丸 池袋東口店") String displayName, + @Schema(example = "1 Chome-1-1 Higashiikebukuro, Toshima City, Tokyo") String address, + @Schema(example = "35.729503") BigDecimal latitude, + @Schema(example = "139.710900") BigDecimal longitude, + @Schema(example = "JP") String countryCode, + @Schema(example = "Tokyo") String administrativeArea, + @Schema(example = "[\"restaurant\",\"food\"]") List types, + @Schema(example = "OPERATIONAL") String businessStatus, + List attributions, + @Schema(example = "https://maps.google.com/?cid=123") String googleMapsUri, + @Schema(description = "후보·입력·인증 관리자를 묶은 10분 유효 서명 토큰. 발급받은 관리자만 사용 가능") + String selectionToken, + @Schema(description = "selectionToken 만료 시각(UTC)") Instant selectionExpiresAt) { + + static Candidate from(RestaurantPlacesCandidateInfo info) { + return new Candidate(info.displayName(), info.address(), info.latitude(), info.longitude(), + info.countryCode(), info.administrativeArea(), info.types(), info.businessStatus(), + info.attributions().stream().map(Attribution::from).toList(), info.googleMapsUri(), + info.selectionToken(), info.selectionExpiresAt()); + } + } + + public record Attribution(@Schema(example = "Google") String displayName, + @Schema(example = "https://www.google.com") String uri) { + static Attribution from(RestaurantPlacesCandidateInfo.Attribution attribution) { + return new Attribution(attribution.displayName(), attribution.uri()); + } + } +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/RetryRestaurantLocationRequest.java b/src/main/java/org/sopt/hashi/admin/dto/RetryRestaurantLocationRequest.java new file mode 100644 index 00000000..b958f62d --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/RetryRestaurantLocationRequest.java @@ -0,0 +1,13 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +/** UNRESOLVED(위치 행 없음)의 revision은 0이다. */ +public record RetryRestaurantLocationRequest( + @Schema(description = "위치 상태 조회 응답의 최신 addressRevision. 이름·현지명·표시주소·위치조회주소 변경과 재시도의 충돌을 막는 조건값이며 UNRESOLVED는 0. 서버의 최신 값과 다르면 409", + example = "1") + @NotNull(message = "주소 버전은 필수입니다") + @PositiveOrZero(message = "주소 버전은 0 이상입니다") Long expectedAddressRevision) { +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/SearchRestaurantPlacesRequest.java b/src/main/java/org/sopt/hashi/admin/dto/SearchRestaurantPlacesRequest.java new file mode 100644 index 00000000..3cd554b5 --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/SearchRestaurantPlacesRequest.java @@ -0,0 +1,12 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record SearchRestaurantPlacesRequest( + @Schema(description = "위치 상태 조회 응답의 최신 addressRevision(위치 확인 기준 정보 변경 번호). 이름·주소 변경으로 최신 값과 다르면 409", + example = "2") + @NotNull(message = "주소 버전은 필수입니다") + @Positive(message = "주소 버전은 양수입니다") Long expectedAddressRevision) { +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/SelectRestaurantPlaceRequest.java b/src/main/java/org/sopt/hashi/admin/dto/SelectRestaurantPlaceRequest.java new file mode 100644 index 00000000..c59ecfe6 --- /dev/null +++ b/src/main/java/org/sopt/hashi/admin/dto/SelectRestaurantPlaceRequest.java @@ -0,0 +1,17 @@ +package org.sopt.hashi.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record SelectRestaurantPlaceRequest( + @Schema(description = "후보 검색 응답과 같은 최신 addressRevision(위치 확인 기준 정보 변경 번호)", example = "2") + @NotNull(message = "주소 버전은 필수입니다") + @Positive(message = "주소 버전은 양수입니다") Long expectedAddressRevision, + @Schema(description = "후보 검색 응답의 selectionToken. 발급받은 관리자만 10분 안에 한 번 선택 가능. 이름·주소가 바뀌거나 토큰을 수정하면 무효", + example = "ZXlK...Q2Q") + @NotBlank(message = "후보 선택 토큰은 필수입니다") + @Size(max = 2048, message = "후보 선택 토큰이 너무 깁니다") String selectionToken) { +} diff --git a/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java b/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java index 48c3551d..9abb16ac 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java +++ b/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java @@ -19,22 +19,29 @@ import java.util.UUID; /** - * 어드민 식당 부분 수정(PATCH) 요청 — null 필드는 변경하지 않는다(값 비우기 불가). + * 어드민 식당 부분 수정(PATCH) 요청 — null 필드는 변경하지 않는다. + * geocodingAddress만 공백 문자열로 명시적 override 삭제가 가능하다. * 컬렉션은 전체 교체 의미다. null이면 유지하며, imageKeys·hashtags는 최소 1개를 유지해야 한다. * businessHours는 보낼 경우 7개 요일을 중복 없이 모두 포함해야 한다. */ public record UpdateRestaurantRequest( - @Schema(description = "식당명(선택)", example = "야키니쿠 리키마루 이케부쿠로점") + @Schema(description = "식당명(선택). 값이 바뀌면 기존 위치와 관리자 확인을 무효화하고 재확인", example = "야키니쿠 리키마루 이케부쿠로점") @Size(max = 100, message = "식당명은 100자 이내입니다") String name, - @Schema(description = "현지(일본어) 식당명(선택, 공백 불가)", example = "焼肉力丸 池袋東口店") + @Schema(description = "현지(일본어) 식당명(선택, 공백 불가). 값이 바뀌면 위치 재확인", example = "焼肉力丸 池袋東口店") @Pattern(regexp = ".*\\S.*", message = "현지 식당명은 공백일 수 없습니다") @Size(max = 100, message = "현지 식당명은 100자 이내입니다") String localName, @Schema(description = "한 줄 소개(선택)", example = "이케부쿠로의 인기 야키니쿠 전문점") @Size(max = 100, message = "한 줄 소개는 100자 이내입니다") String summary, @Schema(description = "매장 상세 설명(선택)", example = "엄선된 고기와 다양한 코스를 제공합니다.") @Size(max = 500, message = "상세 설명은 500자 이내입니다") String description, - @Schema(description = "주소(선택)", example = "도쿄도 도시마구 히가시이케부쿠로 1-1-1") + @Schema(description = "사용자 화면에 표시할 전체 주소(PATCH 선택). 건물명·층 정보를 보존. 이 값만 바꾸면 기존 geocodingAddress를 삭제하고 새 address를 위치 확인 기준으로 사용하며, 표시 주소가 바뀌면 기존 위치와 관리자 확인을 무효화하고 다시 요청", + example = "도쿄도 도시마구 히가시이케부쿠로 1-1-1") @Size(max = 255, message = "주소는 255자 이내입니다") String address, + @Schema(description = "Google 위치 확인용 별도 지정 주소(PATCH). 생략/null은 address가 그대로일 때 기존 값을 유지하고, " + + "address 변경 시 기존 별도 지정 주소를 삭제함. 공백은 별도 지정 주소를 명시적으로 삭제하고 현재 address를 사용. " + + "같은 기본 주소를 보내도 이름이나 표시 주소가 바뀌면 위치 재확인. 동일 입력은 기존 상태 유지", + example = "東京都豊島区東池袋1-1-1") + @Size(max = 255, message = "위치 확인용 주소는 255자 이내입니다") String geocodingAddress, @Schema(description = "지역(선택)", example = "이케부쿠로") @Size(max = 20, message = "지역은 20자 이내입니다") String area, @Schema(description = "장르(소문자 케밥, 선택)", example = "sushi") @@ -71,6 +78,18 @@ public record UpdateRestaurantRequest( @Size(min = 7, max = 7, message = "영업시간은 모든 요일(7개)을 포함해야 합니다") List<@NotNull(message = "영업시간 항목은 null일 수 없습니다") @Valid BusinessHourRequest> businessHours) { + /** geocodingAddress 도입 전 Java 호출부의 source compatibility를 유지한다. */ + public UpdateRestaurantRequest( + String name, String localName, String summary, String description, String address, + String area, String genre, String foodCategory, String placeType, String priceCurrency, + BigDecimal minPrice, BigDecimal maxPrice, List imageKeys, List images, + JsonNode unsupportedImageAssetIds, List menus, List hashtags, + List curationTypes, List businessHours) { + this(name, localName, summary, description, address, null, area, genre, foodCategory, placeType, + priceCurrency, minPrice, maxPrice, imageKeys, images, unsupportedImageAssetIds, menus, + hashtags, curationTypes, businessHours); + } + @AssertTrue(message = "imageKeys와 images는 함께 사용할 수 없습니다") @JsonIgnore public boolean isImageCollectionSourceValid() { diff --git a/src/main/java/org/sopt/hashi/admin/service/AdminRestaurantService.java b/src/main/java/org/sopt/hashi/admin/service/AdminRestaurantService.java index 2ce64b0d..58ad4859 100644 --- a/src/main/java/org/sopt/hashi/admin/service/AdminRestaurantService.java +++ b/src/main/java/org/sopt/hashi/admin/service/AdminRestaurantService.java @@ -1,31 +1,59 @@ package org.sopt.hashi.admin.service; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import org.sopt.hashi.admin.dto.AdminRestaurantResponse; +import org.sopt.hashi.admin.dto.PreviewRestaurantLocationRequest; +import org.sopt.hashi.admin.dto.RestaurantLocationPreviewResponse; import org.sopt.hashi.admin.dto.CreateRestaurantRequest; import org.sopt.hashi.admin.dto.UpdateRestaurantRequest; +import org.sopt.hashi.admin.dto.RestaurantLocationResponse; +import org.sopt.hashi.admin.dto.RestaurantLocationReviewListResponse; +import org.sopt.hashi.admin.dto.RetryRestaurantLocationRequest; +import org.sopt.hashi.admin.dto.RestaurantPlacesSearchResponse; +import org.sopt.hashi.admin.dto.SearchRestaurantPlacesRequest; +import org.sopt.hashi.admin.dto.SelectRestaurantPlaceRequest; +import org.sopt.hashi.auth.AdminAccountPort; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.BusinessHourCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.restaurant.RestaurantLocationInfo; +import org.sopt.hashi.restaurant.RestaurantLocationReviewInfo; +import org.sopt.hashi.restaurant.RestaurantLocationReviewPage; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; import org.springframework.stereotype.Service; /** * 어드민 식당 관리 — 진입점 모듈이라 도메인 로직 없이 {@link RestaurantPort}로 위임하고 - * 요청→커맨드·응답 DTO 변환만 한다(architecture.md §9). 저장·검증·URL 변환은 restaurant 소관. + * 요청→커맨드·응답 DTO 변환과 {@link AdminAccountPort}의 확인자 표시 정보를 조합한다(architecture.md §9). + * 저장·검증·URL 변환은 restaurant 소관. */ @Service public class AdminRestaurantService { private final RestaurantPort restaurantPort; + private final AdminAccountPort adminAccountPort; - public AdminRestaurantService(RestaurantPort restaurantPort) { + public AdminRestaurantService(RestaurantPort restaurantPort, AdminAccountPort adminAccountPort) { this.restaurantPort = restaurantPort; + this.adminAccountPort = adminAccountPort; } public AdminRestaurantResponse create(CreateRestaurantRequest request) { - return AdminRestaurantResponse.from(restaurantPort.createByAdmin(toCommand(request))); + AdminRestaurantCommand command = toCommand(request); + return AdminRestaurantResponse.from(request.locationSelectionToken() == null + ? restaurantPort.createByAdmin(command) + : restaurantPort.createByAdmin(command, request.locationSelectionToken())); + } + + public RestaurantLocationPreviewResponse previewLocation(PreviewRestaurantLocationRequest request) { + return RestaurantLocationPreviewResponse.from(restaurantPort.previewLocationByAdmin(request.toCommand())); } public AdminRestaurantResponse update(Long restaurantId, UpdateRestaurantRequest request) { @@ -36,6 +64,47 @@ public void delete(Long restaurantId) { restaurantPort.deleteByAdmin(restaurantId); } + public RestaurantLocationResponse getLocation(Long restaurantId) { + return locationResponse(restaurantPort.getLocationByAdmin(restaurantId)); + } + + public RestaurantLocationResponse retryLocation(Long restaurantId, RetryRestaurantLocationRequest request) { + return locationResponse( + restaurantPort.retryLocationByAdmin(restaurantId, request.expectedAddressRevision())); + } + + public RestaurantLocationReviewListResponse findLocationReviews( + String status, String source, int page, int size) { + if ((long) page * size > Integer.MAX_VALUE) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + RestaurantLocationReviewPage reviews = restaurantPort.findLocationReviewsByAdmin(status, source, page, size); + Set adminIds = reviews.restaurants().stream() + .map(RestaurantLocationReviewInfo::confirmedByAdminId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map loginIds = adminIds.isEmpty() ? Map.of() : adminAccountPort.findLoginIds(adminIds); + return RestaurantLocationReviewListResponse.from(reviews, loginIds); + } + + public RestaurantPlacesSearchResponse searchLocationPlaces( + Long restaurantId, SearchRestaurantPlacesRequest request) { + return RestaurantPlacesSearchResponse.from(restaurantPort.searchLocationPlacesByAdmin( + restaurantId, request.expectedAddressRevision())); + } + + public RestaurantLocationResponse selectLocationPlace( + Long restaurantId, SelectRestaurantPlaceRequest request) { + return locationResponse(restaurantPort.selectLocationPlaceByAdmin( + restaurantId, request.expectedAddressRevision(), request.selectionToken())); + } + + private RestaurantLocationResponse locationResponse(RestaurantLocationInfo info) { + Long adminId = info.confirmedByAdminId(); + String loginId = adminId == null ? null : adminAccountPort.findLoginIds(Set.of(adminId)).get(adminId); + return RestaurantLocationResponse.from(info, loginId); + } + private AdminRestaurantCommand toCommand(CreateRestaurantRequest request) { return new AdminRestaurantCommand( request.name(), @@ -43,6 +112,7 @@ private AdminRestaurantCommand toCommand(CreateRestaurantRequest request) { request.summary(), request.description(), request.address(), + request.geocodingAddress(), request.area(), request.genre(), request.foodCategory(), @@ -66,6 +136,7 @@ private AdminRestaurantCommand toCommand(UpdateRestaurantRequest request) { request.summary(), request.description(), request.address(), + request.geocodingAddress(), request.area(), request.genre(), request.foodCategory(), diff --git a/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java b/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java index b3881b27..73e0034f 100644 --- a/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java +++ b/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java @@ -1,25 +1,43 @@ package org.sopt.hashi.admin.web; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; import org.sopt.hashi.admin.code.AdminSuccessCode; import org.sopt.hashi.admin.dto.AdminRestaurantResponse; +import org.sopt.hashi.admin.dto.PreviewRestaurantLocationRequest; +import org.sopt.hashi.admin.dto.RestaurantLocationPreviewResponse; import org.sopt.hashi.admin.dto.CreateRestaurantRequest; import org.sopt.hashi.admin.dto.UpdateRestaurantRequest; +import org.sopt.hashi.admin.dto.RestaurantLocationResponse; +import org.sopt.hashi.admin.dto.RestaurantLocationReviewListResponse; +import org.sopt.hashi.admin.dto.RetryRestaurantLocationRequest; +import org.sopt.hashi.admin.dto.RestaurantPlacesSearchResponse; +import org.sopt.hashi.admin.dto.SearchRestaurantPlacesRequest; +import org.sopt.hashi.admin.dto.SelectRestaurantPlaceRequest; import org.sopt.hashi.admin.service.AdminRestaurantService; import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.shared.error.CommonSuccessCode; import org.sopt.hashi.shared.response.SuccessResponse; import org.sopt.hashi.shared.swagger.ApiErrorResponse; import org.sopt.hashi.shared.swagger.ApiException; import org.sopt.hashi.shared.swagger.ApiSuccess; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -37,6 +55,17 @@ public AdminRestaurantController(AdminRestaurantService adminRestaurantService) /** 식당 등록 — 식당·메뉴 사진은 presigned URL로 업로드를 마친 S3 키로 받는다. */ // businessHours는 minItems=7이라 자동 생성 예시가 같은 요일(MONDAY)을 7번 복제해 그대로 보내면 // RESTAURANT-006이 난다. 복붙만으로 성공하도록 요일 7개가 모두 다른 완성형 예시를 명시한다. + @Operation(summary = "식당 등록", description = """ + 식당 정보를 저장한 뒤 지도 위치 확인 작업을 비동기로 요청합니다. + 201 응답은 식당 저장과 위치 작업 등록 성공을 뜻하며, Google 위치 확인 완료를 뜻하지 않습니다. + 응답의 locationStatus와 addressRevision을 확인하고 위치 상태 조회 API로 최신 상태를 조회하세요. + 등록 전에 POST /api/v1/admin/restaurants/location-preview로 후보를 확인할 수 있습니다. + 지도에서 맞는 매장을 고른 뒤 후보의 selectionToken을 locationSelectionToken으로 보내세요. + 같은 관리자가 이름·현지명·표시주소·기본주소를 변경하지 않고 10분 안에 등록해야 합니다. + 토큰이 없으면 등록은 허용하고 기존 주소 자동 확인을 요청합니다. 확인 실패로 식당 정보가 삭제되지는 않습니다. + 토큰이 있으면 선택한 Place ID의 상세 확인만 요청하며 주소 검색을 함께 반복하지 않습니다. + 관리자의 후보 선택과 Google 상세 검증 완료는 다르므로 PENDING 동안에는 핀을 표시하지 마세요. + """) @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(examples = @ExampleObject( name = "식당 등록 예시(복붙 가능)", value = """ { @@ -44,7 +73,8 @@ public AdminRestaurantController(AdminRestaurantService adminRestaurantService) "localName": "焼肉力丸 池袋東口店", "summary": "이케부쿠로의 인기 야키니쿠 전문점", "description": "엄선된 고기와 다양한 코스를 제공합니다.", - "address": "도쿄도 도시마구 히가시이케부쿠로 1-1-1", + "address": "東京都豊島区東池袋1-1-1 架空ビル1F", + "geocodingAddress": "東京都豊島区東池袋1-1-1", "area": "이케부쿠로", "genre": "grill", "foodCategory": "야키니쿠", @@ -89,6 +119,12 @@ public AdminRestaurantController(AdminRestaurantService adminRestaurantService) message = "이미 등록된 식당명입니다.") @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "RESTAURANT-021", message = "이미 등록된 주소입니다.") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, code = "RESTAURANT-027", + message = "Places 선택 정보가 올바르지 않습니다") + @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "RESTAURANT-028", + message = "현재 위치 상태를 다시 확인해주세요") + @ApiErrorResponse(status = HttpStatus.SERVICE_UNAVAILABLE, code = "RESTAURANT-024", + message = "Places 위치 확인을 사용할 수 없습니다") @ApiSuccess(value = AdminSuccessCode.class, codes = {"RESTAURANT_CREATED"}) @ResponseStatus(HttpStatus.CREATED) @PostMapping @@ -98,37 +134,112 @@ public SuccessResponse create( adminRestaurantService.create(request)); } + @PostMapping("/location-preview") + @Operation(summary = "식당 등록 전 위치 미리보기", description = """ + 식당을 저장하기 전에 현지 식당명과 주소로 Google Places 후보를 조회합니다. + geocodingAddress가 없으면 건물명·층을 포함한 address를 사용합니다. 아직 식당 ID는 필요하지 않습니다. + 후보의 이름·지점·주소와 지도 위치를 확인해 맞는 매장을 고르고, selectionToken을 최종 등록의 + locationSelectionToken으로 전달하세요. 클라이언트가 좌표나 Place ID를 직접 확정해 보내는 방식이 아닙니다. + 후보를 고른 관리자만 10분 안에 같은 입력으로 등록할 수 있습니다. 이름·현지명·주소·기본주소를 + 하나라도 바꾸거나 토큰이 만료되면 다시 미리보기를 요청하세요. + candidates=[]는 정상 조회 결과 0건입니다. 429는 호출 예산 소진, 503은 설정 문제, 502는 Google 조회 실패입니다. + 후보가 없어도 토큰 없이 식당 정보를 저장할 수 있습니다. 위치 확인이 완료될 때까지 핀만 숨깁니다. + 후보 결과는 저장하지 말고, Google 지도와 필요한 Google/제3자 출처 표시를 함께 보여주세요. + """) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) + @ApiErrorResponse(status = HttpStatus.TOO_MANY_REQUESTS, code = "RESTAURANT-025", + message = "Places 호출 한도를 확인해주세요") + @ApiErrorResponse(status = HttpStatus.SERVICE_UNAVAILABLE, code = "RESTAURANT-024", + message = "Places 위치 확인을 사용할 수 없습니다") + @ApiErrorResponse(status = HttpStatus.BAD_GATEWAY, code = "RESTAURANT-026", + message = "Places 응답을 확인할 수 없습니다") + public SuccessResponse previewLocation( + @Valid @RequestBody PreviewRestaurantLocationRequest request, HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, adminRestaurantService.previewLocation(request)); + } + /** 식당 부분 수정 — 보낸 필드만 변경하며, 컬렉션(이미지·메뉴·해시태그·큐레이션)은 전체 교체한다. */ // 자동 생성 예시는 businessHours를 같은 요일 7개로 복제해 그대로 보내면 실패한다(등록과 동일). // PATCH 의미(보낸 필드만 변경)가 드러나도록 일부 필드 + 올바른 영업시간 예시를 명시한다. - @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(examples = @ExampleObject( - name = "부분 수정 예시(복붙 가능)", value = """ - { - "name": "야키니쿠 리키마루 이케부쿠로 본점", - "summary": "리뉴얼한 이케부쿠로 야키니쿠 맛집", - "placeType": "cafe", - "menus": [ - { - "menuId": 10, - "name": "특선 모둠 야키니쿠", - "description": "엄선한 부위 5종 모둠", - "imageKey": "restaurant-menus/a1b2c3-menu.jpg", - "priceCurrency": "JPY", - "priceAmount": 4500, - "main": true - } - ], - "businessHours": [ - {"dayOfWeek": "MONDAY", "openTime": "11:30", "closeTime": "22:00", "closed": false}, - {"dayOfWeek": "TUESDAY", "openTime": "11:30", "closeTime": "22:00", "closed": false}, - {"dayOfWeek": "WEDNESDAY", "openTime": "11:30", "closeTime": "22:00", "closed": false}, - {"dayOfWeek": "THURSDAY", "openTime": "11:30", "closeTime": "22:00", "closed": false}, - {"dayOfWeek": "FRIDAY", "openTime": "11:30", "closeTime": "23:00", "closed": false}, - {"dayOfWeek": "SATURDAY", "openTime": "11:30", "closeTime": "23:00", "closed": false}, - {"dayOfWeek": "SUNDAY", "closed": true} - ] - } - """))) + @Operation(summary = "식당 부분 수정", description = """ + 보낸 필드만 변경합니다. address는 건물명·층까지 보존하는 화면 표시 주소이고, + geocodingAddress는 Google 위치 확인에만 쓰는 별도 지정 주소입니다. + geocodingAddress를 생략하거나 null로 보내면 address가 그대로일 때 기존 별도 지정 주소를 유지합니다. + address만 변경하면 기존 별도 지정 주소를 삭제하고 새 address를 위치 확인 기준으로 사용합니다. + 이름(name/localName) 또는 표시 주소(address)가 실제로 달라지면 기존 좌표와 관리자 확인을 무효화하고 다시 확인합니다. + 조회용 기본 주소가 같아도 층수·지점명 등 표시 정보가 바뀌면 같은 규칙을 적용합니다. + geocodingAddress의 공백 문자열은 별도 지정 주소를 명시적으로 삭제하고 현재 address를 사용한다는 뜻입니다. + 동일한 값을 다시 보내거나 위치와 무관한 소개·가격만 바꾸면 위치와 revision을 유지합니다. + 200 응답은 식당 저장과 필요한 위치 작업 등록 성공을 뜻하며, Google 위치 확인 완료를 뜻하지 않습니다. + """) + @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(examples = { + @ExampleObject(name = "일반 부분 수정(복붙 가능)", value = """ + { + "name": "야키니쿠 리키마루 이케부쿠로 본점", + "summary": "리뉴얼한 이케부쿠로 야키니쿠 맛집", + "geocodingAddress": "東京都豊島区東池袋1-1-1", + "placeType": "cafe", + "menus": [ + { + "menuId": 10, + "name": "특선 모둠 야키니쿠", + "description": "엄선한 부위 5종 모둠", + "imageKey": "restaurant-menus/a1b2c3-menu.jpg", + "priceCurrency": "JPY", + "priceAmount": 4500, + "main": true + } + ], + "businessHours": [ + {"dayOfWeek": "MONDAY", "openTime": "11:30", "closeTime": "22:00", "closed": false}, + {"dayOfWeek": "TUESDAY", "openTime": "11:30", "closeTime": "22:00", "closed": false}, + {"dayOfWeek": "WEDNESDAY", "openTime": "11:30", "closeTime": "22:00", "closed": false}, + {"dayOfWeek": "THURSDAY", "openTime": "11:30", "closeTime": "22:00", "closed": false}, + {"dayOfWeek": "FRIDAY", "openTime": "11:30", "closeTime": "23:00", "closed": false}, + {"dayOfWeek": "SATURDAY", "openTime": "11:30", "closeTime": "23:00", "closed": false}, + {"dayOfWeek": "SUNDAY", "closed": true} + ] + } + """), + @ExampleObject( + name = "표시 층 변경 후 위치 재확인", + description = "기존 geocodingAddress가 東京都豊島区東池袋1-1-1인 식당의 층만 바꾸는 예시입니다. " + + "같은 기본 주소를 보내도 표시 주소가 바뀌었으므로 기존 핀을 숨기고 위치를 다시 확인합니다.", + value = """ + { + "address": "東京都豊島区東池袋1-1-1 架空ビル2F", + "geocodingAddress": "東京都豊島区東池袋1-1-1" + } + """), + @ExampleObject( + name = "위치 확인 기준 변경", + description = "표시 주소와 별도 지정 주소의 위치 기준이 바뀌어 기존 좌표를 무효화하고 새 작업을 요청합니다.", + value = """ + { + "address": "東京都豊島区東池袋2-2-2 新館1F", + "geocodingAddress": "東京都豊島区東池袋2-2-2" + } + """), + @ExampleObject( + name = "표시 주소만 변경", + description = "geocodingAddress를 생략하면 기존 별도 지정 주소를 삭제하고 새 address를 위치 확인 기준으로 사용합니다. " + + "표시 주소가 바뀌면 기존 좌표와 확인 결과를 무효화합니다.", + value = """ + { + "address": "東京都豊島区東池袋3-3-3 本館1F" + } + """), + @ExampleObject( + name = "별도 지정 주소 삭제", + description = "공백 문자열은 저장된 geocodingAddress를 명시적으로 삭제하고 현재 address를 위치 확인 기준으로 사용합니다.", + value = """ + { + "geocodingAddress": " " + } + """) + })) @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "MEDIA-001", message = "이미지 자산을 찾을 수 없습니다") @@ -163,4 +274,140 @@ public SuccessResponse delete(@PathVariable Long restaurantId) { adminRestaurantService.delete(restaurantId); return SuccessResponse.of(AdminSuccessCode.RESTAURANT_DELETED, null); } + + /** 식당 정보 저장 결과와 별개인 지도 위치 처리 상태를 조회한다. */ + @GetMapping("/{restaurantId}/location") + @Operation(summary = "식당 위치 처리 상태 조회", description = """ + 식당 저장과 별도로 진행되는 최신 위치 확인 상태를 조회합니다. + UNRESOLVED는 위치 행 없음, PENDING은 처리 중, READY는 위치 확인 완료, + RETRY_WAIT는 자동 재시도 대기, REVIEW_REQUIRED는 관리자 확인 필요, FAILED는 자동 처리 중단입니다. + READY여도 validUntil이 현재보다 늦어야 지도에서 사용할 수 있습니다. + 재시도 화면은 서버가 계산한 canRetry와 addressRevision을 함께 사용하고, 응답은 저장하지 말고 매번 최신 상태를 조회하세요. + """) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @ApiException(value = CommonErrorCode.class, codes = {"UNAUTHORIZED", "FORBIDDEN"}) + @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "RESTAURANT-004", message = "식당을 찾을 수 없습니다.") + public SuccessResponse getLocation(@PathVariable Long restaurantId, + HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, adminRestaurantService.getLocation(restaurantId)); + } + + /** 현재 주소 revision의 위치 확인을 재요청한다. PENDING은 기존 작업을 반환한다. */ + @PostMapping("/{restaurantId}/location/retry") + @Operation(summary = "식당 위치 확인 재요청", description = """ + 먼저 위치 상태 조회 API에서 최신 addressRevision을 읽어 expectedAddressRevision으로 보냅니다. + 같은 revision의 PENDING 작업이 있으면 새 작업을 만들지 않고 현재 상태를 반환하며, + 그 밖의 재시도 가능 상태는 PENDING 작업을 새로 요청합니다. + 이름·현지명·주소 변경으로 revision이 달라졌거나 이미 READY이면 409를 반환합니다. + 200 응답은 재요청 접수 성공을 뜻하며, Google 위치 확인 완료를 뜻하지 않습니다. + """) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) + @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "RESTAURANT-004", message = "식당을 찾을 수 없습니다.") + @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "RESTAURANT-019", message = "현재 주소의 위치 상태를 다시 확인해주세요") + public SuccessResponse retryLocation(@PathVariable Long restaurantId, + @Valid @RequestBody RetryRestaurantLocationRequest request, HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, adminRestaurantService.retryLocation(restaurantId, request)); + } + + /** 위치 상태별 관리자 검토 목록. 관리자 목록 표준인 0-based offset 페이지를 사용한다. */ + @GetMapping("/locations") + @Operation(summary = "식당 위치 검토 목록 조회", description = """ + 활성 식당을 locationStatus로 필터링해 restaurantId 오름차순으로 조회합니다. + status 기본값은 REVIEW_REQUIRED이며 UNRESOLVED는 아직 위치 행이 없는 식당입니다. + source는 GOOGLE_GEOCODING, GOOGLE_PLACES 또는 ADMIN으로 좁힐 때만 보내며, 승인 위치가 없는 행의 source는 null입니다. + 각 항목의 attempt와 failureCode는 위치의 현재 requestId와 일치하는 작업에서만 가져옵니다. + confirmedByAdminId는 확인자 내부 ID, confirmedByAdminLoginId는 현재 계정의 표시용 로그인 ID입니다. + 자동 확인·과거 확인자 기록 없음·계정 삭제 시 로그인 ID는 null이며, 확인자 계정은 페이지 단위로 함께 조회합니다. + READY는 validUntil이 현재보다 늦어야 실제 지도 위치로 사용할 수 있습니다. + 재시도는 canRetry가 true인 항목의 최신 addressRevision을 expectedAddressRevision으로 보내세요. + page는 0부터 시작하며 같은 응답의 totalCount와 totalPages는 조회한 목록과 같은 DB snapshot 기준입니다. + 처리 중 상태가 바뀌면 페이지 사이의 항목과 총건수도 달라질 수 있습니다. 처리 후 현재 page를 다시 조회하세요. + """) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) + public SuccessResponse findLocationReviews( + @Parameter(description = "위치 처리 상태. 생략하면 REVIEW_REQUIRED", + schema = @Schema(allowableValues = {"UNRESOLVED", "PENDING", "READY", "RETRY_WAIT", + "REVIEW_REQUIRED", "FAILED"}), example = "REVIEW_REQUIRED") + @Pattern(regexp = "UNRESOLVED|PENDING|READY|RETRY_WAIT|REVIEW_REQUIRED|FAILED") + @RequestParam(defaultValue = "REVIEW_REQUIRED") String status, + @Parameter(description = "승인 위치 출처 필터. 생략하면 출처와 무관하게 조회", + schema = @Schema(allowableValues = {"GOOGLE_GEOCODING", "GOOGLE_PLACES", "ADMIN"}), + example = "GOOGLE_PLACES") + @Pattern(regexp = "GOOGLE_GEOCODING|GOOGLE_PLACES|ADMIN") + @RequestParam(required = false) String source, + @Parameter(description = "0부터 시작하는 페이지 번호. page × size는 2,147,483,647 이하여야 함", example = "0") + @Min(0) @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기(기본 20, 최대 100)", example = "20") + @Min(1) @Max(100) @RequestParam(defaultValue = "20") int size, + HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, + adminRestaurantService.findLocationReviews(status, source, page, size)); + } + + @PostMapping("/{restaurantId}/location/place-candidates") + @Operation(summary = "식당 Places 후보 검색", description = """ + 저장된 식당 현지명과 위치 확인 주소로 Google Places 후보를 새로 조회합니다. + 클라이언트가 임의 검색어나 Place ID, 좌표를 보내지 않으며 서버가 일본·도쿄·지도 서비스 범위를 검증합니다. + 외부 유료 호출이므로 화면 진입만으로 자동 호출하지 말고 관리자가 검색을 요청할 때 호출하세요. + 검색 결과가 없거나 범위를 통과한 후보가 없으면 200과 빈 candidates를 반환합니다. + 각 selectionToken은 검색 당시 관리자, restaurantId, addressRevision, requestId, Place ID에 묶여 10분 동안 유효합니다. + """) + @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(examples = @ExampleObject( + name = "현재 주소의 후보 검색", value = """ + {"expectedAddressRevision": 2} + """))) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) + @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "RESTAURANT-004", message = "식당을 찾을 수 없습니다.") + @ApiErrorResponse(status = HttpStatus.SERVICE_UNAVAILABLE, code = "RESTAURANT-024", message = "Places 위치 확인을 사용할 수 없습니다") + @ApiErrorResponse(status = HttpStatus.TOO_MANY_REQUESTS, code = "RESTAURANT-025", message = "Places 호출 한도를 확인해주세요") + @ApiErrorResponse(status = HttpStatus.BAD_GATEWAY, code = "RESTAURANT-026", message = "Places 응답을 확인할 수 없습니다") + @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "RESTAURANT-028", message = "현재 위치 상태를 다시 확인해주세요") + public SuccessResponse searchLocationPlaces( + @PathVariable Long restaurantId, + @Valid @RequestBody SearchRestaurantPlacesRequest request, + HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, + adminRestaurantService.searchLocationPlaces(restaurantId, request)); + } + + @PostMapping("/{restaurantId}/location/place-selection") + @Operation(summary = "식당 Places 후보 선택", description = """ + 직전 후보 검색 응답의 addressRevision을 요청의 expectedAddressRevision으로 전달하고, + 선택한 후보의 selectionToken으로 한 후보를 선택합니다. + 토큰을 수정했거나 만료됐으면 400, 다른 관리자·변경된 식당 정보·현재 작업·상태이거나 PENDING이면 409입니다. + 서버 인증 정보로 선택 관리자·선택 시각·확인 방식·당시 revision을 기록합니다. + 응답의 confirmedByAdminLoginId는 확인자 내부 ID로 서버가 조회한 현재 로그인 ID이며 요청으로 받지 않습니다. + 선택 직후 source는 null이며, Places Details 검증에 성공해 좌표를 저장하면 GOOGLE_PLACES가 됩니다. + 선택 즉시 기존 승인 좌표를 제거하고 새 PLACE_DETAILS 작업을 PENDING으로 등록합니다. + 200 응답은 선택 저장과 작업 등록 성공을 뜻하며 Places 상세 확인 완료를 뜻하지 않습니다. + locationStatus를 다시 조회해 READY와 validUntil을 확인하세요. + """) + @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(examples = @ExampleObject( + name = "검색 후보 선택", value = """ + { + "expectedAddressRevision": 2, + "selectionToken": "ZXlK...Q2Q" + } + """))) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, code = "RESTAURANT-027", message = "Places 선택 정보가 올바르지 않습니다") + @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "RESTAURANT-004", message = "식당을 찾을 수 없습니다.") + @ApiErrorResponse(status = HttpStatus.SERVICE_UNAVAILABLE, code = "RESTAURANT-024", message = "Places 위치 확인을 사용할 수 없습니다") + @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "RESTAURANT-028", message = "현재 위치 상태를 다시 확인해주세요") + public SuccessResponse selectLocationPlace( + @PathVariable Long restaurantId, + @Valid @RequestBody SelectRestaurantPlaceRequest request, + HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, + adminRestaurantService.selectLocationPlace(restaurantId, request)); + } } diff --git a/src/main/java/org/sopt/hashi/auth/AdminAccountPort.java b/src/main/java/org/sopt/hashi/auth/AdminAccountPort.java new file mode 100644 index 00000000..c2bd6bbe --- /dev/null +++ b/src/main/java/org/sopt/hashi/auth/AdminAccountPort.java @@ -0,0 +1,11 @@ +package org.sopt.hashi.auth; + +import java.util.Map; +import java.util.Set; + +/** 관리자 화면의 확인자 표시를 위한 읽기 전용 공개 포트. 현재 ADMIN 인증에서만 사용할 수 있다. */ +public interface AdminAccountPort { + + /** 현재 로그인 ID를 한 번에 조회한다. 없는 계정은 결과에서 제외하며, 과거 로그인 ID를 복원하지 않는다. */ + Map findLoginIds(Set adminIds); +} diff --git a/src/main/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortImpl.java b/src/main/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortImpl.java new file mode 100644 index 00000000..669924bd --- /dev/null +++ b/src/main/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortImpl.java @@ -0,0 +1,38 @@ +package org.sopt.hashi.auth.internal.admin; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.AdminAccountPort; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +class AdminAccountPortImpl implements AdminAccountPort { + + private final AdminRepository admins; + private final CurrentActorProvider actors; + + AdminAccountPortImpl(AdminRepository admins, CurrentActorProvider actors) { + this.admins = admins; + this.actors = actors; + } + + @Override + @Transactional(readOnly = true) + public Map findLoginIds(Set adminIds) { + if (actors.currentActor().type() != ActorType.ADMIN) { + throw new BusinessException(CommonErrorCode.FORBIDDEN); + } + if (adminIds.isEmpty()) { + return Map.of(); + } + return admins.findLoginIds(adminIds).stream().collect(Collectors.toUnmodifiableMap( + AdminRepository.AdminLoginIdProjection::getId, + AdminRepository.AdminLoginIdProjection::getLoginId)); + } +} diff --git a/src/main/java/org/sopt/hashi/auth/internal/admin/AdminRepository.java b/src/main/java/org/sopt/hashi/auth/internal/admin/AdminRepository.java index 7332bc29..7a12943d 100644 --- a/src/main/java/org/sopt/hashi/auth/internal/admin/AdminRepository.java +++ b/src/main/java/org/sopt/hashi/auth/internal/admin/AdminRepository.java @@ -1,9 +1,22 @@ package org.sopt.hashi.auth.internal.admin; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; interface AdminRepository extends JpaRepository { Optional findByLoginId(String loginId); + + @Query("select a.id as id, a.loginId as loginId from Admin a where a.id in :adminIds") + List findLoginIds(@Param("adminIds") Set adminIds); + + interface AdminLoginIdProjection { + Long getId(); + + String getLoginId(); + } } diff --git a/src/main/java/org/sopt/hashi/auth/internal/security/SecurityConfig.java b/src/main/java/org/sopt/hashi/auth/internal/security/SecurityConfig.java index c9ac25d6..1006e5ba 100644 --- a/src/main/java/org/sopt/hashi/auth/internal/security/SecurityConfig.java +++ b/src/main/java/org/sopt/hashi/auth/internal/security/SecurityConfig.java @@ -43,13 +43,14 @@ public class SecurityConfig { static final String MEDIA_PATH = "/api/v1/media/**"; /** * 식당 컬렉션 공유 열람(#216) — 공개 컬렉션의 상세·저장 식당 목록은 비로그인도 GET할 수 있다. - * 목록(/api/v1/collections)·쓰기 메서드는 회원 전용이라 GET 두 경로만 연다. 비공개 여부는 user Service가 판정한다. + * 목록(/api/v1/collections)·쓰기 메서드는 회원 전용이며 상세·목록·전체 핀 GET만 연다. 비공개 여부는 user Service가 판정한다. */ static final String[] TERMS_PUBLIC_GET_PATHS = {"/api/v1/terms", "/api/v1/terms/*"}; static final String[] NOTICE_PUBLIC_GET_PATHS = {"/api/v1/notices", "/api/v1/notices/*"}; static final String[] COLLECTION_PUBLIC_GET_PATHS = { "/api/v1/collections/*", - "/api/v1/collections/*/restaurants" + "/api/v1/collections/*/restaurants", + "/api/v1/collections/*/map-markers" }; /** 공개 경로 단일 소스 — {@link SwaggerAuthorizationCustomizer}가 같은 목록으로 문서 자물쇠를 판정한다. */ static final String[] PUBLIC_PATHS = { diff --git a/src/main/java/org/sopt/hashi/dev/DevDataService.java b/src/main/java/org/sopt/hashi/dev/DevDataService.java index 609e545c..09e5e06a 100644 --- a/src/main/java/org/sopt/hashi/dev/DevDataService.java +++ b/src/main/java/org/sopt/hashi/dev/DevDataService.java @@ -15,6 +15,7 @@ import org.sopt.hashi.user.dev.DevUserDataGenerator; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; /** @@ -54,7 +55,8 @@ public DevDataService(DevRestaurantDataGenerator restaurantGenerator, this.devTokenService = devTokenService; } - @Transactional + // 위치 작업을 추가하는 내부 REQUIRED 호출도 이 바깥 transaction의 격리 수준을 따른다. + @Transactional(isolation = Isolation.READ_COMMITTED) public DummyScenarioResponse createScenario() { Long restaurantId = restaurantGenerator.createRestaurant(); List userIds = userGenerator.createUsers(USER_COUNT); diff --git a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java index 33f29e1b..e7bf1313 100644 --- a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java +++ b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java @@ -24,6 +24,7 @@ public record AdminRestaurantCommand( String summary, String description, String address, + String geocodingAddress, String area, String genre, String foodCategory, @@ -39,6 +40,18 @@ public record AdminRestaurantCommand( List curationTypes, List businessHours) { + /** geocodingAddress 도입 전 모듈 내부 호출부의 source compatibility를 유지한다. */ + public AdminRestaurantCommand( + String name, String localName, String summary, String description, String address, + String area, String genre, String foodCategory, String placeType, String priceCurrency, + BigDecimal minPrice, BigDecimal maxPrice, List imageKeys, List imageAssetIds, + List images, List menus, List hashtags, + List curationTypes, List businessHours) { + this(name, localName, summary, description, address, null, area, genre, foodCategory, placeType, + priceCurrency, minPrice, maxPrice, imageKeys, imageAssetIds, images, menus, hashtags, + curationTypes, businessHours); + } + /** 수정 collection의 유지 association 또는 신규 asset. 배열 위치가 최종 순서다. */ public record ImageCommand(Long restaurantImageId, UUID imageAssetId) { } diff --git a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantInfo.java b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantInfo.java index 8f1d7857..50d7952f 100644 --- a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantInfo.java +++ b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantInfo.java @@ -19,6 +19,7 @@ public record AdminRestaurantInfo( String summary, String description, String address, + String geocodingAddress, String area, String genre, String foodCategory, @@ -35,7 +36,9 @@ public record AdminRestaurantInfo( List hashtags, List curationTypes, List businessHours, - LocalDateTime createdAt) { + LocalDateTime createdAt, + String locationStatus, + long addressRevision) { public record AdminRestaurantMenuInfo( Long menuId, diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationInfo.java new file mode 100644 index 00000000..7ef2c51c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationInfo.java @@ -0,0 +1,17 @@ +package org.sopt.hashi.restaurant; + +import java.time.Instant; + +/** 관리자용 값 계약. 내부 상태 enum, 주소, 좌표, 요청/lease 식별자를 노출하지 않는다. */ +public record RestaurantLocationInfo(Long restaurantId, String locationStatus, long addressRevision, + String source, String verificationMode, + Instant validUntil, int attempt, Instant nextAttemptAt, + String failureCode, boolean canRetry, String confirmationMethod, + Long confirmedByAdminId, Instant confirmedAt, Long confirmedRevision) { + public RestaurantLocationInfo(Long restaurantId, String locationStatus, long addressRevision, + String source, String verificationMode, Instant validUntil, int attempt, + Instant nextAttemptAt, String failureCode, boolean canRetry) { + this(restaurantId, locationStatus, addressRevision, source, verificationMode, validUntil, attempt, + nextAttemptAt, failureCode, canRetry, null, null, null, null); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationPreviewCommand.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationPreviewCommand.java new file mode 100644 index 00000000..60f0ba97 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationPreviewCommand.java @@ -0,0 +1,15 @@ +package org.sopt.hashi.restaurant; + +/** 등록 전 위치 확인에 사용한 입력. 최종 등록도 같은 네 필드를 보내야 한다. */ +public record RestaurantLocationPreviewCommand(String name, String localName, String address, + String geocodingAddress) { + public static RestaurantLocationPreviewCommand from(AdminRestaurantCommand command) { + return new RestaurantLocationPreviewCommand(command.name(), command.localName(), command.address(), + command.geocodingAddress()); + } + + @Override + public String toString() { + return "RestaurantLocationPreviewCommand[redacted]"; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationReviewInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationReviewInfo.java new file mode 100644 index 00000000..836d5287 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationReviewInfo.java @@ -0,0 +1,28 @@ +package org.sopt.hashi.restaurant; + +import java.time.Instant; + +/** 관리자 위치 검토 목록용 공개 정보. 좌표와 provider 원문, 내부 작업 ID는 노출하지 않는다. */ +public record RestaurantLocationReviewInfo( + Long restaurantId, + String name, + String address, + String geocodingAddress, + String locationStatus, + String source, + String verificationMode, + long addressRevision, + Instant validUntil, + int attempt, + Instant nextAttemptAt, + String failureCode, + boolean canRetry, + Long confirmedByAdminId) { + public RestaurantLocationReviewInfo(Long restaurantId, String name, String address, String geocodingAddress, + String locationStatus, String source, String verificationMode, + long addressRevision, Instant validUntil, int attempt, + Instant nextAttemptAt, String failureCode, boolean canRetry) { + this(restaurantId, name, address, geocodingAddress, locationStatus, source, verificationMode, + addressRevision, validUntil, attempt, nextAttemptAt, failureCode, canRetry, null); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationReviewPage.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationReviewPage.java new file mode 100644 index 00000000..31918a59 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantLocationReviewPage.java @@ -0,0 +1,16 @@ +package org.sopt.hashi.restaurant; + +import java.util.List; + +/** 식당 ID 오름차순 관리자 offset 페이지. */ +public record RestaurantLocationReviewPage( + List restaurants, + int page, + int size, + long totalCount, + int totalPages) { + + public RestaurantLocationReviewPage { + restaurants = List.copyOf(restaurants); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantMapInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantMapInfo.java new file mode 100644 index 00000000..cc23ba62 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantMapInfo.java @@ -0,0 +1,26 @@ +package org.sopt.hashi.restaurant; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + +/** 공개 식당의 최소 값 정보. location이 null이면 목록은 유지하고 핀만 생략한다. */ +public record RestaurantMapInfo(Long restaurantId, String name, String placeType, String genre, + LocationInfo location) { + + /** validUntil은 UTC 절대 시각이며 이 시각부터 좌표를 표시할 수 없다. */ + public record LocationInfo(BigDecimal latitude, BigDecimal longitude, Instant validUntil, + @Schema(description = "Google이 제공한 제3자 출처. 비어 있지 않으면 좌표와 함께 이름과 링크를 표시하고 좌표 만료 시 같이 제거. Google Maps 자체 로고/출처 표기와 별개") + List attributions) { + public LocationInfo { + attributions = List.copyOf(attributions); + } + + public LocationInfo(BigDecimal latitude, BigDecimal longitude, Instant validUntil) { + this(latitude, longitude, validUntil, List.of()); + } + } + + public record AttributionInfo(String displayName, String uri) { } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantPlacesCandidateInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantPlacesCandidateInfo.java new file mode 100644 index 00000000..ecf32edf --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantPlacesCandidateInfo.java @@ -0,0 +1,24 @@ +package org.sopt.hashi.restaurant; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** Places 검색 응답의 일회성 후보. provider 원문과 Place ID는 공개 계약에 포함하지 않는다. */ +public record RestaurantPlacesCandidateInfo( + String displayName, + String address, + BigDecimal latitude, + BigDecimal longitude, + String countryCode, + String administrativeArea, + List types, + String businessStatus, + List attributions, + String googleMapsUri, + String selectionToken, + Instant selectionExpiresAt) { + + public record Attribution(String displayName, String uri) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantPlacesSearchInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantPlacesSearchInfo.java new file mode 100644 index 00000000..4f744d68 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantPlacesSearchInfo.java @@ -0,0 +1,7 @@ +package org.sopt.hashi.restaurant; + +import java.util.List; + +public record RestaurantPlacesSearchInfo(Long restaurantId, long addressRevision, + List candidates) { +} diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantPort.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantPort.java index b03a5a2e..2fdc387a 100644 --- a/src/main/java/org/sopt/hashi/restaurant/RestaurantPort.java +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantPort.java @@ -31,12 +31,19 @@ public interface RestaurantPort { */ List findActiveDetails(Collection restaurantIds); + /** + * 컬렉션 목록/핀용 공개 식당 정보. 삭제/없는 식당은 생략하고 위치 미준비/만료 식당은 location=null로 남긴다. + * 최초 입력 순서를 유지하고 중복 ID는 제거한다. null/빈 입력은 빈 목록, null/0/음수 ID는 잘못된 입력이다. + * 내부 500개 단위로 조회하며 전체 결과를 모은 뒤 반환한다. 중간 조회 실패는 부분 결과 없이 전파한다. + * 반환된 좌표도 validUntil부터 사용할 수 없으며 호출자는 응답 직전 자신의 권한/컬렉션 버전을 재검사한다. + */ + List findActiveMapInfos(Collection restaurantIds); + /** * 사용자 노출용 식당 카드 목록 — 저장 컬렉션처럼 카드 표시와 평점·리뷰 수 정렬, 음식점 분류 필터가 필요한 enrich용(#216). * 상세와 달리 영업시간·이미지 전체·가격대는 읽지 않는다. 삭제된 식당은 제외하고 존재하는 식당만 반환하며 순서는 보장하지 않는다. */ List findActiveCards(Collection restaurantIds); - /** 리뷰 생성 시 식당 평점 합계·리뷰 수·평균을 원자적으로 증가시킨다. */ void increaseReviewStatistics(Long restaurantId, int rating); @@ -49,6 +56,12 @@ public interface RestaurantPort { /** 어드민 식당 등록 — 이미지·메뉴 사진은 업로드 완료된 S3 키로 받는다. */ AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command); + /** 등록 전 후보 토큰을 검증한 뒤 식당과 Places 상세 확인 작업을 같은 transaction으로 저장한다. */ + AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command, String locationSelectionToken); + + /** 식당을 저장하지 않고 후보를 조회한다. 반환 토큰은 입력 네 필드와 인증 관리자에 묶인다. */ + List previewLocationByAdmin(RestaurantLocationPreviewCommand command); + /** * 어드민 식당 수정 — 부분 수정(PATCH). null 필드는 변경하지 않고, 컬렉션은 전체 교체한다. * 식당이 없으면 BusinessException(RESTAURANT-004 NOT_FOUND). @@ -60,4 +73,21 @@ public interface RestaurantPort { * 데이터는 보존한다. 식당이 없으면 BusinessException(RESTAURANT-004 NOT_FOUND). */ void deleteByAdmin(Long restaurantId); + + /** 관리자 위치 상태 조회. Entity/내부 enum과 provider 원문을 반환하지 않는다. */ + RestaurantLocationInfo getLocationByAdmin(Long restaurantId); + + /** expectedAddressRevision이 현재와 일치할 때 같은 주소의 새 작업을 등록한다. */ + RestaurantLocationInfo retryLocationByAdmin(Long restaurantId, long expectedAddressRevision); + + /** 관리자 위치 검토 목록. restaurantId 오름차순 offset 페이지로 활성 식당만 조회한다. */ + RestaurantLocationReviewPage findLocationReviewsByAdmin( + String status, String source, int page, int size); + + /** 저장된 식당명과 위치 확인 주소로 Places 후보를 조회한다. */ + RestaurantPlacesSearchInfo searchLocationPlacesByAdmin(Long restaurantId, long expectedAddressRevision); + + /** 서명된 후보를 현재 위치 request에 적용하고 PLACE_DETAILS 작업을 등록한다. */ + RestaurantLocationInfo selectLocationPlaceByAdmin( + Long restaurantId, long expectedAddressRevision, String selectionToken); } diff --git a/src/main/java/org/sopt/hashi/restaurant/code/RestaurantErrorCode.java b/src/main/java/org/sopt/hashi/restaurant/code/RestaurantErrorCode.java index 3015586b..26b13e7f 100644 --- a/src/main/java/org/sopt/hashi/restaurant/code/RestaurantErrorCode.java +++ b/src/main/java/org/sopt/hashi/restaurant/code/RestaurantErrorCode.java @@ -18,9 +18,26 @@ public enum RestaurantErrorCode implements ErrorCode { RECOMMENDATION_NOT_FOUND(HttpStatus.NOT_FOUND, "RESTAURANT-008", "추천 가능한 식당이 없습니다."), MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "RESTAURANT-009", "메뉴를 찾을 수 없습니다."), UNSUPPORTED_PLACE_TYPE(HttpStatus.BAD_REQUEST, "RESTAURANT-010", "지원하지 않는 음식점 분류입니다."), + MAP_BOUNDS_INVALID(HttpStatus.BAD_REQUEST, "RESTAURANT-011", "조회할 지도 범위가 올바르지 않습니다."), + MAP_REGION_INVALID(HttpStatus.BAD_REQUEST, "RESTAURANT-012", "조회할 관광 지역이 없거나 비활성 상태입니다."), + MAP_SESSION_EXPIRED(HttpStatus.GONE, "RESTAURANT-013", "지도 조회가 만료되었습니다. 새로 조회해주세요"), + MAP_SESSION_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "RESTAURANT-014", "지도 조회를 이어갈 수 없습니다."), + MAP_QUERY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "RESTAURANT-015", "지도 정보를 조회할 수 없습니다."), + MAP_CAPACITY_EXCEEDED(HttpStatus.SERVICE_UNAVAILABLE, "RESTAURANT-016", + "지도 조회 수용량이 일시적으로 부족합니다. 잠시 후 다시 시도해주세요."), + MAP_CONFIGURATION_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "RESTAURANT-017", "지도 설정이 준비되지 않았습니다."), + MAP_LOCATION_UNAVAILABLE(HttpStatus.CONFLICT, "RESTAURANT-018", "현재 표시할 수 있는 식당 위치가 없습니다."), + LOCATION_RETRY_CONFLICT(HttpStatus.CONFLICT, "RESTAURANT-019", "현재 주소의 위치 상태를 다시 확인해주세요"), // RESTAURANT-011~019는 진행 중 기능 브랜치에 이미 배정돼 있어 #230은 020부터 쓴다 — 번호 재사용 금지 DUPLICATE_NAME(HttpStatus.CONFLICT, "RESTAURANT-020", "이미 등록된 식당명입니다."), - DUPLICATE_ADDRESS(HttpStatus.CONFLICT, "RESTAURANT-021", "이미 등록된 주소입니다."); + DUPLICATE_ADDRESS(HttpStatus.CONFLICT, "RESTAURANT-021", "이미 등록된 주소입니다."), + MAP_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, "RESTAURANT-022", "지도 조회 요청이 많습니다. 잠시 후 다시 시도해주세요."), + // RESTAURANT-023 is reserved for the admin tourist-region API (#256). + PLACES_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "RESTAURANT-024", "Places 위치 확인을 사용할 수 없습니다"), + PLACES_BUDGET_EXHAUSTED(HttpStatus.TOO_MANY_REQUESTS, "RESTAURANT-025", "Places 호출 한도를 확인해주세요"), + PLACES_PROVIDER_FAILED(HttpStatus.BAD_GATEWAY, "RESTAURANT-026", "Places 응답을 확인할 수 없습니다"), + PLACE_SELECTION_INVALID(HttpStatus.BAD_REQUEST, "RESTAURANT-027", "Places 선택 정보가 올바르지 않습니다"), + PLACE_SELECTION_CONFLICT(HttpStatus.CONFLICT, "RESTAURANT-028", "현재 위치 상태를 다시 확인해주세요"); private final HttpStatus status; private final String code; diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/GeocodingBudget.java b/src/main/java/org/sopt/hashi/restaurant/domain/GeocodingBudget.java new file mode 100644 index 00000000..a3986d68 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/GeocodingBudget.java @@ -0,0 +1,64 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** 모든 서버가 공유하는 운영 제어 행. 배포/재시작에서 운영값을 덮어쓰지 않는다. */ +@Entity +@Table(name = "restaurant_geocoding_budget") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GeocodingBudget { + @Id + private Long id; + @Column(nullable = false) + private boolean enabled; + @Column(nullable = false) + private int dailyLimit; + @Column(nullable = false) + private int maxConcurrent; + private LocalDate budgetDay; + @Column(nullable = false) + private int reservedCalls; + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + private LocalDateTime blockedUntil; + + /** Read-only polling hint; claim must recheck while holding the shared budget lock. */ + public boolean canReserve(LocalDateTime now, long running) { + if (!enabled || dailyLimit <= 0 || running >= maxConcurrent + || (blockedUntil != null && now.isBefore(blockedUntil))) { + return false; + } + if (budgetDay == null || budgetDay.isBefore(now.toLocalDate())) { + return true; + } + return budgetDay.equals(now.toLocalDate()) && reservedCalls < dailyLimit; + } + + public boolean reserve(LocalDateTime now, long running) { + if (!canReserve(now, running)) { + return false; + } + if (budgetDay == null || budgetDay.isBefore(now.toLocalDate())) { + budgetDay = now.toLocalDate(); + reservedCalls = 0; + } + reservedCalls++; + return true; + } + + public void blockUntil(LocalDateTime until) { + if (blockedUntil == null || blockedUntil.isBefore(until)) { + blockedUntil = until; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/GeocodingBudgetRepository.java b/src/main/java/org/sopt/hashi/restaurant/domain/GeocodingBudgetRepository.java new file mode 100644 index 00000000..7c1bd561 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/GeocodingBudgetRepository.java @@ -0,0 +1,17 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; + +public interface GeocodingBudgetRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select b from GeocodingBudget b where b.id = 1") + Optional findControlForUpdate(); + + // A string avoids the driver's Timestamp/session-zone conversion at the clock boundary. + @Query(value = "SELECT DATE_FORMAT(UTC_TIMESTAMP(6), '%Y-%m-%dT%H:%i:%s.%f')", nativeQuery = true) + String databaseUtcTime(); +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/LocationConfirmationMethod.java b/src/main/java/org/sopt/hashi/restaurant/domain/LocationConfirmationMethod.java new file mode 100644 index 00000000..aceabda1 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/LocationConfirmationMethod.java @@ -0,0 +1,7 @@ +package org.sopt.hashi.restaurant.domain; + +/** 좌표 제공자(source)와 별개로 누가 어떤 방식으로 위치를 확인했는지 기록한다. */ +public enum LocationConfirmationMethod { + AUTOMATIC, + ADMIN_PLACE_SELECTION +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/MapBounds.java b/src/main/java/org/sopt/hashi/restaurant/domain/MapBounds.java new file mode 100644 index 00000000..9493fcb8 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/MapBounds.java @@ -0,0 +1,53 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.math.BigDecimal; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 경계를 포함하는 BBOX. 도쿄 1차에서는 날짜변경선 횡단(west > east)을 지원하지 않는다. */ +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MapBounds { + + @Column(name = "south", precision = 9, scale = 6, nullable = false) + private BigDecimal south; + + @Column(name = "north", precision = 9, scale = 6, nullable = false) + private BigDecimal north; + + @Column(name = "west", precision = 10, scale = 6, nullable = false) + private BigDecimal west; + + @Column(name = "east", precision = 10, scale = 6, nullable = false) + private BigDecimal east; + + private MapBounds(BigDecimal south, BigDecimal north, BigDecimal west, BigDecimal east) { + this.south = MapCoordinates.requireLatitude(south); + this.north = MapCoordinates.requireLatitude(north); + this.west = MapCoordinates.requireLongitude(west); + this.east = MapCoordinates.requireLongitude(east); + boolean hasArea = this.south.compareTo(this.north) < 0 && this.west.compareTo(this.east) < 0; + if (!hasArea) { + throw new IllegalArgumentException("BBOX는 south < north, west < east여야 합니다"); + } + } + + public static MapBounds of(BigDecimal south, BigDecimal north, BigDecimal west, BigDecimal east) { + return new MapBounds(south, north, west, east); + } + + public boolean contains(MapCoordinates coordinates) { + Objects.requireNonNull(coordinates, "coordinates"); + return coordinates.getLatitude().compareTo(south) >= 0 + && coordinates.getLatitude().compareTo(north) <= 0 + && coordinates.getLongitude().compareTo(west) >= 0 + && coordinates.getLongitude().compareTo(east) <= 0; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/MapCoordinates.java b/src/main/java/org/sopt/hashi/restaurant/domain/MapCoordinates.java new file mode 100644 index 00000000..f81d3c30 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/MapCoordinates.java @@ -0,0 +1,56 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.math.BigDecimal; +import java.math.RoundingMode; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** WGS84 좌표 쌍. 저장 정밀도와 실제 위치 정확도는 별개이며 암묵적으로 반올림하지 않는다. */ +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MapCoordinates { + + private static final int SCALE = 6; + private static final BigDecimal MAX_LATITUDE = new BigDecimal("90"); + private static final BigDecimal MAX_LONGITUDE = new BigDecimal("180"); + + @Column(name = "latitude", precision = 9, scale = 6) + private BigDecimal latitude; + + @Column(name = "longitude", precision = 10, scale = 6) + private BigDecimal longitude; + + private MapCoordinates(BigDecimal latitude, BigDecimal longitude) { + this.latitude = requireLatitude(latitude); + this.longitude = requireLongitude(longitude); + } + + public static MapCoordinates of(BigDecimal latitude, BigDecimal longitude) { + return new MapCoordinates(latitude, longitude); + } + + static BigDecimal requireLatitude(BigDecimal value) { + return requireCoordinate(value, MAX_LATITUDE); + } + + static BigDecimal requireLongitude(BigDecimal value) { + return requireCoordinate(value, MAX_LONGITUDE); + } + + private static BigDecimal requireCoordinate(BigDecimal value, BigDecimal maximum) { + if (value == null || value.abs().compareTo(maximum) > 0) { + throw new IllegalArgumentException("좌표가 허용 범위를 벗어났습니다"); + } + try { + return value.setScale(SCALE, RoundingMode.UNNECESSARY); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("좌표는 소수점 6자리까지 저장할 수 있습니다", exception); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/MapQueryBounds.java b/src/main/java/org/sopt/hashi/restaurant/domain/MapQueryBounds.java new file mode 100644 index 00000000..565405e1 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/MapQueryBounds.java @@ -0,0 +1,44 @@ +package org.sopt.hashi.restaurant.domain; + +import java.math.BigDecimal; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; + +/** 조회 경계는 저장 좌표와 달리 SDK의 정밀도를 그대로 유지한다. 반올림하거나 절삭하지 않는다. */ +public record MapQueryBounds(BigDecimal south, BigDecimal north, BigDecimal west, BigDecimal east) { + + public MapQueryBounds { + boolean valid = inRange(south, 90) && inRange(north, 90) + && inRange(west, 180) && inRange(east, 180) + && south.compareTo(north) < 0 && west.compareTo(east) < 0; + if (!valid) { + throw new BusinessException(RestaurantErrorCode.MAP_BOUNDS_INVALID); + } + } + + public static MapQueryBounds parse(String south, String north, String west, String east) { + try { + return new MapQueryBounds(new BigDecimal(south), new BigDecimal(north), + new BigDecimal(west), new BigDecimal(east)); + } catch (NumberFormatException | NullPointerException exception) { + throw new BusinessException(RestaurantErrorCode.MAP_BOUNDS_INVALID); + } + } + + public void validateQueryWithin(MapQueryBounds supported) { + boolean allowed = north.subtract(south).compareTo(BigDecimal.ONE) <= 0 + && east.subtract(west).compareTo(BigDecimal.ONE) <= 0 && supported.contains(this); + if (!allowed) { + throw new BusinessException(RestaurantErrorCode.MAP_BOUNDS_INVALID); + } + } + + public boolean contains(MapQueryBounds other) { + return other.south.compareTo(south) >= 0 && other.north.compareTo(north) <= 0 + && other.west.compareTo(west) >= 0 && other.east.compareTo(east) <= 0; + } + + private static boolean inRange(BigDecimal value, int maximum) { + return value != null && value.abs().compareTo(BigDecimal.valueOf(maximum)) <= 0; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/MapRegion.java b/src/main/java/org/sopt/hashi/restaurant/domain/MapRegion.java new file mode 100644 index 00000000..00c6bd8b --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/MapRegion.java @@ -0,0 +1,88 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.hashi.BaseTimeEntity; + +/** 행정구와 별개인 운영 관광 지역. 식당 Aggregate에서는 ID로만 참조한다. */ +@Getter +@Entity +@Table(name = "map_region") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MapRegion extends BaseTimeEntity { + + private static final Pattern BLANK_NAME = Pattern.compile("[\\p{javaWhitespace}\\p{IsWhite_Space}]*"); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "code", length = 40, nullable = false, unique = true) + private String code; + + @Column(name = "name", length = 100, nullable = false) + private String name; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "latitude", + column = @Column(name = "cluster_latitude", precision = 9, scale = 6, nullable = false)), + @AttributeOverride(name = "longitude", + column = @Column(name = "cluster_longitude", precision = 10, scale = 6, nullable = false)) + }) + private MapCoordinates clusterPosition; + + @Embedded + private MapBounds cameraBounds; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "active", nullable = false) + private boolean active; + + private MapRegion(String code, String name, MapCoordinates clusterPosition, + MapBounds cameraBounds, int displayOrder) { + if (code == null || !code.matches("[A-Z][A-Z0-9_]{0,39}")) { + throw new IllegalArgumentException("관광 지역 코드는 대문자 영문으로 시작하는 40자 이내 코드여야 합니다"); + } + if (name == null || BLANK_NAME.matcher(name).matches() || name.codePointCount(0, name.length()) > 100) { + throw new IllegalArgumentException("관광 지역 이름은 1~100자여야 합니다"); + } + Objects.requireNonNull(cameraBounds, "cameraBounds"); + if (!cameraBounds.contains(clusterPosition) || displayOrder < 0) { + throw new IllegalArgumentException("대표 좌표는 카메라 범위 안에 있고 표시 순서는 0 이상이어야 합니다"); + } + this.code = code; + this.name = name; + this.clusterPosition = clusterPosition; + this.cameraBounds = cameraBounds; + this.displayOrder = displayOrder; + this.active = false; + } + + public static MapRegion create(String code, String name, MapCoordinates clusterPosition, + MapBounds cameraBounds, int displayOrder) { + return new MapRegion(code, name, clusterPosition, cameraBounds, displayOrder); + } + + public void activate() { + this.active = true; + } + + public void deactivate() { + this.active = false; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/MapRegionRepository.java b/src/main/java/org/sopt/hashi/restaurant/domain/MapRegionRepository.java new file mode 100644 index 00000000..b42bedea --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/MapRegionRepository.java @@ -0,0 +1,9 @@ +package org.sopt.hashi.restaurant.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MapRegionRepository extends JpaRepository { + + List findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/MapRegionSummary.java b/src/main/java/org/sopt/hashi/restaurant/domain/MapRegionSummary.java new file mode 100644 index 00000000..bab5cd3b --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/MapRegionSummary.java @@ -0,0 +1,8 @@ +package org.sopt.hashi.restaurant.domain; + +import java.math.BigDecimal; + +public record MapRegionSummary(Long id, String name, BigDecimal latitude, BigDecimal longitude, + MapQueryBounds bounds, int displayOrder, long restaurantCount, + long outsideBoundsCount) { +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/MapSearchCriteria.java b/src/main/java/org/sopt/hashi/restaurant/domain/MapSearchCriteria.java new file mode 100644 index 00000000..cb6f0c0a --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/MapSearchCriteria.java @@ -0,0 +1,68 @@ +package org.sopt.hashi.restaurant.domain; + +import java.util.List; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; + +/** HTTP 페이지 API와 Redis 세션이 공통으로 사용할 정규화된 조회 조건. */ +public record MapSearchCriteria(MapQueryBounds bounds, Long mapRegionId, RestaurantGenre genre, + RestaurantPlaceType placeType, String keyword) { + + public MapSearchCriteria { + if (bounds == null || (mapRegionId != null && mapRegionId <= 0)) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + keyword = normalizeKeyword(keyword); + } + + public static MapSearchCriteria of(MapQueryBounds bounds, Long regionId, String genre, + String placeType, String keyword) { + RestaurantGenre parsedGenre = genre == null ? null : RestaurantGenre.from(genre) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.UNSUPPORTED_GENRE)); + RestaurantPlaceType parsedType = placeType == null ? null : RestaurantPlaceType.from(placeType) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.UNSUPPORTED_PLACE_TYPE)); + return new MapSearchCriteria(bounds, regionId, parsedGenre, parsedType, keyword); + } + + public boolean hashtagOnly() { + return keyword != null && keyword.startsWith("#"); + } + + /** !를 명시적 LIKE escape 문자로 사용한다. 사용자 입력의 !, %, _ 모두 리터럴이다. */ + public List keywordPatterns() { + if (keyword == null) { + return List.of(); + } + String searchText = hashtagOnly() ? keyword.substring(1) : keyword; + return RestaurantSearchKeyword.terms(searchText).stream() + .map(RestaurantSearchKeyword::containsPattern) + .toList(); + } + + private static String normalizeKeyword(String keyword) { + if (keyword == null) { + return null; + } + boolean forbidden = keyword.codePoints().anyMatch(codePoint -> Character.isISOControl(codePoint) + || Character.getType(codePoint) == Character.FORMAT + || Character.getType(codePoint) == Character.LINE_SEPARATOR + || Character.getType(codePoint) == Character.PARAGRAPH_SEPARATOR); + if (forbidden) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + String normalized = RestaurantSearchKeyword.normalize(keyword); + if (normalized == null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + int length = normalized.codePointCount(0, normalized.length()); + if (length > 30) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + if (normalized.startsWith("#") + && (normalized.length() == 1 || RestaurantSearchKeyword.terms(normalized).size() != 1)) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + return normalized; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/MapSearchResultExtent.java b/src/main/java/org/sopt/hashi/restaurant/domain/MapSearchResultExtent.java new file mode 100644 index 00000000..ce534201 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/MapSearchResultExtent.java @@ -0,0 +1,34 @@ +package org.sopt.hashi.restaurant.domain; + +import java.math.BigDecimal; +import java.time.Instant; + +/** 검색 조건에 맞는 전체 식당 수, 모든 유효 좌표의 경계와 가장 이른 만료. 0건이면 경계와 만료는 null이다. */ +public record MapSearchResultExtent(long totalCount, ResultBounds bounds, Instant earliestValidUntil) { + + public MapSearchResultExtent { + boolean empty = totalCount == 0; + if (totalCount < 0 || empty != (bounds == null) || empty != (earliestValidUntil == null)) { + throw new IllegalArgumentException("검색 결과 수와 경계 및 좌표 만료 시각이 일치해야 합니다"); + } + } + + public static MapSearchResultExtent empty() { + return new MapSearchResultExtent(0, null, null); + } + + /** 단일 결과는 남북 또는 동서 값이 같을 수 있으므로 요청용 MapQueryBounds와 검증 규칙을 분리한다. */ + public record ResultBounds(BigDecimal south, BigDecimal north, BigDecimal west, BigDecimal east) { + + public ResultBounds { + if (!inRange(south, 90) || !inRange(north, 90) || !inRange(west, 180) || !inRange(east, 180) + || south.compareTo(north) > 0 || west.compareTo(east) > 0) { + throw new IllegalArgumentException("검색 결과 경계가 올바르지 않습니다"); + } + } + + private static boolean inRange(BigDecimal value, int maximum) { + return value != null && value.abs().compareTo(BigDecimal.valueOf(maximum)) <= 0; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/PlacesBudget.java b/src/main/java/org/sopt/hashi/restaurant/domain/PlacesBudget.java new file mode 100644 index 00000000..97570a5c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/PlacesBudget.java @@ -0,0 +1,83 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** SEARCH와 DETAILS가 서로 예산을 잠식하지 않도록 operation별로 공유하는 DB 제어 행. */ +@Entity +@Table(name = "restaurant_places_budget") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PlacesBudget { + @Id + @Enumerated(EnumType.STRING) + @Column(length = 20) + private Operation operation; + @Column(nullable = false) + private boolean enabled; + @Column(nullable = false) + private int dailyLimit; + @Column(nullable = false) + private int minuteLimit; + private LocalDate budgetDay; + @Column(nullable = false) + private int dailyUsed; + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + private LocalDateTime minuteWindowStart; + @Column(nullable = false) + private int minuteUsed; + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + private LocalDateTime blockedUntil; + + public boolean canReserve(LocalDateTime now) { + if (!enabled || dailyLimit <= 0 || minuteLimit <= 0 + || (blockedUntil != null && now.isBefore(blockedUntil))) { + return false; + } + int effectiveDaily = budgetDay == null || budgetDay.isBefore(now.toLocalDate()) ? 0 : dailyUsed; + LocalDateTime minute = now.truncatedTo(ChronoUnit.MINUTES); + int effectiveMinute = minuteWindowStart == null || minuteWindowStart.isBefore(minute) ? 0 : minuteUsed; + return effectiveDaily < dailyLimit && effectiveMinute < minuteLimit; + } + + /** 호출 직전에 예약하며 provider 결과나 동시 주소 변경과 관계없이 환불하지 않는다. */ + public boolean reserve(LocalDateTime now) { + if (!canReserve(now)) { + return false; + } + if (budgetDay == null || budgetDay.isBefore(now.toLocalDate())) { + budgetDay = now.toLocalDate(); + dailyUsed = 0; + } + LocalDateTime minute = now.truncatedTo(ChronoUnit.MINUTES); + if (minuteWindowStart == null || minuteWindowStart.isBefore(minute)) { + minuteWindowStart = minute; + minuteUsed = 0; + } + dailyUsed++; + minuteUsed++; + return true; + } + + public void blockUntil(LocalDateTime until) { + if (blockedUntil == null || blockedUntil.isBefore(until)) { + blockedUntil = until; + } + } + + public enum Operation { + SEARCH, DETAILS + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/PlacesBudgetRepository.java b/src/main/java/org/sopt/hashi/restaurant/domain/PlacesBudgetRepository.java new file mode 100644 index 00000000..f614d615 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/PlacesBudgetRepository.java @@ -0,0 +1,15 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.sopt.hashi.restaurant.domain.PlacesBudget.Operation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PlacesBudgetRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select b from PlacesBudget b where b.operation = :operation") + Optional findByOperationForUpdate(@Param("operation") Operation operation); +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java b/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java index dd8ecad8..32960f06 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java @@ -13,13 +13,17 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; import java.math.BigDecimal; +import java.time.Clock; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -60,9 +64,22 @@ public class Restaurant extends BaseTimeEntity { @Column(name = "address", length = 255, nullable = false) private String address; + /** null이면 기존 표시 주소를 위치 확인 입력으로 사용한다. */ + @Column(name = "geocoding_address", length = 255) + private String geocodingAddress; + @Column(name = "area", length = 20, nullable = false) private String area; + /** 운영 관광 지역은 별도 Aggregate의 ID로만 참조한다. null은 미분류다. */ + @Column(name = "map_region_id") + private Long mapRegionId; + + /** 소유 측 FK로 위치 없음도 추가 SELECT 없이 판별한다. soft delete 시 자식은 보존한다. */ + @OneToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + @JoinColumn(name = "location_id", unique = true) + private RestaurantLocation location; + @Enumerated(EnumType.STRING) @Column(name = "genre", length = 20, nullable = false) private RestaurantGenre genre; @@ -125,6 +142,7 @@ public class Restaurant extends BaseTimeEntity { private List businessHours = new ArrayList<>(); private Restaurant(String name, String localName, String summary, String description, String address, + String geocodingAddress, String area, RestaurantGenre genre, String foodCategory, RestaurantPlaceType placeType, PriceCurrency priceCurrency, BigDecimal minPrice, BigDecimal maxPrice) { this.name = name; @@ -132,6 +150,7 @@ private Restaurant(String name, String localName, String summary, String descrip this.summary = summary; this.description = description; this.address = address; + this.geocodingAddress = normalizeGeocodingAddress(geocodingAddress); this.area = area; this.genre = genre; this.foodCategory = foodCategory; @@ -145,20 +164,32 @@ private Restaurant(String name, String localName, String summary, String descrip this.deleted = false; } + public static Restaurant create(String name, String localName, String summary, String description, + String address, String geocodingAddress, String area, RestaurantGenre genre, + String foodCategory, RestaurantPlaceType placeType, + PriceCurrency priceCurrency, BigDecimal minPrice, + BigDecimal maxPrice) { + return new Restaurant(name, localName, summary, description, address, geocodingAddress, area, genre, + foodCategory, placeType, priceCurrency, minPrice, maxPrice); + } + + /** geocodingAddress 도입 전 도메인 호출부의 source compatibility를 유지한다. */ public static Restaurant create(String name, String localName, String summary, String description, String address, String area, RestaurantGenre genre, String foodCategory, RestaurantPlaceType placeType, PriceCurrency priceCurrency, BigDecimal minPrice, BigDecimal maxPrice) { - return new Restaurant(name, localName, summary, description, address, area, genre, foodCategory, + return create(name, localName, summary, description, address, null, area, genre, foodCategory, placeType, priceCurrency, minPrice, maxPrice); } - /** 부분 수정(PATCH) — null 필드는 기존 값을 유지한다(값 비우기 불가, magazine과 동일 정책). */ + /** 부분 수정(PATCH) — null은 유지하며 geocodingAddress만 공백으로 명시 값을 지울 수 있다. */ public void updateBasicInfo(String name, String localName, String summary, String description, - String address, String area, RestaurantGenre genre, + String address, String geocodingAddress, String area, RestaurantGenre genre, String foodCategory, RestaurantPlaceType placeType, PriceCurrency priceCurrency, BigDecimal minPrice, BigDecimal maxPrice) { + boolean nameChanged = name != null && !Objects.equals(this.name, name); + boolean localNameChanged = localName != null && !Objects.equals(this.localName, localName); if (name != null) { this.name = name; } @@ -171,9 +202,23 @@ public void updateBasicInfo(String name, String localName, String summary, Strin if (description != null) { this.description = description; } - if (address != null) { + String previousGeocodingInput = geocodingAddressForResolution(); + boolean addressChanged = address != null && !Objects.equals(this.address, address); + boolean geocodingAddressProvided = geocodingAddress != null; + String nextGeocodingAddress = geocodingAddressProvided + ? normalizeGeocodingAddress(geocodingAddress) + : addressChanged ? null : this.geocodingAddress; + if (addressChanged) { this.address = address; } + if (geocodingAddressProvided || addressChanged) { + this.geocodingAddress = nextGeocodingAddress; + } + boolean geocodingInputChanged = !Objects.equals(previousGeocodingInput, geocodingAddressForResolution()); + if ((nameChanged || localNameChanged || addressChanged || geocodingInputChanged) + && location != null && !deleted) { + location.addressChanged(); + } if (area != null) { this.area = area; } @@ -197,11 +242,137 @@ public void updateBasicInfo(String name, String localName, String summary, Strin } } + /** geocodingAddress 도입 전 호출부는 base 미전송으로 해석한다. */ + public void updateBasicInfo(String name, String localName, String summary, String description, + String address, String area, RestaurantGenre genre, + String foodCategory, RestaurantPlaceType placeType, + PriceCurrency priceCurrency, BigDecimal minPrice, BigDecimal maxPrice) { + updateBasicInfo(name, localName, summary, description, address, null, area, genre, foodCategory, + placeType, priceCurrency, minPrice, maxPrice); + } + + /** 자동 문자열 추측 없이 명시 base를 우선하고, 기존 row는 표시 주소로 호환 처리한다. */ + public String geocodingAddressForResolution() { + return geocodingAddress == null ? address : geocodingAddress; + } + + public static String normalizeGeocodingAddress(String value) { + if (value == null) { + return null; + } + int start = 0; + int end = value.length(); + while (start < end) { + int codePoint = value.codePointAt(start); + if (!isAddressWhitespace(codePoint)) { + break; + } + start += Character.charCount(codePoint); + } + while (start < end) { + int codePoint = value.codePointBefore(end); + if (!isAddressWhitespace(codePoint)) { + break; + } + end -= Character.charCount(codePoint); + } + return start == end ? null : value.substring(start, end); + } + + private static boolean isAddressWhitespace(int codePoint) { + return Character.isWhitespace(codePoint) || Character.isSpaceChar(codePoint); + } + /** 어드민 삭제(soft delete) — 사용자 노출만 차단하고 예약·리뷰가 참조하는 데이터는 보존한다. */ public void softDelete() { this.deleted = true; } + /** 실제 지역 존재·활성 검증은 후속 운영 Service에서 수행한다. null로 미분류로 되돌릴 수 있다. */ + public void assignMapRegion(Long mapRegionId) { + if (mapRegionId != null && mapRegionId <= 0) { + throw new IllegalArgumentException("관광 지역 ID는 양수여야 합니다"); + } + this.mapRegionId = mapRegionId; + } + + /** 후속 Service가 식당 잠금과 같은 transaction에서 호출하고 durable 작업을 연결한다. */ + public void requestLocationResolution() { + requireActiveForLocation(); + if (location == null) { + location = RestaurantLocation.pending(); + } else { + location.requestRetry(); + } + } + + public void refreshLocation() { + requireActiveForLocation(); + if (location == null) { + throw new IllegalStateException("갱신할 위치가 없습니다"); + } + location.beginRefresh(); + } + + /** Retention also applies to soft-deleted restaurants; original restaurant data stays intact. */ + public boolean purgeGoogleLocation(long revision, UUID request, LocalDateTime obtained, LocalDateTime until, + LocalDateTime purgeBefore) { + return location != null && location.purgeGoogle(revision, request, obtained, until, purgeBefore); + } + + /** 관리자가 새 Places 후보를 선택하면 잘못된 기존 좌표를 즉시 제거하고 새 requestId를 발급한다. */ + public void selectPlaceForLocation() { + requireActiveForLocation(); + if (location == null) { + throw new IllegalStateException("선택할 위치가 없습니다"); + } + location.selectPlaceForVerification(); + } + + public void confirmPlaceSelection(Long adminId, LocalDateTime confirmedAt) { + requireActiveForLocation(); + if (location == null) { + throw new IllegalStateException("확인할 위치가 없습니다"); + } + location.confirmPlaceSelection(adminId, confirmedAt); + } + + public boolean retryLocationWhenDue(Clock clock) { + return !deleted && location != null && location.beginScheduledRetry(clock); + } + + /** 후속 worker는 식당 잠금 후 revision/requestId뿐 아니라 job lease도 검사해야 한다. */ + public boolean completeLocation(long expectedRevision, UUID expectedRequestId, MapCoordinates coordinates, + RestaurantLocationSource source, LocalDateTime obtainedAt, + LocalDateTime validUntil, Clock clock) { + return !deleted && location != null && location.complete(expectedRevision, expectedRequestId, + coordinates, source, obtainedAt, validUntil, clock); + } + + public boolean completePlacesLocation(long expectedRevision, UUID expectedRequestId, + MapCoordinates coordinates, String googlePlaceId, + List placesAttributions, + LocalDateTime obtainedAt, LocalDateTime validUntil, Clock clock) { + return !deleted && location != null && location.complete(expectedRevision, expectedRequestId, + coordinates, RestaurantLocationSource.GOOGLE_PLACES, googlePlaceId, placesAttributions, + obtainedAt, validUntil, clock); + } + + public boolean deferLocation(long expectedRevision, UUID expectedRequestId, + LocalDateTime nextAttemptAt, Clock clock) { + return !deleted && location != null + && location.defer(expectedRevision, expectedRequestId, nextAttemptAt, clock); + } + + public boolean rejectLocation(long expectedRevision, UUID expectedRequestId, RestaurantLocationStatus outcome) { + return !deleted && location != null && location.reject(expectedRevision, expectedRequestId, outcome); + } + + /** 관광 지역 미분류 여부는 일반 지도 노출 조건에 포함하지 않는다. */ + public boolean hasUsableMapLocation(Clock clock) { + return !deleted && location != null && location.isUsable(clock); + } + public void replaceHashtags(List hashtags) { this.hashtags.clear(); if (hashtags != null) { @@ -311,4 +482,10 @@ public void addBusinessHour(RestaurantBusinessHour businessHour) { businessHour.assignRestaurant(this); this.businessHours.add(businessHour); } + + private void requireActiveForLocation() { + if (deleted) { + throw new IllegalStateException("삭제된 식당의 위치를 요청할 수 없습니다"); + } + } } diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocation.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocation.java new file mode 100644 index 00000000..8c23dbbf --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocation.java @@ -0,0 +1,284 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.sopt.hashi.BaseTimeEntity; + +/** + * Restaurant가 소유하는 선택적 위치. 변경은 Restaurant를 통해 수행한다. + * requestId는 같은 주소의 이전 요청을 구별하며 worker lease를 대신하지 않는다. + */ +@Getter +@Entity +@Table(name = "restaurant_location") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RestaurantLocation extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private MapCoordinates coordinates; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + private RestaurantLocationStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "source", length = 20) + private RestaurantLocationSource source; + + @Column(name = "google_place_id", length = 255) + private String googlePlaceId; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "places_attributions", columnDefinition = "json") + private List placesAttributions; + + @Column(name = "address_revision", nullable = false) + private long addressRevision; + + @JdbcTypeCode(SqlTypes.CHAR) + @Column(name = "request_id", length = 36, nullable = false) + private UUID requestId; + + // Keep UTC calendar values unchanged at the JDBC boundary, independently of the connection timezone. + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + @Column(name = "obtained_at") + private LocalDateTime obtainedAt; + + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + @Column(name = "valid_until") + private LocalDateTime validUntil; + + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + @Column(name = "next_attempt_at") + private LocalDateTime nextAttemptAt; + + @Enumerated(EnumType.STRING) + @Column(name = "confirmation_method", length = 30) + private LocationConfirmationMethod confirmationMethod; + + @Column(name = "confirmed_by_admin_id") + private Long confirmedByAdminId; + + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + @Column(name = "confirmed_at") + private LocalDateTime confirmedAt; + + @Column(name = "confirmed_revision") + private Long confirmedRevision; + + @Version + @Column(name = "lock_version", nullable = false) + private long lockVersion; + + static RestaurantLocation pending() { + RestaurantLocation location = new RestaurantLocation(); + location.addressRevision = 1; + location.beginPending(); + return location; + } + + /** 갱신 작업 상태와 별개로 마지막 검증 좌표를 사용한다. 정확히 validUntil이면 이미 만료다. */ + public boolean isUsable(Clock clock) { + boolean hasAcceptedLocation = coordinates != null && source != null + && obtainedAt != null && validUntil != null && obtainedAt.isBefore(validUntil); + if (source == RestaurantLocationSource.GOOGLE_PLACES + && (googlePlaceId == null || placesAttributions == null)) { + return false; + } + return hasAcceptedLocation && utcNow(clock).isBefore(validUntil); + } + + void addressChanged() { + addressRevision = Math.incrementExact(addressRevision); + clearAcceptedLocation(); + beginPending(); + } + + void requestRetry() { + if (status == RestaurantLocationStatus.READY) { + throw new IllegalStateException("준비된 위치는 갱신 절차를 사용해야 합니다"); + } + if (status != RestaurantLocationStatus.PENDING) { + beginPending(); + } + } + + void beginRefresh() { + if (status != RestaurantLocationStatus.READY) { + throw new IllegalStateException("준비된 위치만 갱신할 수 있습니다"); + } + beginPending(); + } + + void selectPlaceForVerification() { + clearAcceptedLocation(); + beginPending(); + } + + void confirmPlaceSelection(Long adminId, LocalDateTime confirmedAt) { + if (adminId == null || adminId < 1) { + throw new IllegalArgumentException("유효한 관리자 ID가 필요합니다"); + } + this.confirmationMethod = LocationConfirmationMethod.ADMIN_PLACE_SELECTION; + this.confirmedByAdminId = adminId; + this.confirmedAt = toMicros(confirmedAt); + this.confirmedRevision = addressRevision; + } + + boolean beginScheduledRetry(Clock clock) { + boolean due = status == RestaurantLocationStatus.RETRY_WAIT + && !utcNow(clock).isBefore(nextAttemptAt); + if (due) { + beginPending(); + } + return due; + } + + boolean complete(long expectedRevision, UUID expectedRequestId, MapCoordinates coordinates, + RestaurantLocationSource source, LocalDateTime obtainedAt, + LocalDateTime validUntil, Clock clock) { + return complete(expectedRevision, expectedRequestId, coordinates, source, null, + null, obtainedAt, validUntil, clock); + } + + boolean complete(long expectedRevision, UUID expectedRequestId, MapCoordinates coordinates, + RestaurantLocationSource source, String googlePlaceId, + List placesAttributions, LocalDateTime obtainedAt, + LocalDateTime validUntil, Clock clock) { + if (!matchesPending(expectedRevision, expectedRequestId)) { + return false; + } + Objects.requireNonNull(coordinates, "coordinates"); + Objects.requireNonNull(source, "source"); + boolean places = source == RestaurantLocationSource.GOOGLE_PLACES; + boolean validPlacesFields = places + ? googlePlaceId != null && placesAttributions != null + : googlePlaceId == null && placesAttributions == null; + if (!validPlacesFields || (googlePlaceId != null + && (googlePlaceId.isBlank() || !googlePlaceId.equals(googlePlaceId.trim()) + || googlePlaceId.length() > 255))) { + throw new IllegalArgumentException("Google Places 위치에는 유효한 Place ID가 필요합니다"); + } + LocalDateTime obtained = toMicros(obtainedAt); + LocalDateTime until = toMicros(validUntil); + LocalDateTime now = utcNow(clock); + boolean validLifetime = !obtained.isAfter(now) && until.isAfter(now) && until.isAfter(obtained); + if (!validLifetime) { + throw new IllegalArgumentException("취득 시각과 만료 시각이 올바르지 않습니다"); + } + this.coordinates = coordinates; + this.source = source; + this.googlePlaceId = googlePlaceId; + this.placesAttributions = placesAttributions == null ? null : List.copyOf(placesAttributions); + this.obtainedAt = obtained; + this.validUntil = until; + if (confirmationMethod == null && source == RestaurantLocationSource.GOOGLE_GEOCODING) { + this.confirmationMethod = LocationConfirmationMethod.AUTOMATIC; + this.confirmedAt = obtained; + this.confirmedRevision = addressRevision; + } + this.status = RestaurantLocationStatus.READY; + return true; + } + + boolean defer(long expectedRevision, UUID expectedRequestId, LocalDateTime nextAttemptAt, Clock clock) { + if (!matchesPending(expectedRevision, expectedRequestId)) { + return false; + } + LocalDateTime next = toMicros(nextAttemptAt); + if (!next.isAfter(utcNow(clock))) { + throw new IllegalArgumentException("재시도 시각은 현재보다 늦어야 합니다"); + } + this.nextAttemptAt = next; + this.status = RestaurantLocationStatus.RETRY_WAIT; + return true; + } + + boolean reject(long expectedRevision, UUID expectedRequestId, RestaurantLocationStatus outcome) { + if (!matchesPending(expectedRevision, expectedRequestId)) { + return false; + } + boolean terminal = outcome == RestaurantLocationStatus.REVIEW_REQUIRED + || outcome == RestaurantLocationStatus.FAILED; + if (!terminal) { + throw new IllegalArgumentException("확인 필요 또는 자동 처리 중단 상태만 지정할 수 있습니다"); + } + this.status = outcome; + return true; + } + + /** Snapshot identity and lifetime protect a newer result from an old retention scan. */ + boolean purgeGoogle(long revision, UUID request, LocalDateTime obtained, LocalDateTime until, + LocalDateTime purgeBefore) { + boolean matches = (source == RestaurantLocationSource.GOOGLE_GEOCODING + || source == RestaurantLocationSource.GOOGLE_PLACES) + && addressRevision == revision && requestId.equals(request) + && Objects.equals(obtainedAt, obtained) && Objects.equals(validUntil, until) + && !validUntil.isAfter(purgeBefore); + if (!matches) { + return false; + } + clearAcceptedLocation(); + // Keep an active job identity and its durable attempt count intact. + if (status == RestaurantLocationStatus.READY) { + status = RestaurantLocationStatus.REVIEW_REQUIRED; + } + return true; + } + + private boolean matchesPending(long expectedRevision, UUID expectedRequestId) { + return status == RestaurantLocationStatus.PENDING && addressRevision == expectedRevision + && requestId.equals(expectedRequestId); + } + + private void beginPending() { + this.status = RestaurantLocationStatus.PENDING; + this.requestId = UUID.randomUUID(); + this.nextAttemptAt = null; + } + + private void clearAcceptedLocation() { + this.coordinates = null; + this.source = null; + this.googlePlaceId = null; + this.placesAttributions = null; + this.obtainedAt = null; + this.validUntil = null; + this.confirmationMethod = null; + this.confirmedByAdminId = null; + this.confirmedAt = null; + this.confirmedRevision = null; + } + + private static LocalDateTime utcNow(Clock clock) { + return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + } + + private static LocalDateTime toMicros(LocalDateTime time) { + return Objects.requireNonNull(time, "time").truncatedTo(ChronoUnit.MICROS); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationAttribution.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationAttribution.java new file mode 100644 index 00000000..d0fd73cd --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationAttribution.java @@ -0,0 +1,24 @@ +package org.sopt.hashi.restaurant.domain; + +import java.net.URI; +import java.util.Objects; + +/** 공개 좌표와 함께 표시해야 하는 Places 제3자 attribution의 최소 저장 계약. */ +public record RestaurantLocationAttribution(String displayName, String uri) { + public RestaurantLocationAttribution { + Objects.requireNonNull(displayName, "displayName"); + Objects.requireNonNull(uri, "uri"); + if (displayName.isBlank() || displayName.length() > 200 || uri.length() > 2048) { + throw new IllegalArgumentException("Places attribution is invalid"); + } + URI parsed; + try { + parsed = URI.create(uri); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Places attribution URI is invalid", exception); + } + if (!"https".equalsIgnoreCase(parsed.getScheme()) || parsed.getHost() == null) { + throw new IllegalArgumentException("Places attribution URI must use HTTPS"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationJob.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationJob.java new file mode 100644 index 00000000..52664146 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationJob.java @@ -0,0 +1,165 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** 주소/Google 응답을 복제하지 않는 durable 작업. 자동 재시도는 같은 행의 attempt를 이어간다. */ +@Entity +@Table(name = "restaurant_location_job") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RestaurantLocationJob { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private Long restaurantId; + @Column(nullable = false) + private long addressRevision; + @JdbcTypeCode(SqlTypes.CHAR) + @Column(length = 36, nullable = false, unique = true) + private UUID requestId; + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private Operation operation; + @Column(name = "google_place_id", length = 255) + private String googlePlaceId; + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private State state; + @Column(nullable = false) + private int attempt; + @Column(nullable = false) + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + private LocalDateTime nextAttemptAt; + @JdbcTypeCode(SqlTypes.CHAR) + @Column(length = 36) + private UUID leaseToken; + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + private LocalDateTime leaseUntil; + /** 대체된 작업도 기존 호출의 실행 슬롯을 기한까지 점유한다. */ + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + private LocalDateTime reservedUntil; + @Column(length = 40) + private String failureCode; + @Column(nullable = false) + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + private LocalDateTime createdAt; + @Column(nullable = false) + @JdbcTypeCode(SqlTypes.LOCAL_DATE_TIME) + private LocalDateTime updatedAt; + + public static RestaurantLocationJob pending(Restaurant restaurant, LocalDateTime now) { + return pending(restaurant, Operation.GEOCODING, null, now); + } + + public static RestaurantLocationJob pendingDetails(Restaurant restaurant, String googlePlaceId, + LocalDateTime now) { + if (googlePlaceId == null || googlePlaceId.isBlank() || !googlePlaceId.equals(googlePlaceId.trim()) + || googlePlaceId.length() > 255) { + throw new IllegalArgumentException("Google Place ID is invalid"); + } + return pending(restaurant, Operation.PLACE_DETAILS, googlePlaceId, now); + } + + private static RestaurantLocationJob pending(Restaurant restaurant, Operation operation, + String googlePlaceId, LocalDateTime now) { + RestaurantLocationJob job = new RestaurantLocationJob(); + job.restaurantId = restaurant.getId(); + job.addressRevision = restaurant.getLocation().getAddressRevision(); + job.requestId = restaurant.getLocation().getRequestId(); + job.operation = operation; + job.googlePlaceId = googlePlaceId; + job.state = State.PENDING; + job.nextAttemptAt = now; + job.createdAt = now; + job.updatedAt = now; + return job; + } + + public boolean isCurrent(Restaurant restaurant) { + RestaurantLocation location = restaurant.getLocation(); + return !restaurant.isDeleted() && location != null + && restaurantId.equals(restaurant.getId()) + && addressRevision == location.getAddressRevision() + && requestId.equals(location.getRequestId()); + } + + public boolean isDue(LocalDateTime now) { + return switch (state) { + case PENDING -> reservedUntil == null || !reservedUntil.isAfter(now); + case RETRY_WAIT -> !nextAttemptAt.isAfter(now) + && (reservedUntil == null || !reservedUntil.isAfter(now)); + case LEASED -> !leaseUntil.isAfter(now); + default -> false; + }; + } + + public boolean ownsLease(UUID token, LocalDateTime now) { + return state == State.LEASED && leaseToken.equals(token) && now.isBefore(leaseUntil); + } + + public void claim(UUID currentRequestId, LocalDateTime now, LocalDateTime until) { + requestId = currentRequestId; + state = State.LEASED; + leaseToken = UUID.randomUUID(); + leaseUntil = until; + reservedUntil = until; + attempt = Math.incrementExact(attempt); + updatedAt = now; + } + + public void defer(String code, LocalDateTime next, LocalDateTime now) { + LocalDateTime outstandingReservation = reservedUntil; + finish(State.RETRY_WAIT, code, now); + nextAttemptAt = next; + if ("CANCELLED".equals(code) || "TIMEOUT".equals(code)) { + reservedUntil = outstandingReservation; + } + } + + public void followRequest(UUID currentRequestId) { + requestId = currentRequestId; + } + + public void finish(State outcome, String code, LocalDateTime now) { + LocalDateTime outstandingReservation = reservedUntil; + state = outcome; + failureCode = code; + leaseToken = null; + leaseUntil = null; + reservedUntil = null; + if ("ATTEMPTS_EXHAUSTED".equals(code)) { + reservedUntil = outstandingReservation; + } + updatedAt = now; + } + + public void supersede(LocalDateTime now) { + LocalDateTime outstandingReservation = reservedUntil; + finish(State.SUPERSEDED, "SUPERSEDED", now); + reservedUntil = outstandingReservation; + } + + public enum State { + PENDING, LEASED, RETRY_WAIT, SUCCEEDED, REVIEW_REQUIRED, FAILED, SUPERSEDED + } + + public enum Operation { + GEOCODING, PLACE_DETAILS + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationJobRepository.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationJobRepository.java new file mode 100644 index 00000000..5ac0156c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationJobRepository.java @@ -0,0 +1,63 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.LockModeType; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob.Operation; + +public interface RestaurantLocationJobRepository extends JpaRepository { + + Optional findByRestaurantIdAndRequestId(Long restaurantId, UUID requestId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select j from RestaurantLocationJob j where j.restaurantId = :restaurantId and j.requestId = :requestId") + Optional findCurrentForUpdate(@Param("restaurantId") Long restaurantId, + @Param("requestId") UUID requestId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select j from RestaurantLocationJob j where j.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select j from RestaurantLocationJob j where j.restaurantId = :restaurantId + and j.state in ('PENDING', 'LEASED', 'RETRY_WAIT') order by j.id + """) + List findActiveForUpdate(@Param("restaurantId") Long restaurantId); + + @Query(""" + select j from RestaurantLocationJob j + where j.operation = :operation and ( + (j.state = 'PENDING' and (j.reservedUntil is null or j.reservedUntil <= :now)) + or (j.state = 'RETRY_WAIT' and j.nextAttemptAt <= :now + and (j.reservedUntil is null or j.reservedUntil <= :now)) + or (j.state = 'LEASED' and j.leaseUntil <= :now)) + order by j.nextAttemptAt, j.id + """) + List findDue(@Param("now") LocalDateTime now, + @Param("operation") Operation operation, Pageable pageable); + + /** Budget pauses must not hide cleanup behind ordinary jobs that cannot make a call. */ + @Query(""" + select j from RestaurantLocationJob j + where j.operation = :operation and j.attempt >= :maxAttempts and ( + (j.state = 'PENDING' and (j.reservedUntil is null or j.reservedUntil <= :now)) + or (j.state = 'RETRY_WAIT' and j.nextAttemptAt <= :now + and (j.reservedUntil is null or j.reservedUntil <= :now)) + or (j.state = 'LEASED' and j.leaseUntil <= :now)) + order by j.nextAttemptAt, j.id + """) + List findDueExhausted(@Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts, + @Param("operation") Operation operation, Pageable pageable); + + @Query("select count(j) from RestaurantLocationJob j where j.operation = :operation and j.reservedUntil > :now") + long countReservations(@Param("now") LocalDateTime now, @Param("operation") Operation operation); +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationSource.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationSource.java new file mode 100644 index 00000000..4efd261f --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationSource.java @@ -0,0 +1,8 @@ +package org.sopt.hashi.restaurant.domain; + +/** 운영자 확인만으로 Google 유래 좌표의 출처를 ADMIN으로 바꾸지 않는다. */ +public enum RestaurantLocationSource { + GOOGLE_GEOCODING, + GOOGLE_PLACES, + ADMIN +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationStatus.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationStatus.java new file mode 100644 index 00000000..5260d14c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantLocationStatus.java @@ -0,0 +1,10 @@ +package org.sopt.hashi.restaurant.domain; + +/** 위치 행이 없으면 UNRESOLVED로 해석한다. 실제 작업 claim/lease는 후속 worker의 책임이다. */ +public enum RestaurantLocationStatus { + PENDING, + READY, + RETRY_WAIT, + REVIEW_REQUIRED, + FAILED +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapCandidate.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapCandidate.java new file mode 100644 index 00000000..737106d2 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapCandidate.java @@ -0,0 +1,7 @@ +package org.sopt.hashi.restaurant.domain; + +import java.math.BigDecimal; + +/** 후보 순위를 고정하는 데 필요한 값만 읽는다. 이미지나 Entity를 조회하지 않는다. */ +public record RestaurantMapCandidate(Long restaurantId, BigDecimal rating, long reviewCount) { +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapQueryRepository.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapQueryRepository.java new file mode 100644 index 00000000..28686870 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapQueryRepository.java @@ -0,0 +1,246 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.RestaurantMapInfo.LocationInfo; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent.ResultBounds; +import org.sopt.hashi.restaurant.RestaurantMapInfo.AttributionInfo; +import org.springframework.stereotype.Repository; +import org.springframework.dao.DataRetrievalFailureException; + +/** 지도 전용 값 projection. 모든 SQL은 restaurant 모듈 안의 테이블만 사용한다. */ +@Repository +public class RestaurantMapQueryRepository { + + private static final ObjectMapper ATTRIBUTIONS_MAPPER = new ObjectMapper(); + + private static final DateTimeFormatter UTC_DATETIME = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSS"); + private static final String USABLE_LOCATION = """ + l.latitude is not null and l.longitude is not null + and l.latitude between -90 and 90 and l.longitude between -180 and 180 + and l.source is not null and l.obtained_at is not null + and l.obtained_at < l.valid_until + and l.valid_until > cast(:now as datetime(6)) + """; + private static final String REGION_CONTAINS_LOCATION = """ + l.latitude between g.south and g.north and l.longitude between g.west and g.east + """; + + private final EntityManager entityManager; + + public RestaurantMapQueryRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + /** maxResults는 후보 상한 + 1이다. 호출자가 초과를 감지하고 전체 실패시킨다. */ + public List findCandidates(MapSearchCriteria criteria, Instant now, int maxResults) { + return candidateRows(criteria, now, null, maxResults); + } + + public List findMatchingCandidates(MapSearchCriteria criteria, Instant now, List ids) { + return ids.isEmpty() ? List.of() : candidateRows(criteria, now, ids, null); + } + + /** 페이지 크기와 무관하게 검색 조건에 맞는 모든 유효 좌표의 개수와 경계를 집계한다. */ + public MapSearchResultExtent findResultExtent(MapSearchCriteria criteria, Instant now) { + StringBuilder sql = matchingRowsSql(""" + select count(*), min(l.latitude), max(l.latitude), min(l.longitude), max(l.longitude), + date_format(min(l.valid_until), '%Y-%m-%dT%H:%i:%s.%f') + """, criteria); + Object[] row = (Object[]) bindCriteria(entityManager.createNativeQuery(sql.toString()), criteria, now) + .getSingleResult(); + long totalCount = number(row[0]); + if (totalCount == 0) { + return MapSearchResultExtent.empty(); + } + return new MapSearchResultExtent(totalCount, new ResultBounds( + (BigDecimal) row[1], (BigDecimal) row[2], (BigDecimal) row[3], (BigDecimal) row[4]), instant(row[5])); + } + + public Optional findActiveRegionBounds(Long id) { + List rows = rows(entityManager.createNativeQuery(""" + select south, north, west, east from map_region where id = :id and active = true + """).setParameter("id", id)); + return rows.stream().findFirst().map(row -> bounds(row, 0)); + } + + /** 위치가 무효한 공개 식당도 남긴다. 무효 좌표는 SQL LEFT JOIN에서 이미 제외한다. */ + public List findActiveMapInfos(List ids, Instant now) { + if (ids.isEmpty()) { + return List.of(); + } + Query query = entityManager.createNativeQuery(""" + select r.id, r.name, r.place_type, r.genre, l.latitude, l.longitude, + date_format(l.valid_until, '%Y-%m-%dT%H:%i:%s.%f'), + cast(l.places_attributions as char) + from restaurant r left join restaurant_location l on l.id = r.location_id and + """ + USABLE_LOCATION + " where r.deleted = false and r.id in (:ids)") + .setParameter("ids", ids).setParameter("now", utc(now)); + return rows(query).stream().map(row -> new RestaurantMapInfo(number(row[0]), (String) row[1], + RestaurantPlaceType.valueOf((String) row[2]).value(), + RestaurantGenre.valueOf((String) row[3]).value(), + row[4] == null ? null : new LocationInfo((BigDecimal) row[4], (BigDecimal) row[5], instant(row[6]), + attributions(row[7])))) + .toList(); + } + + /** 활성 지역은 0건도 포함한다. 범위 밖 매핑 수는 잘못된 운영 설정을 식별하는 데 사용한다. */ + public List findActiveRegions(Instant now) { + Query query = entityManager.createNativeQuery(""" + select g.id, g.name, g.cluster_latitude, g.cluster_longitude, + g.south, g.north, g.west, g.east, g.display_order, + """ + "sum(case when " + REGION_CONTAINS_LOCATION + " then 1 else 0 end), " + + "sum(case when l.id is not null and not (" + REGION_CONTAINS_LOCATION + + ") then 1 else 0 end) " + """ + from map_region g + left join restaurant r on r.map_region_id = g.id and r.deleted = false + left join restaurant_location l on l.id = r.location_id and + """ + USABLE_LOCATION + """ + where g.active = true + group by g.id, g.name, g.cluster_latitude, g.cluster_longitude, + g.south, g.north, g.west, g.east, g.display_order + order by g.display_order, g.id + """).setParameter("now", utc(now)); + return rows(query).stream().map(row -> new MapRegionSummary(number(row[0]), (String) row[1], + (BigDecimal) row[2], (BigDecimal) row[3], bounds(row, 4), ((Number) row[8]).intValue(), + number(row[9]), number(row[10]))).toList(); + } + + private List candidateRows(MapSearchCriteria criteria, Instant now, + List ids, Integer maxResults) { + StringBuilder sql = matchingRowsSql(""" + select r.id, r.rating, r.review_count + """, criteria); + if (ids != null) { + sql.append(" and r.id in (:ids)"); + } + sql.append(" order by r.id"); + Query query = bindCriteria(entityManager.createNativeQuery(sql.toString()), criteria, now); + if (ids != null) { + query.setParameter("ids", ids); + } + if (maxResults != null) { + query.setMaxResults(maxResults); + } + return rows(query).stream().map(row -> new RestaurantMapCandidate(number(row[0]), + (BigDecimal) row[1], number(row[2]))).toList(); + } + + private static StringBuilder matchingRowsSql(String select, MapSearchCriteria criteria) { + StringBuilder sql = new StringBuilder(select).append(""" + from restaurant r join restaurant_location l on l.id = r.location_id + where r.deleted = false and + """).append(USABLE_LOCATION).append(""" + and l.latitude between :south and :north and l.longitude between :west and :east + """); + if (criteria.mapRegionId() != null) { + sql.append(" and r.map_region_id = :regionId") + .append(" and exists (select 1 from map_region g where g.id = r.map_region_id and g.active = true and ") + .append(REGION_CONTAINS_LOCATION).append(")"); + } + if (criteria.genre() != null) { + sql.append(" and r.genre = :genre"); + } + if (criteria.placeType() != null) { + sql.append(" and r.place_type = :placeType"); + } + if (criteria.keyword() != null) { + appendKeywordPredicate(sql, criteria); + } + return sql; + } + + private static Query bindCriteria(Query query, MapSearchCriteria criteria, Instant now) { + query.setParameter("now", utc(now)) + .setParameter("south", criteria.bounds().south()).setParameter("north", criteria.bounds().north()) + .setParameter("west", criteria.bounds().west()).setParameter("east", criteria.bounds().east()); + if (criteria.mapRegionId() != null) { + query.setParameter("regionId", criteria.mapRegionId()); + } + if (criteria.genre() != null) { + query.setParameter("genre", criteria.genre().name()); + } + if (criteria.placeType() != null) { + query.setParameter("placeType", criteria.placeType().name()); + } + if (criteria.keyword() != null) { + List patterns = criteria.keywordPatterns(); + for (int index = 0; index < patterns.size(); index++) { + query.setParameter("keyword" + index, patterns.get(index)); + } + } + return query; + } + + private static void appendKeywordPredicate(StringBuilder sql, MapSearchCriteria criteria) { + sql.append(" and ("); + List patterns = criteria.keywordPatterns(); + for (int index = 0; index < patterns.size(); index++) { + if (index > 0) { + sql.append(" or "); + } + String parameter = ":keyword" + index; + if (criteria.hashtagOnly()) { + appendHashtagExists(sql, parameter); + } else { + sql.append("(lower(r.name) like ").append(parameter).append(" escape '!' or ") + .append("exists (select 1 from restaurant_menu m where m.restaurant_id = r.id ") + .append("and lower(m.name) like ").append(parameter).append(" escape '!') or "); + appendHashtagExists(sql, parameter); + sql.append(")"); + } + } + sql.append(")"); + } + + private static void appendHashtagExists(StringBuilder sql, String parameter) { + sql.append("exists (select 1 from restaurant_hashtag h where h.restaurant_id = r.id ") + .append("and lower(h.hashtag) like ").append(parameter).append(" escape '!')"); + } + + @SuppressWarnings("unchecked") + private static List rows(Query query) { + return query.getResultList(); + } + + private static Long number(Object value) { + return ((Number) value).longValue(); + } + + private static MapQueryBounds bounds(Object[] row, int start) { + return new MapQueryBounds((BigDecimal) row[start], (BigDecimal) row[start + 1], + (BigDecimal) row[start + 2], (BigDecimal) row[start + 3]); + } + + /** Timestamp 바인딩의 JVM/연결 시간대 변환을 피하고 UTC DATETIME(6)을 직접 비교한다. */ + private static String utc(Instant instant) { + return UTC_DATETIME.format(LocalDateTime.ofInstant(instant, ZoneOffset.UTC)); + } + + private static Instant instant(Object value) { + return LocalDateTime.parse((String) value).toInstant(ZoneOffset.UTC); + } + + private static List attributions(Object value) { + if (value == null) { + return List.of(); + } + try { + return List.of(ATTRIBUTIONS_MAPPER.readValue((String) value, RestaurantLocationAttribution[].class)) + .stream().map(item -> new AttributionInfo(item.displayName(), item.uri())).toList(); + } catch (JsonProcessingException | IllegalArgumentException | NullPointerException exception) { + // 출처가 손상된 좌표를 출처 없이 공개하지 않는다. 원문은 로그/예외에 포함하지 않는다. + throw new DataRetrievalFailureException("Stored location attribution is invalid"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapSort.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapSort.java new file mode 100644 index 00000000..68a2a10c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapSort.java @@ -0,0 +1,25 @@ +package org.sopt.hashi.restaurant.domain; + +import java.util.Arrays; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; + +/** 일반 목록의 basic/popular/rating과 분리된 지도 정렬 계약. */ +public enum RestaurantMapSort { + RECOMMEND("recommend"), RATING("rating"), REVIEWS("reviews"); + + private final String value; + + RestaurantMapSort(String value) { + this.value = value; + } + + public String value() { + return value; + } + + public static RestaurantMapSort parse(String value) { + return Arrays.stream(values()).filter(sort -> sort.value.equals(value)).findFirst() + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.UNSUPPORTED_SORT)); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java index 08dbbb1d..86d9ddf4 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java @@ -5,6 +5,7 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; @@ -35,6 +36,74 @@ public interface RestaurantRepository extends JpaRepository, J boolean existsByAddressAndIdNotAndDeletedFalse(String address, Long id); + /** 위치와 현재 requestId의 작업을 한 번에 읽어 관리자 목록의 per-row 추가 조회를 막는다. */ + @Query(value = """ + select r.id as restaurantId, + r.name as restaurantName, + r.address as address, + r.geocoding_address as geocodingAddress, + case when l.id is null then 'UNRESOLVED' else l.status end as locationStatus, + l.source as locationSource, + j.operation as verificationMode, + coalesce(l.address_revision, 0) as addressRevision, + date_format(l.valid_until, '%Y-%m-%dT%H:%i:%s.%f') as validUntilUtc, + coalesce(j.attempt, 0) as attempt, + date_format(l.next_attempt_at, '%Y-%m-%dT%H:%i:%s.%f') as nextAttemptAtUtc, + j.failure_code as failureCode, + l.confirmed_by_admin_id as confirmedByAdminId + from restaurant r + left join restaurant_location l on l.id = r.location_id + left join restaurant_location_job j + on j.restaurant_id = r.id and j.request_id = l.request_id + where r.deleted = false + and ((:status = 'UNRESOLVED' and l.id is null) + or (:status <> 'UNRESOLVED' and l.status = :status)) + and (:source is null or l.source = :source) + order by r.id asc + """, countQuery = """ + select count(*) + from restaurant r + left join restaurant_location l on l.id = r.location_id + where r.deleted = false + and ((:status = 'UNRESOLVED' and l.id is null) + or (:status <> 'UNRESOLVED' and l.status = :status)) + and (:source is null or l.source = :source) + """, nativeQuery = true) + Page findLocationReviews( + @Param("status") String status, + @Param("source") String source, + Pageable pageable + ); + + /** 관리자 위치 검토 목록의 단일 조인 결과. Repository 밖에서는 공개 포트 DTO로 변환한다. */ + interface RestaurantLocationReviewProjection { + Long getRestaurantId(); + + String getRestaurantName(); + + String getAddress(); + + String getGeocodingAddress(); + + String getLocationStatus(); + + String getLocationSource(); + + String getVerificationMode(); + + Long getAddressRevision(); + + String getValidUntilUtc(); + + Integer getAttempt(); + + String getNextAttemptAtUtc(); + + String getFailureCode(); + + Long getConfirmedByAdminId(); + } + @EntityGraph(attributePaths = "businessHours") @Query("select distinct r from Restaurant r where r.id = :restaurantId and r.deleted = false") Optional findActiveByIdWithBusinessHours(@Param("restaurantId") Long restaurantId); diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantSearchKeyword.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantSearchKeyword.java new file mode 100644 index 00000000..85fec07c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantSearchKeyword.java @@ -0,0 +1,30 @@ +package org.sopt.hashi.restaurant.domain; + +import java.util.List; +import java.util.Locale; + +/** 검색어의 Unicode 앞뒤 공백 제거와 SQL LIKE 리터럴 이스케이프를 한 곳에서 관리한다. */ +final class RestaurantSearchKeyword { + + static final char LIKE_ESCAPE = '!'; + + private RestaurantSearchKeyword() { + } + + static String normalize(String keyword) { + if (keyword == null) { + return null; + } + String normalized = keyword.replaceAll("(?U)^\\s+|\\s+$", ""); + return normalized.isEmpty() ? null : normalized; + } + + static String containsPattern(String normalizedKeyword) { + return normalizedKeyword == null ? null : "%" + normalizedKeyword.toLowerCase(Locale.ROOT) + .replace("!", "!!").replace("%", "!%").replace("_", "!_") + "%"; + } + + static List terms(String normalizedKeyword) { + return normalizedKeyword == null ? List.of() : List.of(normalizedKeyword.split("(?U)\\s+")); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantSpecifications.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantSpecifications.java index fbb5663b..110fc0fd 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantSpecifications.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantSpecifications.java @@ -67,20 +67,22 @@ public static Specification curationTypeEquals(RestaurantCurationTyp } public static Specification keywordContains(String keyword) { + String likeKeyword = RestaurantSearchKeyword.containsPattern(RestaurantSearchKeyword.normalize(keyword)); return (root, query, criteriaBuilder) -> { - if (keyword == null || keyword.isBlank()) { + if (likeKeyword == null) { return criteriaBuilder.conjunction(); } if (query != null) { query.distinct(true); } - String likeKeyword = "%" + keyword.trim().toLowerCase() + "%"; Join menus = root.join("menus", JoinType.LEFT); return criteriaBuilder.or( - criteriaBuilder.like(criteriaBuilder.lower(root.get("name")), likeKeyword), - criteriaBuilder.like(criteriaBuilder.lower(menus.get("name")), likeKeyword) + criteriaBuilder.like(criteriaBuilder.lower(root.get("name")), likeKeyword, + RestaurantSearchKeyword.LIKE_ESCAPE), + criteriaBuilder.like(criteriaBuilder.lower(menus.get("name")), likeKeyword, + RestaurantSearchKeyword.LIKE_ESCAPE) ); }; } diff --git a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapLocationResponse.java b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapLocationResponse.java new file mode 100644 index 00000000..299c8fbb --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapLocationResponse.java @@ -0,0 +1,10 @@ +package org.sopt.hashi.restaurant.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.sopt.hashi.restaurant.RestaurantMapInfo.LocationInfo; + +@Schema(description = "선택한 공개 식당의 현재 표시 가능한 지도 위치") +public record RestaurantMapLocationResponse( + @Schema(description = "식당 ID", example = "1") Long restaurantId, + @Schema(description = "핀 좌표와 UTC 만료 시각. validUntil부터는 표시할 수 없음") LocationInfo location) { +} diff --git a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageRequest.java b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageRequest.java new file mode 100644 index 00000000..9b95e976 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageRequest.java @@ -0,0 +1,92 @@ +package org.sopt.hashi.restaurant.dto; + +import java.util.Set; +import java.math.BigDecimal; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.springframework.util.MultiValueMap; + +/** 세 모드를 먼저 분리해 중복 값이나 무시되는 필터가 생기지 않도록 한다. */ +public record RestaurantMapPageRequest(MapSearchCriteria criteria, RestaurantMapSort sort, + String querySessionId, String cursor) { + + private static final int MAX_COORDINATE_LENGTH = 128; + private static final int MAX_COORDINATE_SCALE = 128; + private static final int MAX_COORDINATE_PRECISION = 128; + private static final Set NEW_QUERY = Set.of("south", "north", "west", "east", + "mapRegionId", "keyword", "genre", "placeType", "sort"); + private static final Set BOUNDS = Set.of("south", "north", "west", "east"); + + public static RestaurantMapPageRequest from(MultiValueMap parameters) { + if (parameters.values().stream().anyMatch(values -> values == null || values.size() != 1 + || values.getFirst() == null)) { + throw invalid(); + } + if (parameters.containsKey("cursor")) { + String cursor = parameters.getFirst("cursor"); + if (parameters.size() != 1 || cursor.isBlank() || cursor.length() > 512) { + throw invalid(); + } + return new RestaurantMapPageRequest(null, null, null, cursor); + } + if (parameters.containsKey("querySessionId")) { + if (!parameters.keySet().equals(Set.of("querySessionId", "sort"))) { + throw invalid(); + } + return new RestaurantMapPageRequest(null, RestaurantMapSort.parse(parameters.getFirst("sort")), + parameters.getFirst("querySessionId"), null); + } + if (!NEW_QUERY.containsAll(parameters.keySet()) || !parameters.keySet().containsAll(BOUNDS)) { + throw invalid(); + } + Long region = null; + try { + if (parameters.containsKey("mapRegionId")) { + String value = parameters.getFirst("mapRegionId"); + if (!value.matches("[1-9][0-9]{0,18}")) { + throw invalid(); + } + region = Long.valueOf(value); + } + } catch (NumberFormatException exception) { + throw invalid(); + } + MapQueryBounds bounds = new MapQueryBounds(coordinate(parameters.getFirst("south")), + coordinate(parameters.getFirst("north")), coordinate(parameters.getFirst("west")), + coordinate(parameters.getFirst("east"))); + return new RestaurantMapPageRequest(MapSearchCriteria.of(bounds, region, parameters.getFirst("genre"), + parameters.getFirst("placeType"), parameters.getFirst("keyword")), + parameters.containsKey("sort") ? RestaurantMapSort.parse(parameters.getFirst("sort")) + : RestaurantMapSort.RECOMMEND, null, null); + } + + @Override + public String toString() { + return "RestaurantMapPageRequest[redacted]"; + } + + /** 산술 전에 자원 상한을 검사한다. 허용된 SDK 소수는 반올림하거나 절삭하지 않는다. */ + private static BigDecimal coordinate(String value) { + try { + if (value == null || value.length() > MAX_COORDINATE_LENGTH) { + throw new NumberFormatException(); + } + BigDecimal decimal = new BigDecimal(value); + if (Math.abs((long) decimal.scale()) > MAX_COORDINATE_SCALE + || decimal.precision() > MAX_COORDINATE_PRECISION) { + throw new NumberFormatException(); + } + return decimal; + } catch (NumberFormatException exception) { + throw new BusinessException(RestaurantErrorCode.MAP_BOUNDS_INVALID); + } + } + + private static BusinessException invalid() { + return new BusinessException(CommonErrorCode.INVALID_INPUT); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageResponse.java b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageResponse.java new file mode 100644 index 00000000..9b0533fc --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageResponse.java @@ -0,0 +1,118 @@ +package org.sopt.hashi.restaurant.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import org.sopt.hashi.restaurant.RestaurantImageInfo; +import org.sopt.hashi.restaurant.RestaurantMapInfo.LocationInfo; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent.ResultBounds; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.restaurant.dto.RestaurantListResponse.RestaurantSummaryResponse; +import org.sopt.hashi.restaurant.dto.RestaurantListResponse.TodayBusinessHourResponse; +import org.sopt.hashi.restaurant.dto.RestaurantStoreInformationResponse.PriceRangeResponse; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "지도 목록 한 페이지와 조회 세션 정보") +public record RestaurantMapPageResponse( + @Schema(description = "현재 페이지의 식당 카드 겸 핀 데이터. 유효한 결과가 없어도 빈 배열이며 최대 10개") + List content, + @Schema(description = "다음 페이지에 그대로 전달할 72자 cursor. hasNext=false이면 응답에서 생략", + example = "AhI-RWfomxLTpFZCZhQXQAAAAAAACso6YZT4UYYs1U4g1RUyPn-6Z318IrJMCmUuvhgmPhvW") + String nextCursor, + @Schema(description = "다음 유효 후보 페이지 존재 여부", example = "true") + boolean hasNext, + @Schema(description = "정렬 변경에 사용하는 조회 세션 ID", format = "uuid", + example = "123e4567-e89b-12d3-a456-426614174000") + String querySessionId, + @Schema(description = "이번 정상 조회 뒤 확정된 서버 세션 만료 시각. 유휴 5분 갱신, 최초 생성부터 최대 30분이며 " + + "검색 결과 좌표 중 가장 이른 만료 시각을 넘지 않음", + example = "2026-09-26T01:05:00Z") + Instant expiresAt, + @Schema(description = "별점·리뷰 수와 검색 결과 요약을 고정한 시각", example = "2026-09-26T01:00:00Z") + Instant rankingAsOf, + @Schema(description = "keyword 조회에서만 제공하는 최초 검색 결과 전체의 수와 경계. " + + "rankingAsOf 이후 식당 삭제 등으로 현재 content와 달라질 수 있음") + SearchResultResponse searchResult, + @Schema(description = "세션에 고정되어 실제로 적용된 조회 범위·필터·정렬") + QueryResponse query) { + + @JsonInclude(JsonInclude.Include.ALWAYS) + @Schema(description = "검색 결과 전체를 화면에 맞추기 위한 최초 조회 시점의 집계") + public record SearchResultResponse( + @Schema(description = "현재 viewport와 keyword 조건에 최초 일치한 전체 식당 수", example = "27") + long totalCount, + @Schema(description = "전체 검색 결과 좌표의 최소 경계. 0건이면 null이고 1건이면 각 최소·최대값이 같음", + nullable = true) + ResultBoundsResponse bounds) { + + public static SearchResultResponse from(MapSearchResultExtent extent) { + return extent == null ? null : new SearchResultResponse(extent.totalCount(), + ResultBoundsResponse.from(extent.bounds())); + } + } + + @Schema(description = "검색 결과 전체의 좌표 경계") + public record ResultBoundsResponse( + @Schema(description = "최소 위도", example = "35.0") BigDecimal south, + @Schema(description = "최대 위도", example = "35.1") BigDecimal north, + @Schema(description = "최소 경도", example = "139.0") BigDecimal west, + @Schema(description = "최대 경도", example = "139.1") BigDecimal east) { + + private static ResultBoundsResponse from(ResultBounds bounds) { + return bounds == null ? null : new ResultBoundsResponse( + bounds.south(), bounds.north(), bounds.west(), bounds.east()); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "지도 조회에 적용된 정규화 조건") + public record QueryResponse( + @Schema(description = "조회 범위의 남쪽 위도", example = "35.0") BigDecimal south, + @Schema(description = "조회 범위의 북쪽 위도", example = "35.1") BigDecimal north, + @Schema(description = "조회 범위의 서쪽 경도", example = "139.0") BigDecimal west, + @Schema(description = "조회 범위의 동쪽 경도", example = "139.1") BigDecimal east, + @Schema(description = "적용된 관광 지역 ID. 지역 필터가 없으면 생략", example = "1") Long mapRegionId, + @Schema(description = "앞뒤 공백이 제거된 검색어. 검색어가 없으면 생략", example = "sushi") String keyword, + @Schema(description = "적용된 음식 장르. 필터가 없으면 생략", example = "sushi") String genre, + @Schema(description = "적용된 음식점 분류. 전체이면 생략", example = "restaurant") String placeType, + @Schema(description = "적용된 지도 정렬", allowableValues = {"recommend", "rating", "reviews"}, + example = "recommend") String sort) { + public static QueryResponse from(MapSearchCriteria criteria, RestaurantMapSort sort) { + return new QueryResponse(criteria.bounds().south(), criteria.bounds().north(), + criteria.bounds().west(), criteria.bounds().east(), criteria.mapRegionId(), criteria.keyword(), + criteria.genre() == null ? null : criteria.genre().value(), + criteria.placeType() == null ? null : criteria.placeType().value(), sort.value()); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "목록 카드와 지도 핀에 함께 사용하는 식당 정보") + public record MapCardResponse( + @Schema(description = "식당 ID", example = "1") Long restaurantId, + @Schema(description = "식당명", example = "스시 하시") String name, + @Schema(description = "rankingAsOf 기준 별점", example = "4.5") BigDecimal rating, + String thumbnailUrl, + RestaurantImageInfo thumbnailImage, List imageUrls, + List cardImages, String area, String genre, + String foodCategory, String summary, List hashtags, + TodayBusinessHourResponse todayBusinessHour, + @Schema(description = "음식점 분류", allowableValues = {"restaurant", "cafe", "bar"}) + String placeType, + @Schema(description = "rankingAsOf 기준 리뷰 수", example = "128") long reviewCount, + PriceRangeResponse priceRange, + @Schema(description = "핀 좌표와 좌표 만료 시각. content 항목에는 현재 유효한 위치만 포함") + LocationInfo location) { + public static MapCardResponse from(RestaurantSummaryResponse card, RestaurantMapCandidate rank, + String placeType, PriceRangeResponse price, LocationInfo location) { + return new MapCardResponse(card.restaurantId(), card.name(), rank.rating(), card.thumbnailUrl(), + card.thumbnailImage(), card.imageUrls(), card.cardImages(), card.area(), card.genre(), + card.foodCategory(), card.summary(), card.hashtags(), card.todayBusinessHour(), + placeType, rank.reviewCount(), price, location); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapRegionsResponse.java b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapRegionsResponse.java new file mode 100644 index 00000000..077b343b --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMapRegionsResponse.java @@ -0,0 +1,49 @@ +package org.sopt.hashi.restaurant.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.util.List; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; + +@Schema(description = "지도 최초 진입과 관광 지역 안내에 필요한 설정") +public record RestaurantMapRegionsResponse( + @Schema(description = "최초 진입 시 카메라를 맞출 권장 범위") BoundsResponse initialBounds, + @Schema(description = "서버가 허용하는 새 조회 범위 제한") QueryLimitsResponse queryLimits, + @Schema(description = "표시 순서대로 정렬된 활성 관광 지역. 식당 수 0인 지역도 포함") + List regions) { + + @Schema(description = "WGS84 위도·경도 경계") + public record BoundsResponse( + @Schema(description = "남쪽 위도", example = "35.0") BigDecimal south, + @Schema(description = "북쪽 위도", example = "35.1") BigDecimal north, + @Schema(description = "서쪽 경도", example = "139.0") BigDecimal west, + @Schema(description = "동쪽 경도", example = "139.1") BigDecimal east) { + public static BoundsResponse from(MapQueryBounds bounds) { + return new BoundsResponse(bounds.south(), bounds.north(), bounds.west(), bounds.east()); + } + } + + @Schema(description = "새 조회 BBOX의 허용 범위와 최대 크기") + public record QueryLimitsResponse( + @Schema(description = "BBOX가 완전히 포함되어야 하는 지원 범위") BoundsResponse supportedBounds, + @Schema(description = "최대 위도 폭", example = "1") int maxLatitudeSpan, + @Schema(description = "최대 경도 폭", example = "1") int maxLongitudeSpan) { + } + + @Schema(description = "WGS84 지도 위치") + public record PositionResponse( + @Schema(description = "위도", example = "35.05") BigDecimal latitude, + @Schema(description = "경도", example = "139.05") BigDecimal longitude) { + } + + @Schema(description = "활성 관광 지역 클러스터") + public record RegionResponse( + @Schema(description = "관광 지역 ID", example = "1") Long mapRegionId, + @Schema(description = "관광 지역 이름", example = "신주쿠") String name, + @Schema(description = "지역 ID와 cameraBounds를 모두 만족하는 현재 표시 가능 식당 수", example = "24") + long restaurantCount, + @Schema(description = "관광 지역 클러스터 표시 위치") PositionResponse clusterPosition, + @Schema(description = "관광 지역 선택 뒤 카메라를 맞출 범위") BoundsResponse cameraBounds, + @Schema(description = "클러스터 표시 순서", example = "1") int displayOrder) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingCandidate.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingCandidate.java new file mode 100644 index 00000000..cf7c9eee --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingCandidate.java @@ -0,0 +1,41 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.math.BigDecimal; +import java.util.List; + +/** 검증에 필요한 값만 일시적으로 전달한다. Google 원문 응답과 formattedAddress는 보관하지 않는다. */ +public record GeocodingCandidate( + BigDecimal latitude, + BigDecimal longitude, + Granularity granularity, + String countryCode, + String administrativeArea, + List addressComponents, + List types +) { + + public GeocodingCandidate { + addressComponents = List.copyOf(addressComponents); + types = List.copyOf(types); + } + + @Override + public String toString() { + return "GeocodingCandidate[redacted]"; + } + + public enum Granularity { + ROOFTOP, RANGE_INTERPOLATED, GEOMETRIC_CENTER, APPROXIMATE, UNKNOWN + } + + public record AddressComponent(String longText, String shortText, List types) { + public AddressComponent { + types = List.copyOf(types); + } + + @Override + public String toString() { + return "GeocodingAddressComponent[redacted]"; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingProvider.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingProvider.java new file mode 100644 index 00000000..d44e3c82 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingProvider.java @@ -0,0 +1,10 @@ +package org.sopt.hashi.restaurant.internal.map; + +/** + * restaurant 내부의 주소 조회 경계. 후보는 채택/저장된 위치가 아니며 호출자는 DB transaction 밖에서 호출한다. + * 재시도, 국가/주소 일치 판정, 저장 정밀도와 보관 수명은 후속 작업 처리 서비스가 담당한다. + */ +public interface GeocodingProvider { + + GeocodingResult geocode(String address); +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingResult.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingResult.java new file mode 100644 index 00000000..9f6b653e --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/GeocodingResult.java @@ -0,0 +1,43 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.util.List; + +/** 외부 예외/원문을 운반하지 않는다. 관측에는 실패 종류와 HTTP 상태만 사용한다. */ +public sealed interface GeocodingResult { + + record Candidates(List candidates) implements GeocodingResult { + public Candidates { + candidates = List.copyOf(candidates); + if (candidates.isEmpty()) { + throw new IllegalArgumentException("geocoding candidates must not be empty"); + } + } + + @Override + public String toString() { + return "GeocodingCandidates[redacted]"; + } + } + + record NoResults() implements GeocodingResult { + } + + record Failure(FailureKind kind, Integer httpStatus) implements GeocodingResult { + } + + enum FailureKind { + DISABLED, + INVALID_REQUEST, + ACCESS_DENIED, + CONFIGURATION_ERROR, + QUOTA_EXCEEDED, + TRANSIENT_ERROR, + TIMEOUT, + CANCELLED, + CAPACITY_EXCEEDED, + CONNECTION_ERROR, + INVALID_RESPONSE, + RESPONSE_TOO_LARGE, + REDIRECT_REJECTED + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobConfiguration.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobConfiguration.java new file mode 100644 index 00000000..a13201af --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobConfiguration.java @@ -0,0 +1,25 @@ +package org.sopt.hashi.restaurant.internal.map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(LocationJobProperties.class) +public class LocationJobConfiguration { + public static final String SCHEDULER = "restaurantLocationTaskScheduler"; + + @Bean(name = SCHEDULER, defaultCandidate = false) + @ConditionalOnProperty(prefix = "hashi.map.location-job", name = "enabled", havingValue = "true") + ThreadPoolTaskScheduler restaurantLocationTaskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("restaurant-location-"); + scheduler.setRemoveOnCancelPolicy(true); + scheduler.setWaitForTasksToCompleteOnShutdown(false); + scheduler.setAwaitTerminationSeconds(5); + return scheduler; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobProperties.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobProperties.java new file mode 100644 index 00000000..5925c84a --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobProperties.java @@ -0,0 +1,40 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.math.BigDecimal; +import java.time.Duration; +import org.sopt.hashi.restaurant.domain.MapBounds; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "hashi.map.location-job") +public record LocationJobProperties( + boolean enabled, + Duration retention, + BigDecimal south, + BigDecimal north, + BigDecimal west, + BigDecimal east, + Integer maxAttempts +) { + public static final Duration LEASE = Duration.ofMinutes(2); + + public LocationJobProperties { + maxAttempts = maxAttempts == null ? 8 : maxAttempts; + if (maxAttempts < 1 || maxAttempts > 8) { + throw new IllegalArgumentException("Location job max-attempts must be between 1 and 8"); + } + } + + /** Missing map settings must not prevent the existing restaurant application from starting. */ + public boolean isConfigured() { + if (retention == null || retention.compareTo(Duration.ofMinutes(5)) < 0 + || retention.compareTo(Duration.ofDays(30)) > 0) { + return false; + } + try { + MapBounds.of(south, north, west, east); + return true; + } catch (IllegalArgumentException exception) { + return false; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobScheduler.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobScheduler.java new file mode 100644 index 00000000..4c39150d --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationJobScheduler.java @@ -0,0 +1,30 @@ +package org.sopt.hashi.restaurant.internal.map; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@EnableScheduling +@ConditionalOnProperty(prefix = "hashi.map.location-job", name = "enabled", havingValue = "true") +public class LocationJobScheduler { + private final RestaurantLocationWorker worker; + + public LocationJobScheduler(RestaurantLocationWorker worker) { + this.worker = worker; + } + + @Scheduled(fixedDelayString = "${hashi.map.location-job.poll-delay:5000}", + scheduler = LocationJobConfiguration.SCHEDULER) + public void poll() { + try { + worker.runOnce(); + } catch (RuntimeException exception) { + // Lease expiry recovers failed DB completions. Exception text can contain SQL/input values. + log.warn("Location worker database cycle failed; durable jobs will be retried"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationMaintenanceMetrics.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationMaintenanceMetrics.java new file mode 100644 index 00000000..9e73de06 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationMaintenanceMetrics.java @@ -0,0 +1,87 @@ +package org.sopt.hashi.restaurant.internal.map; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.sopt.hashi.restaurant.internal.map.places.GooglePlacesProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** One bounded set of aggregate series. Never attach restaurant IDs, addresses or exception text. */ +@Component +@ConditionalOnProperty(prefix = "hashi.map.maintenance", name = "retention-enabled", havingValue = "true") +public class LocationMaintenanceMetrics { + private final JdbcTemplate jdbc; + private final LocationJobProperties jobOptions; + private final GooglePlacesProperties placesOptions; + private final LocationRetentionProperties retentionOptions; + private final Map counts = new LinkedHashMap<>(); + private final AtomicLong observedAt = new AtomicLong(); + + public LocationMaintenanceMetrics(JdbcTemplate jdbc, MeterRegistry registry, LocationJobProperties jobOptions, + GooglePlacesProperties placesOptions, + LocationRetentionProperties retentionOptions) { + this.jdbc = jdbc; + this.jobOptions = jobOptions; + this.placesOptions = placesOptions; + this.retentionOptions = retentionOptions; + for (String reason : new String[]{"access_denied", "configuration_error", "retrying", + "attempts_exhausted", "expiry_soon", "stalled", "refresh_window_invalid"}) { + var value = new AtomicLong(); + counts.put(reason, value); + Gauge.builder("hashi.map.maintenance.issues", value, AtomicLong::doubleValue) + .tag("reason", reason).register(registry); + } + Gauge.builder("hashi.map.maintenance.observed.timestamp", observedAt, AtomicLong::doubleValue) + .register(registry); + } + + public void refresh() { + var jobs = jdbc.queryForMap(""" + SELECT + COALESCE(SUM(j.failure_code='ACCESS_DENIED'),0) access_denied, + COALESCE(SUM(j.failure_code='CONFIGURATION_ERROR'),0) configuration_error, + COALESCE(SUM((j.attempt >= 3 AND j.state <> 'LEASED' OR j.attempt >= 4) AND j.failure_code IS NOT NULL + AND j.state IN ('PENDING','LEASED','RETRY_WAIT')),0) retrying, + COALESCE(SUM(j.failure_code='ATTEMPTS_EXHAUSTED'),0) attempts_exhausted, + COALESCE(SUM(? AND ((j.operation='GEOCODING' AND EXISTS ( + SELECT 1 FROM restaurant_geocoding_budget b + WHERE b.id=1 AND b.enabled=true AND b.daily_limit>0 AND b.max_concurrent>0 + AND (b.blocked_until IS NULL OR b.blocked_until <= UTC_TIMESTAMP(6)) + AND (b.budget_day IS NULL OR b.budget_day < UTC_DATE() + OR (b.budget_day = UTC_DATE() AND b.reserved_calls < b.daily_limit)))) + OR (j.operation='PLACE_DETAILS' AND ? AND EXISTS ( + SELECT 1 FROM restaurant_places_budget b + WHERE b.operation='DETAILS' AND b.enabled=true + AND b.daily_limit>0 AND b.minute_limit>0 + AND (b.blocked_until IS NULL OR b.blocked_until <= UTC_TIMESTAMP(6)) + AND (b.budget_day IS NULL OR b.budget_day < UTC_DATE() + OR (b.budget_day = UTC_DATE() AND b.daily_used < b.daily_limit)) + AND (b.minute_window_start IS NULL + OR b.minute_window_start < DATE_FORMAT( + UTC_TIMESTAMP(6), '%Y-%m-%d %H:%i:00') + OR b.minute_used < b.minute_limit)))) + AND ((j.state IN ('PENDING','RETRY_WAIT') + AND j.next_attempt_at <= UTC_TIMESTAMP(6)-INTERVAL 15 MINUTE + AND j.updated_at <= UTC_TIMESTAMP(6)-INTERVAL 15 MINUTE) + OR (j.state='LEASED' AND j.lease_until <= UTC_TIMESTAMP(6)-INTERVAL 15 MINUTE))),0) stalled + FROM restaurant_location_job j JOIN restaurant r ON r.id=j.restaurant_id + JOIN restaurant_location l ON l.id=r.location_id + WHERE r.deleted=false AND l.address_revision=j.address_revision AND l.request_id=j.request_id + """, jobOptions.enabled() && jobOptions.isConfigured(), placesOptions.enabled()); + long expiry = jdbc.queryForObject(""" + SELECT COUNT(*) FROM restaurant_location + WHERE source IN ('GOOGLE_GEOCODING', 'GOOGLE_PLACES') + AND valid_until <= UTC_TIMESTAMP(6)+INTERVAL 24 HOUR + """, Long.class); + long now = jdbc.queryForObject("SELECT UNIX_TIMESTAMP()", Long.class); + jobs.forEach((reason, value) -> counts.get(reason).set(((Number) value).longValue())); + counts.get("expiry_soon").set(expiry); + counts.get("refresh_window_invalid").set(jobOptions.enabled() && jobOptions.retention() != null + && retentionOptions.refreshAhead().compareTo(jobOptions.retention()) >= 0 ? 1 : 0); + observedAt.set(now); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionConfiguration.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionConfiguration.java new file mode 100644 index 00000000..73e8531d --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionConfiguration.java @@ -0,0 +1,9 @@ +package org.sopt.hashi.restaurant.internal.map; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(LocationRetentionProperties.class) +public class LocationRetentionConfiguration { +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionProperties.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionProperties.java new file mode 100644 index 00000000..d027ecc9 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionProperties.java @@ -0,0 +1,37 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "hashi.map.maintenance") +public record LocationRetentionProperties( + Integer batchSize, Integer maxBatches, Duration refreshAhead, Duration purgeAhead, + boolean retentionEnabled, Duration pollDelay +) { + public LocationRetentionProperties { + batchSize = batchSize == null ? 50 : batchSize; + maxBatches = maxBatches == null ? 1 : maxBatches; + refreshAhead = refreshAhead == null ? Duration.ofDays(3) : refreshAhead; + purgeAhead = purgeAhead == null ? Duration.ZERO : purgeAhead; + pollDelay = pollDelay == null ? Duration.ofMinutes(1) : pollDelay; + range(batchSize, 1, 100, "batch-size"); + range(maxBatches, 1, 100, "max-batches"); + boolean invalidWindows = pollDelay.compareTo(Duration.ofSeconds(1)) < 0 + || pollDelay.compareTo(Duration.ofHours(1)) > 0 + || purgeAhead.isNegative() + || refreshAhead.compareTo(purgeAhead) <= 0 + || refreshAhead.compareTo(Duration.ofDays(30)) > 0; + if (invalidWindows) { + throw new IllegalArgumentException("Require 0 <= purge-ahead < refresh-ahead <= 30d"); + } + if (refreshAhead.getNano() % 1_000 != 0) { + throw new IllegalArgumentException("refresh-ahead must use whole microseconds"); + } + } + + private static void range(int value, int min, int max, String name) { + if (value < min || value > max) { + throw new IllegalArgumentException(name + " is outside the allowed range"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionReader.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionReader.java new file mode 100644 index 00000000..efafa3c8 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionReader.java @@ -0,0 +1,74 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** Permanent retention reads remain available after the temporary maintenance runner is removed. */ +@Repository +public class LocationRetentionReader { + static final String PURGE_SQL = """ + SELECT r.id, r.deleted, l.address_revision, l.request_id, l.status, l.source, + l.obtained_at, l.valid_until + FROM restaurant_location l JOIN restaurant r ON r.location_id = l.id + WHERE l.source IN ('GOOGLE_GEOCODING', 'GOOGLE_PLACES') AND l.valid_until <= ? + ORDER BY l.valid_until, l.id LIMIT ? + """; + private static final DateTimeFormatter SQL_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); + private final JdbcTemplate jdbc; + + public LocationRetentionReader(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public LocalDateTime now() { + return LocalDateTime.parse(jdbc.queryForObject( + "SELECT DATE_FORMAT(UTC_TIMESTAMP(6), '%Y-%m-%dT%H:%i:%s.%f')", String.class)); + } + + public List purgeCandidates(LocalDateTime cutoff, int limit) { + // Bind UTC calendar text. setTimestamp would depend on the JDBC/session timezone. + return jdbc.query(PURGE_SQL, LocationRetentionReader::candidate, sqlTime(cutoff), limit); + } + + public long purgeRemaining(LocalDateTime cutoff) { + return jdbc.queryForObject(""" + SELECT COUNT(*) FROM restaurant_location l JOIN restaurant r ON r.location_id=l.id + WHERE l.source IN ('GOOGLE_GEOCODING', 'GOOGLE_PLACES') AND l.valid_until <= ? + """, Long.class, sqlTime(cutoff)); + } + + public List refreshCandidates(LocalDateTime now, java.time.Duration ahead, int limit) { + return jdbc.query(""" + SELECT r.id, r.deleted, l.address_revision, l.request_id, l.status, l.source, + l.obtained_at, l.valid_until + FROM restaurant_location l JOIN restaurant r ON r.location_id=l.id + WHERE r.deleted=false AND l.status='READY' + AND l.source IN ('GOOGLE_GEOCODING', 'GOOGLE_PLACES') + AND l.valid_until <= ? + AND TIMESTAMPDIFF(MICROSECOND, l.obtained_at, l.valid_until) > ? + ORDER BY l.valid_until, l.id LIMIT ? + """, LocationRetentionReader::candidate, sqlTime(now.plus(ahead)), + ahead.toNanos() / 1000, limit); + } + + private static String sqlTime(LocalDateTime time) { + return time.format(SQL_TIME); + } + + private static Candidate candidate(ResultSet rs, int row) throws SQLException { + String request = rs.getString("request_id"); + return new Candidate(rs.getLong("id"), rs.getBoolean("deleted"), rs.getLong("address_revision"), + request == null ? null : UUID.fromString(request), rs.getString("status"), rs.getString("source"), + rs.getObject("obtained_at", LocalDateTime.class), rs.getObject("valid_until", LocalDateTime.class)); + } + + public record Candidate(long restaurantId, boolean deleted, long revision, UUID requestId, + String status, String source, LocalDateTime obtainedAt, LocalDateTime validUntil) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionScheduler.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionScheduler.java new file mode 100644 index 00000000..b3499610 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionScheduler.java @@ -0,0 +1,81 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.SmartLifecycle; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +/** Private executor avoids changing Spring's default scheduler used by unrelated media work. */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "hashi.map.maintenance", name = "retention-enabled", havingValue = "true") +public class LocationRetentionScheduler implements SmartLifecycle { + private final LocationRetentionService service; + private final LocationRetentionProperties options; + private volatile ScheduledExecutorService executor; + private final ObjectProvider metrics; + + public LocationRetentionScheduler(LocationRetentionService service, LocationRetentionProperties options, + ObjectProvider metrics) { + this.service = service; + this.options = options; + this.metrics = metrics; + } + + @Override + public synchronized void start() { + if (isRunning()) { + return; + } + executor = Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "location-retention"); + thread.setDaemon(true); + return thread; + }); + executor.scheduleWithFixedDelay(this::purge, 0, options.pollDelay().toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + public synchronized void stop() { + if (executor != null) { + executor.shutdownNow(); + } + } + + @Override + public boolean isRunning() { + return executor != null && !executor.isShutdown(); + } + + private void purge() { + try { + try { + int registered = service.refresh(options); + if (registered > 0) { + log.info("Location refresh jobs registered: count={}", registered); + } + } catch (RuntimeException exception) { + log.warn("Location refresh registration failed: type={}", exception.getClass().getSimpleName()); + } + var report = service.purge(options); + log.info("Location retention: {}", report); + if (report.dueRemaining() > 0) { + log.warn("Location retention backlog requires operator action: due={}, overdue={}", + report.dueRemaining(), report.overdueRemaining()); + } + } catch (RuntimeException exception) { + // No exception messages: SQL/connection exceptions can carry private values. + log.error("Location retention failed; check scheduler and database health before the retention deadline"); + } finally { + try { + metrics.ifAvailable(LocationMaintenanceMetrics::refresh); + } catch (RuntimeException exception) { + log.warn("Location maintenance metrics failed: type={}", exception.getClass().getSimpleName()); + } + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionService.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionService.java new file mode 100644 index 00000000..1710c736 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionService.java @@ -0,0 +1,68 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.time.Instant; +import java.time.ZoneOffset; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +public class LocationRetentionService { + private final LocationRetentionReader reader; + private final LocationRetentionTransactions transactions; + + public LocationRetentionService(LocationRetentionReader reader, LocationRetentionTransactions transactions) { + this.reader = reader; + this.transactions = transactions; + } + + public int refresh(LocationRetentionProperties options) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("Refresh loop must run outside a transaction"); + } + int registered = 0; + for (int batch = 0; batch < options.maxBatches() && !Thread.currentThread().isInterrupted(); batch++) { + var candidates = reader.refreshCandidates(reader.now(), options.refreshAhead(), options.batchSize()); + for (var candidate : candidates) { + if (Thread.currentThread().isInterrupted()) { + break; + } + if (transactions.refresh(candidate, options.refreshAhead())) { + registered++; + } + } + if (candidates.size() < options.batchSize()) { + break; + } + } + return registered; + } + + public Report purge(LocationRetentionProperties options) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("Retention loop must run outside a transaction"); + } + int inspected = 0; + int purged = 0; + for (int batch = 0; batch < options.maxBatches() && !Thread.currentThread().isInterrupted(); batch++) { + var candidates = reader.purgeCandidates(reader.now().plus(options.purgeAhead()), options.batchSize()); + for (var candidate : candidates) { + if (Thread.currentThread().isInterrupted()) { + break; + } + inspected++; + if (transactions.purge(candidate, options.purgeAhead())) { + purged++; + } + } + if (candidates.size() < options.batchSize()) { + break; + } + } + var now = reader.now(); + return new Report(now.toInstant(ZoneOffset.UTC), inspected, purged, + reader.purgeRemaining(now.plus(options.purgeAhead())), reader.purgeRemaining(now)); + } + + public record Report(Instant asOfUtc, int inspected, int purged, long dueRemaining, long overdueRemaining) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionTransactions.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionTransactions.java new file mode 100644 index 00000000..b07e8b0d --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionTransactions.java @@ -0,0 +1,68 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.time.Duration; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantLocationStatus; +import org.sopt.hashi.restaurant.service.RestaurantLocationService; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionReader.Candidate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class LocationRetentionTransactions { + private final RestaurantRepository restaurants; + private final LocationRetentionReader reader; + + private final RestaurantLocationJobRepository jobs; + private final RestaurantLocationService locations; + + public LocationRetentionTransactions(RestaurantRepository restaurants, LocationRetentionReader reader, + RestaurantLocationJobRepository jobs, RestaurantLocationService locations) { + this.restaurants = restaurants; + this.reader = reader; + this.jobs = jobs; + this.locations = locations; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED, timeout = 10) + public boolean refresh(Candidate candidate, Duration ahead) { + Restaurant restaurant = restaurants.findByIdForUpdate(candidate.restaurantId()).orElse(null); + if (restaurant == null || restaurant.isDeleted() || restaurant.getLocation() == null) { + return false; + } + var location = restaurant.getLocation(); + var now = reader.now(); + RestaurantLocationSource source = location.getSource(); + boolean current = location.getStatus() == RestaurantLocationStatus.READY + && (source == RestaurantLocationSource.GOOGLE_GEOCODING + || source == RestaurantLocationSource.GOOGLE_PLACES) + && location.getAddressRevision() == candidate.revision() + && location.getRequestId().equals(candidate.requestId()) + && !location.getValidUntil().isAfter(now.plus(ahead)) + && Duration.between(location.getObtainedAt(), location.getValidUntil()).compareTo(ahead) > 0; + if (!current || !jobs.findActiveForUpdate(restaurant.getId()).isEmpty()) { + return false; + } + String googlePlaceId = location.getGooglePlaceId(); + restaurant.refreshLocation(); + if (source == RestaurantLocationSource.GOOGLE_PLACES) { + jobs.save(RestaurantLocationJob.pendingDetails(restaurant, googlePlaceId, now)); + } else { + locations.enqueue(restaurant); + } + return true; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED, timeout = 10) + public boolean purge(Candidate candidate, Duration purgeAhead) { + Restaurant restaurant = restaurants.findByIdForUpdate(candidate.restaurantId()).orElse(null); + return restaurant != null && restaurant.purgeGoogleLocation(candidate.revision(), candidate.requestId(), + candidate.obtainedAt(), candidate.validUntil(), reader.now().plus(purgeAhead)); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapCapacityMetrics.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapCapacityMetrics.java new file mode 100644 index 00000000..c15bc191 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapCapacityMetrics.java @@ -0,0 +1,106 @@ +package org.sopt.hashi.restaurant.internal.map; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** 공개 오류는 유지하면서 고정된 내부 사유와 처리 단계만 기록한다. 요청값이나 식별자는 태그에 넣지 않는다. */ +@Slf4j +@Component +public class MapCapacityMetrics { + static final String REJECTION_METRIC = "hashi.restaurant.map.capacity.rejected"; + static final String STAGE_DURATION_METRIC = "hashi.restaurant.map.stage.duration"; + + private final MeterRegistry registry; + private final AtomicLong lastRecoveryWarning = new AtomicLong(); + + public MapCapacityMetrics(MeterRegistry registry) { + this.registry = registry; + } + + public void rejected(Reason reason) { + if (reason == Reason.REDIS_RECOVERY) { + long now = System.currentTimeMillis(); + long previous = lastRecoveryWarning.get(); + if (now - previous >= 60_000 && lastRecoveryWarning.compareAndSet(previous, now)) { + log.warn("Map shared Redis accounting is recovering; initial or missing metadata " + + "blocks map sessions for up to 30 minutes. Authentication keys are unchanged."); + } + } + try { + registry.counter(REJECTION_METRIC, "reason", reason.tag).increment(); + } catch (RuntimeException exception) { + log.warn("Failed to record a map capacity metric"); + } + } + + /** Timer.record가 성공과 예외 경로 모두 finally에서 시간을 기록하고 원래 결과나 예외를 그대로 전달한다. */ + public T record(Operation operation, Stage stage, Supplier action) { + long startedAt = System.nanoTime(); + try { + return action.get(); + } finally { + try { + Timer.builder(STAGE_DURATION_METRIC) + .description("Restaurant map request stage duration") + .tag("operation", operation.tag) + .tag("stage", stage.tag) + .register(registry) + .record(System.nanoTime() - startedAt, TimeUnit.NANOSECONDS); + } catch (RuntimeException exception) { + log.warn("Failed to record a map stage metric"); + } + } + } + + public enum Operation { + NEW_QUERY("new_query"), + SORT_CHANGE("sort_change"), + NEXT_PAGE("next_page"); + + private final String tag; + + Operation(String tag) { + this.tag = tag; + } + } + + public enum Stage { + ADMIT("admit"), + LOAD_SESSION("load_session"), + CANDIDATES("candidates"), + SAVE("save"), + READ_PAGE("read_page"), + TOUCH("touch"); + + private final String tag; + + Stage(String tag) { + this.tag = tag; + } + } + + public enum Reason { + CONCURRENT_REQUESTS("concurrent_requests"), + CANDIDATE_COUNT("candidate_count"), + SNAPSHOT_BYTES("snapshot_bytes"), + TOTAL_BYTES("total_bytes"), + SESSION_COUNT("session_count"), + CALLER_RATE("caller_rate"), + CALLER_CARDINALITY("caller_cardinality"), + GLOBAL_REQUESTS("global_requests"), + REDIS_MEMORY_GUARD("redis_memory_guard"), + REDIS_RECOVERY("redis_recovery"), + REDIS_ACCOUNTING("redis_accounting"); + + private final String tag; + + Reason(String tag) { + this.tag = tag; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapCursorCodec.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapCursorCodec.java new file mode 100644 index 00000000..5ce621ee --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapCursorCodec.java @@ -0,0 +1,96 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Base64; +import java.util.UUID; +import javax.crypto.Mac; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.springframework.stereotype.Component; + +@Component +public class MapCursorCodec { + private static final int VERSION = 2; + private static final int PAYLOAD_SIZE = 22; + private static final int SIGNATURE_SIZE = 32; + private final MapSessionProperties properties; + + public MapCursorCodec(MapSessionProperties properties) { + this.properties = properties; + } + + public void requireConfigured() { + properties.requireConfigured(); + } + + public String encode(MapSessionId session, RestaurantMapSort sort, int position) { + validatePosition(position); + ByteBuffer bytes = ByteBuffer.allocate(PAYLOAD_SIZE + SIGNATURE_SIZE); + bytes.put((byte) VERSION).putLong(session.id().getMostSignificantBits()) + .putLong(session.id().getLeastSignificantBits()).put((byte) sort.ordinal()).putInt(position); + bytes.put(sign(Arrays.copyOf(bytes.array(), PAYLOAD_SIZE))); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes.array()); + } + + public DecodedCursor decode(String token) { + if (token == null || token.length() > 512 || !token.matches("[A-Za-z0-9_-]{72}")) { + throw invalid(); + } + byte[] bytes; + try { + bytes = Base64.getUrlDecoder().decode(token); + } catch (IllegalArgumentException exception) { + throw invalid(); + } + boolean canonical = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).equals(token); + if (!canonical || !MessageDigest.isEqual(sign(Arrays.copyOf(bytes, PAYLOAD_SIZE)), + Arrays.copyOfRange(bytes, PAYLOAD_SIZE, bytes.length))) { + throw invalid(); + } + ByteBuffer payload = ByteBuffer.wrap(bytes); + int version = Byte.toUnsignedInt(payload.get()); + UUID id = new UUID(payload.getLong(), payload.getLong()); + int sort = Byte.toUnsignedInt(payload.get()); + int position = payload.getInt(); + if (version != VERSION || sort >= RestaurantMapSort.values().length) { + throw invalid(); + } + validatePosition(position); + return new DecodedCursor(new MapSessionId(id), RestaurantMapSort.values()[sort], position); + } + + public String callerKey(String remoteAddress) { + String address = remoteAddress == null ? "unknown" : remoteAddress; + return Base64.getUrlEncoder().withoutPadding().encodeToString( + sign(("map-caller:" + address).getBytes(StandardCharsets.UTF_8))); + } + + private byte[] sign(byte[] payload) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(properties.requireSigningKey()); + return mac.doFinal(payload); + } catch (GeneralSecurityException exception) { + throw new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + } + + private static void validatePosition(int position) { + if (position < 1 || position > MapQuerySession.MAX_CANDIDATES) { + throw invalid(); + } + } + + private static BusinessException invalid() { + return new BusinessException(CommonErrorCode.INVALID_INPUT); + } + + public record DecodedCursor(MapSessionId session, RestaurantMapSort sort, int position) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQueryFailureLogger.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQueryFailureLogger.java new file mode 100644 index 00000000..aa18d0d9 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQueryFailureLogger.java @@ -0,0 +1,16 @@ +package org.sopt.hashi.restaurant.internal.map; + +import lombok.extern.slf4j.Slf4j; + +/** DB 오류의 SQL, 입력값, 연결 정보와 stack trace를 기록하지 않는 지도 조회 진단. */ +@Slf4j +public final class MapQueryFailureLogger { + + private MapQueryFailureLogger() { + } + + public static void warn(RuntimeException exception) { + log.warn("Map query failed. operation=restaurant-map-query exceptionType={}", + exception.getClass().getSimpleName()); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQueryProperties.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQueryProperties.java new file mode 100644 index 00000000..47d45121 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQueryProperties.java @@ -0,0 +1,47 @@ +package org.sopt.hashi.restaurant.internal.map; + +import lombok.Getter; +import lombok.Setter; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** 문자열로 바인딩한 뒤 지도 요청에서 검증해 잘못된 지도 설정이 서버 시작을 막지 않게 한다. */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "hashi.restaurant.map") +public class MapQueryProperties { + + private Bounds initialBounds = new Bounds(); + private Bounds supportedBounds = new Bounds(); + + public Configuration requireConfiguration() { + try { + MapQueryBounds initial = initialBounds.parse(); + MapQueryBounds supported = supportedBounds.parse(); + initial.validateQueryWithin(supported); + return new Configuration(initial, supported); + } catch (BusinessException | NullPointerException exception) { + throw new BusinessException(RestaurantErrorCode.MAP_CONFIGURATION_UNAVAILABLE); + } + } + + @Getter + @Setter + public static class Bounds { + private String south; + private String north; + private String west; + private String east; + + private MapQueryBounds parse() { + return MapQueryBounds.parse(south, north, west, east); + } + } + + public record Configuration(MapQueryBounds initialBounds, MapQueryBounds supportedBounds) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQuerySession.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQuerySession.java new file mode 100644 index 00000000..a90c440f --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapQuerySession.java @@ -0,0 +1,79 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent.ResultBounds; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; + +/** Redis에는 조회 조건, 최초 추천 순서/순위 값, 검색 결과의 집계 경계만 저장한다. 개별 좌표·카드·개인 상태는 없다. */ +public record MapQuerySession(int schemaVersion, UUID id, MapSearchCriteria criteria, + List candidates, MapSearchResultExtent searchResult, + Instant rankingAsOf, Instant expiresAt) { + public static final int SCHEMA_VERSION = 2; + public static final int MAX_CANDIDATES = 4_194_304 / 32; + public static final int MAX_BYTES = 4_194_304; + public static final Duration LIFETIME = Duration.ofMinutes(30); + + public MapQuerySession { + if (schemaVersion != SCHEMA_VERSION || id == null || criteria == null || candidates == null + || candidates.size() > MAX_CANDIDATES || rankingAsOf == null || expiresAt == null) { + throw new IllegalArgumentException("Invalid map session"); + } + var ids = new HashSet(); + for (var candidate : candidates) { + if (candidate == null || candidate.restaurantId() == null || candidate.restaurantId() <= 0 + || candidate.rating() == null || candidate.rating().signum() < 0 || candidate.reviewCount() < 0 + || !ids.add(candidate.restaurantId())) { + throw new IllegalArgumentException("Invalid map candidate"); + } + } + if (!validSearchResult(criteria, candidates.size(), searchResult, rankingAsOf, expiresAt)) { + throw new IllegalArgumentException("Invalid map search result"); + } + candidates = List.copyOf(candidates); + } + + private static boolean validSearchResult(MapSearchCriteria criteria, int candidateCount, + MapSearchResultExtent result, Instant rankingAsOf, Instant expiresAt) { + if (criteria.keyword() == null) { + return result == null; + } + if (result == null || result.totalCount() != candidateCount) { + return false; + } + if (result.bounds() == null) { + return result.totalCount() == 0; + } + return contains(criteria, result.bounds()) && result.earliestValidUntil().isAfter(rankingAsOf) + && !expiresAt.isAfter(result.earliestValidUntil()); + } + + private static boolean contains(MapSearchCriteria criteria, ResultBounds result) { + var query = criteria.bounds(); + return result.south().compareTo(query.south()) >= 0 && result.north().compareTo(query.north()) <= 0 + && result.west().compareTo(query.west()) >= 0 && result.east().compareTo(query.east()) <= 0; + } + + /** Stream의 안정 정렬이 최초 추천 순서를 동점 기준으로 보존한다. */ + public List ordered(RestaurantMapSort sort) { + return switch (sort) { + case RECOMMEND -> candidates; + case RATING -> candidates.stream().sorted(Comparator.comparing(RestaurantMapCandidate::rating) + .reversed()).toList(); + case REVIEWS -> candidates.stream().sorted(Comparator.comparingLong(RestaurantMapCandidate::reviewCount) + .reversed()).toList(); + }; + } + + @Override + public String toString() { + return "MapQuerySession[version=" + schemaVersion + ", candidates=" + candidates.size() + "]"; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisConnection.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisConnection.java new file mode 100644 index 00000000..1a190657 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisConnection.java @@ -0,0 +1,115 @@ +package org.sopt.hashi.restaurant.internal.map; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import io.lettuce.core.resource.ClientResources; +import io.lettuce.core.resource.DefaultClientResources; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * 지도 전용 연결의 생애를 소유한다. factory/template을 빈으로 노출하지 않아 + * Boot의 인증용 Redis 자동 구성과 기존 주입 지점을 바꾸지 않는다. + */ +@Slf4j +@Component +public class MapRedisConnection { + private final MapRedisProperties properties; + private final MapSessionProperties sessions; + private ClientResources clientResources; + private LettuceConnectionFactory connections; + private StringRedisTemplate template; + private boolean closed; + + public MapRedisConnection(MapRedisProperties properties, MapSessionProperties sessions) { + this.properties = properties; + this.sessions = sessions; + } + + @PostConstruct + void reportConfiguration() { + if (!sessions.isEnabled()) { + return; + } + try { + properties.requireConfiguration(); + } catch (BusinessException exception) { + log.warn("Map query sessions are enabled but the map Redis configuration is missing or invalid."); + } + } + + boolean shared() { + return properties.shared(); + } + + /** 첫 유효 요청에서만 초기화하며, 동시 요청이 별도 연결을 중복 생성하지 않는다. */ + synchronized StringRedisTemplate template() { + sessions.requireConfigured(); + var settings = properties.requireConfiguration(); + if (closed) { + throw unavailable(); + } + if (template != null) { + return template; + } + var server = new RedisStandaloneConfiguration(properties.getHost(), settings.port()); + if (properties.getUsername() != null && !properties.getUsername().isBlank()) { + server.setUsername(properties.getUsername()); + } + if (properties.getPassword() != null && !properties.getPassword().isEmpty()) { + server.setPassword(properties.getPassword()); + } + if (clientResources == null) { + clientResources = DefaultClientResources.builder().ioThreadPoolSize(2).computationThreadPoolSize(2).build(); + } + var client = LettuceClientConfiguration.builder() + .clientResources(clientResources) + .commandTimeout(settings.timeout()) + .clientOptions(ClientOptions.builder() + .socketOptions(SocketOptions.builder().connectTimeout(settings.connectTimeout()) + .keepAlive(true).build()) + .requestQueueSize(256) + .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS) + .build()); + if (settings.ssl()) { + client.useSsl(); // 기본 인증서/호스트 검증 유지. + } + var created = new LettuceConnectionFactory(server, client.build()); + try { + created.afterPropertiesSet(); + template = new StringRedisTemplate(created); + connections = created; + return template; + } catch (RuntimeException exception) { + created.destroy(); + // 접속정보를 포함할 수 있는 드라이버 예외는 외부로 전달하지 않는다. + throw unavailable(); + } + } + + @PreDestroy + synchronized void close() { + closed = true; + template = null; + if (connections != null) { + connections.destroy(); + connections = null; + } + if (clientResources != null) { + clientResources.shutdown(); + clientResources = null; + } + } + + private static BusinessException unavailable() { + return new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisMetrics.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisMetrics.java new file mode 100644 index 00000000..fdeb6a53 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisMetrics.java @@ -0,0 +1,83 @@ +package org.sopt.hashi.restaurant.internal.map; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.List; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** 조회가 없어도 메모리 증가를 관측한다. 실패 시 이전 값으로 정상처럼 보이지 않도록 probe 상태를 내린다. */ +@Component +public class MapRedisMetrics { + private static final DefaultRedisScript PROBE = new DefaultRedisScript<>(""" + local memory = redis.call('INFO', 'memory') + local stats = redis.call('INFO', 'stats') + return {string.match(memory, 'used_memory:(%d+)'), string.match(memory, 'maxmemory:(%d+)'), + string.match(stats, 'evicted_keys:(%d+)')} + """, List.class); + private final MapRedisConnection redis; + private final MapSessionProperties sessions; + private final MapSessionLimits limits; + private volatile double usedBytes = Double.NaN; + private volatile double admissionLimitBytes = Double.NaN; + private volatile double warningLimitBytes = Double.NaN; + private volatile double evictions = Double.NaN; + private volatile double probeSuccess; + + public MapRedisMetrics(MapRedisConnection redis, MapSessionProperties sessions, + MapSessionLimits limits, MeterRegistry registry) { + this.redis = redis; + this.sessions = sessions; + this.limits = limits; + Gauge.builder("hashi.restaurant.map.redis.enabled", sessions, + value -> value.isEnabled() ? 1 : 0).register(registry); + Gauge.builder("hashi.restaurant.map.redis.used.bytes", this, value -> value.usedBytes).register(registry); + Gauge.builder("hashi.restaurant.map.redis.admission.limit.bytes", this, + value -> value.admissionLimitBytes).register(registry); + Gauge.builder("hashi.restaurant.map.redis.warning.limit.bytes", this, + value -> value.warningLimitBytes).register(registry); + Gauge.builder("hashi.restaurant.map.redis.evicted.keys", this, value -> value.evictions).register(registry); + Gauge.builder("hashi.restaurant.map.redis.probe.success", this, value -> value.probeSuccess).register(registry); + } + + /** 명령 하나로 집계만 읽으며 서버 설정·사용자 키·좌표는 조회하지 않는다. */ + @Scheduled(fixedDelay = 30_000, initialDelay = 30_000) + void refresh() { + if (!sessions.isEnabled()) { + invalidate(); + return; + } + try { + limits.validate(); + List values = redis.template().execute(PROBE, List.of()); + if (values == null || values.size() != 3) { + throw new IllegalStateException("Map Redis metrics are unavailable"); + } + long used = Long.parseLong(values.get(0).toString()); + long maximum = Long.parseLong(values.get(1).toString()); + long evicted = Long.parseLong(values.get(2).toString()); + long ceiling = maximum > 0 ? Math.min(maximum, limits.getRedisMemoryCeiling()) + : limits.getRedisMemoryCeiling(); + if (used < 0 || maximum < 0 || evicted < 0 || ceiling <= limits.getRedisHeadroom()) { + throw new IllegalStateException("Invalid map Redis metrics"); + } + usedBytes = used; + admissionLimitBytes = ceiling - limits.getRedisHeadroom(); + warningLimitBytes = admissionLimitBytes * 0.75; + evictions = evicted; + probeSuccess = 1; + } catch (RuntimeException exception) { + // 드라이버 오류는 접속 정보를 포함할 수 있어 원문을 기록하지 않는다. + invalidate(); + } + } + + private void invalidate() { + probeSuccess = 0; + usedBytes = Double.NaN; + admissionLimitBytes = Double.NaN; + warningLimitBytes = Double.NaN; + evictions = Double.NaN; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisProperties.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisProperties.java new file mode 100644 index 00000000..1971062e --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapRedisProperties.java @@ -0,0 +1,59 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.time.Duration; +import lombok.Getter; +import lombok.Setter; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.convert.DurationStyle; +import org.springframework.stereotype.Component; + +/** 인증 Redis 설정과 별개다. host를 생략해도 기존 Redis로 연결하지 않는다. */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "hashi.restaurant.map.redis") +public class MapRedisProperties { + private String host; + private String port = "6379"; + private String ssl = "false"; + private String username; + private String password; + private String connectTimeout = "2s"; + private String timeout = "3s"; + private String mode = "dedicated"; + + boolean shared() { + if (!"dedicated".equals(mode) && !"shared".equals(mode)) { + throw new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + return "shared".equals(mode); + } + + /** 먼저 문자열로 바인딩해 지도 설정 오타가 다른 API의 시작을 막지 않게 한다. */ + ConnectionSettings requireConfiguration() { + try { + shared(); + int parsedPort = Integer.parseInt(port.strip()); + Duration parsedConnectTimeout = DurationStyle.detectAndParse(connectTimeout.strip()); + Duration parsedTimeout = DurationStyle.detectAndParse(timeout.strip()); + if (host == null || host.isBlank() || parsedPort < 1 || parsedPort > 65535 + || !validTimeout(parsedConnectTimeout) || !validTimeout(parsedTimeout) + || !("true".equalsIgnoreCase(ssl) || "false".equalsIgnoreCase(ssl))) { + throw new IllegalArgumentException(); + } + return new ConnectionSettings(parsedPort, Boolean.parseBoolean(ssl), parsedConnectTimeout, parsedTimeout); + } catch (IllegalArgumentException | NullPointerException exception) { + throw new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + } + + private boolean validTimeout(Duration duration) { + return duration != null && duration.compareTo(Duration.ofMillis(1)) >= 0 + && duration.compareTo(Duration.ofSeconds(30)) <= 0; + } + + record ConnectionSettings(int port, boolean ssl, Duration connectTimeout, Duration timeout) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionId.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionId.java new file mode 100644 index 00000000..a34bf959 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionId.java @@ -0,0 +1,25 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.util.UUID; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; + +/** 조회 ID 하나가 Redis 키 하나를 식별한다. */ +public record MapSessionId(UUID id) { + public MapSessionId { + if (id == null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } + + public String value() { + return id.toString(); + } + + public static MapSessionId parse(String value) { + if (value == null || !value.matches("[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + return new MapSessionId(UUID.fromString(value)); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionLimits.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionLimits.java new file mode 100644 index 00000000..c212523f --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionLimits.java @@ -0,0 +1,49 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.time.Duration; +import lombok.Getter; +import lombok.Setter; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** 초기 운영값이다. 공개 전에 요청량과 Redis 메모리를 측정해 조정한다. */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "hashi.restaurant.map.session.limits") +public class MapSessionLimits { + private int concurrentRequests = 4; + private Duration idleTimeout = Duration.ofMinutes(5); + private Duration maxLifetime = Duration.ofMinutes(30); + private int snapshotBytes = 1_048_576; + private long totalBytes = 16_777_216; + private int sessions = 1024; + private int callersPerMinute = 2048; + private int requestsPerCaller = 120; + private int newQueriesPerCaller = 12; + private int requestsPerMinute = 600; + private long redisMemoryCeiling = 134_217_728; + private long redisHeadroom = 33_554_432; + + public void validate() { + if (concurrentRequests < 1 || concurrentRequests > 16 || idleTimeout == null || idleTimeout.compareTo(Duration.ofSeconds(1)) < 0 + || idleTimeout.compareTo(Duration.ofMinutes(10)) > 0 || maxLifetime == null + || maxLifetime.compareTo(idleTimeout) < 0 || maxLifetime.compareTo(Duration.ofMinutes(30)) > 0 + || snapshotBytes < 65_536 || snapshotBytes > MapQuerySession.MAX_BYTES + || totalBytes < snapshotBytes || totalBytes > 67_108_864 || sessions < 1 || sessions > 4096 + || callersPerMinute < 1 || callersPerMinute > 4096 || requestsPerCaller < 1 + || requestsPerCaller > 600 || newQueriesPerCaller < 1 || newQueriesPerCaller > requestsPerCaller + || requestsPerMinute < 1 || requestsPerMinute > 6000 + || redisHeadroom < totalBytes || redisMemoryCeiling < redisHeadroom + totalBytes) { + throw new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + } + + /** DB 결과를 무제한 적재하지 않는 보수적 32-byte 계획 단위다. 최종 tuple JSON은 실제 byte로 다시 검사한다. */ + public int candidateCapacity() { + validate(); + return snapshotBytes / 32; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionProperties.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionProperties.java new file mode 100644 index 00000000..a3bb6990 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionProperties.java @@ -0,0 +1,66 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.util.Base64; +import jakarta.annotation.PostConstruct; +import javax.crypto.spec.SecretKeySpec; +import lombok.extern.slf4j.Slf4j; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** 공개 활성화와 키 준비를 분리하며, 설정 오류가 앱 시작과 다른 API에 영향을 주지 않게 한다. */ +@Slf4j +@Component +@ConfigurationProperties(prefix = "hashi.restaurant.map.session") +public class MapSessionProperties { + private boolean enabled; + private String signingKey; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setSigningKey(String signingKey) { + this.signingKey = signingKey; + } + + @PostConstruct + void reportConfiguration() { + if (!enabled) { + log.info("Map query sessions are disabled."); + return; + } + try { + requireSigningKey(); + } catch (BusinessException exception) { + log.warn("Map query sessions are enabled but the signing key is missing or invalid."); + } + } + + public void requireConfigured() { + if (!enabled) { + throw new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + requireSigningKey(); + } + + public SecretKeySpec requireSigningKey() { + try { + if (signingKey == null || signingKey.length() > 128) { + throw new IllegalArgumentException(); + } + byte[] key = Base64.getDecoder().decode(signingKey); + if (key.length < 32 || key.length > 64) { + throw new IllegalArgumentException(); + } + return new SecretKeySpec(key, "HmacSHA256"); + } catch (IllegalArgumentException exception) { + throw new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionSerializer.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionSerializer.java new file mode 100644 index 00000000..0534546b --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/MapSessionSerializer.java @@ -0,0 +1,117 @@ +package org.sopt.hashi.restaurant.internal.map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.stereotype.Component; + +/** 기본 typing을 사용하지 않고 버전이 있는 구체 record로만 역직렬화한다. */ +@Component +public class MapSessionSerializer { + private static final int FORMAT_VERSION = 2; + private final ObjectMapper mapper = new ObjectMapper(JsonFactory.builder().streamReadConstraints( + StreamReadConstraints.builder().maxNumberLength(MapQuerySession.MAX_BYTES) + .maxStringLength(MapQuerySession.MAX_BYTES).maxNestingDepth(16).build()).build()) + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + public String serialize(MapQuerySession session) { + try { + String json = mapper.writeValueAsString(SessionPayload.from(session)); + if (json.getBytes(StandardCharsets.UTF_8).length > MapQuerySession.MAX_BYTES) { + throw new BusinessException(RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + } + return json; + } catch (JsonProcessingException exception) { + throw new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + } + + public MapQuerySession deserialize(String json) { + try { + if (json == null || json.getBytes(StandardCharsets.UTF_8).length > MapQuerySession.MAX_BYTES) { + throw new IllegalArgumentException(); + } + SessionPayload payload = mapper.readValue(json, SessionPayload.class); + if (payload == null || payload.formatVersion() != FORMAT_VERSION) { + throw new IllegalArgumentException(); + } + return payload.toSession(); + } catch (JsonProcessingException | IllegalArgumentException exception) { + // 구버전/손상 payload의 파서 메시지에는 검색어가 있을 수 있으므로 cause를 전달하지 않는다. + throw new BusinessException(RestaurantErrorCode.MAP_SESSION_EXPIRED); + } + } + + @JsonPropertyOrder({"formatVersion", "schemaVersion", "id", "criteria", "candidates", "searchResult", + "rankingAsOf", "expiresAt"}) + private record SessionPayload(int formatVersion, int schemaVersion, UUID id, MapSearchCriteria criteria, + List candidates, MapSearchResultExtent searchResult, + Instant rankingAsOf, Instant expiresAt) { + + private static SessionPayload from(MapQuerySession session) { + return new SessionPayload(FORMAT_VERSION, session.schemaVersion(), session.id(), session.criteria(), + session.candidates().stream().map(CandidateRow::from).toList(), + session.searchResult(), session.rankingAsOf(), session.expiresAt()); + } + + private MapQuerySession toSession() { + if (candidates == null || candidates.stream().anyMatch(candidate -> candidate == null)) { + throw new IllegalArgumentException("Invalid map session payload"); + } + return new MapQuerySession(schemaVersion, id, criteria, + candidates.stream().map(CandidateRow::toCandidate).toList(), searchResult, rankingAsOf, expiresAt); + } + } + + /** 후보의 세 값은 한 JSON tuple로 묶어 순서 어긋남 없이 반복 필드명만 제거한다. */ + private record CandidateRow(Long restaurantId, BigDecimal rating, Long reviewCount) { + + private CandidateRow { + if (restaurantId == null || rating == null || reviewCount == null) { + throw new IllegalArgumentException("Invalid map candidate payload"); + } + } + + private static CandidateRow from(RestaurantMapCandidate candidate) { + return new CandidateRow(candidate.restaurantId(), candidate.rating(), candidate.reviewCount()); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + private static CandidateRow fromJson(List values) { + if (values == null || values.size() != 3 || values.stream().anyMatch(value -> value == null)) { + throw new IllegalArgumentException("Invalid map candidate payload"); + } + try { + return new CandidateRow(values.get(0).longValueExact(), values.get(1), values.get(2).longValueExact()); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("Invalid map candidate payload"); + } + } + + @JsonValue + private List toJson() { + return List.of(restaurantId, rating, reviewCount); + } + + private RestaurantMapCandidate toCandidate() { + return new RestaurantMapCandidate(restaurantId, rating, reviewCount); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionConfiguration.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionConfiguration.java new file mode 100644 index 00000000..6aaafb25 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionConfiguration.java @@ -0,0 +1,9 @@ +package org.sopt.hashi.restaurant.internal.map; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(PlacesSelectionProperties.class) +public class PlacesSelectionConfiguration { +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionProperties.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionProperties.java new file mode 100644 index 00000000..12508d88 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionProperties.java @@ -0,0 +1,37 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.time.Duration; +import java.util.Base64; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "hashi.map.places-selection") +public record PlacesSelectionProperties(boolean enabled, String signingKey, Duration tokenTtl) { + private static final Duration REQUIRED_TTL = Duration.ofMinutes(10); + + public PlacesSelectionProperties { + tokenTtl = tokenTtl == null ? REQUIRED_TTL : tokenTtl; + } + + public boolean isConfigured() { + if (!enabled || signingKey == null || !REQUIRED_TTL.equals(tokenTtl)) { + return false; + } + try { + return Base64.getDecoder().decode(signingKey).length >= 32; + } catch (IllegalArgumentException exception) { + return false; + } + } + + byte[] signingKeyBytes() { + if (!isConfigured()) { + throw new IllegalStateException("Places selection signing is not configured"); + } + return Base64.getDecoder().decode(signingKey); + } + + @Override + public String toString() { + return "PlacesSelectionProperties[redacted]"; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionToken.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionToken.java new file mode 100644 index 00000000..5168c479 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionToken.java @@ -0,0 +1,141 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.sopt.hashi.restaurant.RestaurantLocationPreviewCommand; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +/** 후보와 확인 당시 입력·관리자를 10분 동안 묶는다. 등록 전후의 토큰은 서로 사용할 수 없다. */ +@Component +public class PlacesSelectionToken { + private static final String HMAC = "HmacSHA256"; + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + private final PlacesSelectionProperties properties; + private final Clock clock; + + public PlacesSelectionToken(PlacesSelectionProperties properties, @Qualifier("japanClock") Clock clock) { + this.properties = properties; + this.clock = clock; + } + + public boolean isConfigured() { + return properties.isConfigured(); + } + + public Issued issue(Long restaurantId, long addressRevision, UUID requestId, String placeId, Long adminId) { + return issue("2", restaurantId, addressRevision, requestId, placeId, adminId, ""); + } + + public Issued issuePreview(RestaurantLocationPreviewCommand input, String placeId, Long adminId) { + return issue("3", 0L, 0, new UUID(0, 0), placeId, adminId, fingerprint(input)); + } + + private Issued issue(String version, Long restaurantId, long revision, UUID requestId, + String placeId, Long adminId, String fingerprint) { + if (!isConfigured()) { + throw new IllegalStateException("Places selection signing is not configured"); + } + Instant issuedAt = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS); + Instant expiresAt = issuedAt.plus(properties.tokenTtl()); + String encodedPlaceId = ENCODER.encodeToString(placeId.getBytes(StandardCharsets.UTF_8)); + String payload = String.join("|", version, restaurantId.toString(), Long.toString(revision), + requestId.toString(), encodedPlaceId, Long.toString(issuedAt.getEpochSecond()), + Long.toString(expiresAt.getEpochSecond()), adminId.toString(), fingerprint); + byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8); + String value = ENCODER.encodeToString(payloadBytes) + "." + ENCODER.encodeToString(sign(payloadBytes)); + return new Issued(value, expiresAt); + } + + public static String fingerprint(RestaurantLocationPreviewCommand input) { + // Length-prefix each raw input so null, empty and delimiter-containing values remain distinct. + StringBuilder value = new StringBuilder(); + for (String field : new String[]{input.name(), input.localName(), input.address(), input.geocodingAddress()}) { + value.append(field == null ? -1 : field.length()).append(':'); + if (field != null) { + value.append(field); + } + } + try { + return ENCODER.encodeToString(MessageDigest.getInstance("SHA-256") + .digest(value.toString().getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + public Optional verify(String token) { + if (!isConfigured() || token == null || token.length() > 2048) { + return Optional.empty(); + } + try { + String[] tokenParts = token.split("\\.", -1); + if (tokenParts.length != 2) { + return Optional.empty(); + } + byte[] payloadBytes = DECODER.decode(tokenParts[0]); + byte[] suppliedSignature = DECODER.decode(tokenParts[1]); + if (!tokenParts[0].equals(ENCODER.encodeToString(payloadBytes)) + || !tokenParts[1].equals(ENCODER.encodeToString(suppliedSignature))) { + return Optional.empty(); + } + if (!MessageDigest.isEqual(sign(payloadBytes), suppliedSignature)) { + return Optional.empty(); + } + String[] claims = new String(payloadBytes, StandardCharsets.UTF_8).split("\\|", -1); + if (claims.length != 9 || !("2".equals(claims[0]) || "3".equals(claims[0]))) { + return Optional.empty(); + } + Claims parsed = new Claims(Long.valueOf(claims[1]), Long.parseLong(claims[2]), + UUID.fromString(claims[3]), new String(DECODER.decode(claims[4]), StandardCharsets.UTF_8), + Instant.ofEpochSecond(Long.parseLong(claims[5])), Instant.ofEpochSecond(Long.parseLong(claims[6])), + Long.valueOf(claims[7]), "3".equals(claims[0]), claims[8]); + Instant now = clock.instant(); + if (parsed.adminId() < 1 || (parsed.preview() + ? parsed.restaurantId() != 0 || parsed.addressRevision() != 0 + || !parsed.requestId().equals(new UUID(0, 0)) || parsed.fingerprint().length() != 43 + : parsed.restaurantId() < 1 || parsed.addressRevision() < 1 || !parsed.fingerprint().isEmpty()) + || parsed.issuedAt().isAfter(now) || !now.isBefore(parsed.expiresAt()) + || !parsed.expiresAt().equals(parsed.issuedAt().plus(properties.tokenTtl())) + || parsed.placeId().isBlank() || !parsed.placeId().equals(parsed.placeId().trim()) + || parsed.placeId().length() > 255) { + return Optional.empty(); + } + return Optional.of(parsed); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + private byte[] sign(byte[] payload) { + try { + Mac mac = Mac.getInstance(HMAC); + mac.init(new SecretKeySpec(properties.signingKeyBytes(), HMAC)); + return mac.doFinal(payload); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Places selection signing is unavailable", exception); + } + } + + public record Issued(String token, Instant expiresAt) { + } + + public record Claims(Long restaurantId, long addressRevision, UUID requestId, String placeId, + Instant issuedAt, Instant expiresAt, Long adminId, boolean preview, String fingerprint) { + @Override + public String toString() { + return "PlacesSelectionClaims[restaurantId=" + restaurantId + ",addressRevision=" + + addressRevision + "]"; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/RedisMapSessionStore.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/RedisMapSessionStore.java new file mode 100644 index 00000000..342cf967 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/RedisMapSessionStore.java @@ -0,0 +1,199 @@ +package org.sopt.hashi.restaurant.internal.map; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics.Reason; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.beans.BeansException; +import org.springframework.core.io.ClassPathResource; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +/** UUID별 payload와 TTL. ledger는 용량 회수만 담당하며 정렬이나 페이지 위치를 보관하지 않는다. */ +@Component +public class RedisMapSessionStore { + private static final String PREFIX = "hashi:restaurant:map:{sessions-v2}:"; + private static final String SHARED_PREFIX = "hashi:restaurant:map:{sessions-shared-v3}:"; + private static final DefaultRedisScript CREATE = script("create"); + private static final DefaultRedisScript TOUCH = script("touch"); + private static final DefaultRedisScript ADMIT = script("admit"); + private final MapRedisConnection redis; + private final MapSessionSerializer serializer; + private final MapSessionLimits limits; + private final MapCapacityMetrics metrics; + + public RedisMapSessionStore(MapRedisConnection redis, MapSessionSerializer serializer, + MapSessionLimits limits, MapCapacityMetrics metrics) { + this.redis = redis; + this.serializer = serializer; + this.limits = limits; + this.metrics = metrics; + } + + /** DB 조회와 JSON 생성 전에 호출한다. 원문 IP 대신 서명키로 HMAC한 caller만 Redis에 남긴다. */ + public Instant admit(String caller, boolean newQuery) { + limits.validate(); + long result = execute(ADMIT, List.of(prefix() + "requests", ledger()), + Long.toString(limits.getRedisMemoryCeiling()), Long.toString(limits.getRedisHeadroom()), caller, + Integer.toString(limits.getCallersPerMinute()), Integer.toString(limits.getRequestsPerMinute()), + Integer.toString(limits.getRequestsPerCaller()), Integer.toString(limits.getNewQueriesPerCaller()), + newQuery ? "1" : "0", sharedArgument()); + if (result == -3) { + metrics.rejected(Reason.CALLER_RATE); + throw new BusinessException(RestaurantErrorCode.MAP_RATE_LIMITED); + } + checkAdmission(result); + return Instant.ofEpochMilli(result); + } + + public MapSessionId save(MapQuerySession session) { + limits.validate(); + String payload; + try { + payload = serializer.serialize(session); + } catch (BusinessException exception) { + if (exception.getErrorCode() == RestaurantErrorCode.MAP_CAPACITY_EXCEEDED) { + metrics.rejected(Reason.SNAPSHOT_BYTES); + } + throw exception; + } + var id = new MapSessionId(session.id()); + long result = execute(CREATE, List.of(key(id), ledger(), prefix() + "requests"), + Long.toString(limits.getRedisMemoryCeiling()), Long.toString(limits.getRedisHeadroom()), payload, + Long.toString(session.expiresAt().toEpochMilli()), Long.toString(limits.getIdleTimeout().toMillis()), + Integer.toString(limits.getSnapshotBytes()), Long.toString(limits.getTotalBytes()), + Integer.toString(limits.getSessions()), id.value(), Long.toString(limits.getMaxLifetime().toMillis()), + sharedArgument()); + checkCreation(result); + return id; + } + + public MapQuerySession find(MapSessionId id) { + String payload; + try { + payload = redis.template().opsForValue().get(key(id)); + } catch (DataAccessException | BeansException exception) { + throw unavailable(); + } + MapQuerySession session = serializer.deserialize(payload); + if (!session.id().equals(id.id())) { + throw expired(); + } + return session; + } + + /** 읽기/커서 검증/카드 구성이 성공한 뒤에만 호출한다. GET 뒤 만료된 키를 다시 생성하지 않는다. */ + public Instant touch(MapSessionId id, MapQuerySession session) { + limits.validate(); + long expiry = execute(TOUCH, List.of(key(id), ledger(), prefix() + "requests"), id.value(), + Long.toString(limits.getIdleTimeout().toMillis()), Long.toString(session.expiresAt().toEpochMilli()), + sharedArgument()); + if (expiry == -5) { + metrics.rejected(Reason.REDIS_MEMORY_GUARD); + throw unavailable(); + } + if (expiry < 0) { + throw expired(); + } + return Instant.ofEpochMilli(expiry); + } + + private long execute(DefaultRedisScript script, List keys, String... args) { + try { + Long result = redis.template().execute(script, keys, (Object[]) args); + if (result == null) { + throw unavailable(); + } + if (result == -6) { + metrics.rejected(Reason.REDIS_RECOVERY); + throw unavailable(); + } + if (result == -7) { + metrics.rejected(Reason.REDIS_ACCOUNTING); + throw unavailable(); + } + return result; + } catch (DataAccessException | BeansException exception) { + throw unavailable(); + } + } + + private void checkAdmission(long result) { + if (result == -1) { + reject(Reason.CALLER_CARDINALITY); + } + if (result == -4) { + reject(Reason.GLOBAL_REQUESTS); + } + if (result == -5) { + metrics.rejected(Reason.REDIS_MEMORY_GUARD); + throw unavailable(); + } + if (result < 0) { + throw unavailable(); + } + } + + private void checkCreation(long result) { + if (result == -1) { + reject(Reason.SNAPSHOT_BYTES); + } + if (result == -3) { + reject(Reason.TOTAL_BYTES); + } + if (result == -4) { + reject(Reason.SESSION_COUNT); + } + if (result == -5) { + metrics.rejected(Reason.REDIS_MEMORY_GUARD); + throw unavailable(); + } + if (result < 0) { + throw unavailable(); + } + } + + private void reject(Reason reason) { + metrics.rejected(reason); + throw new BusinessException(RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + } + + private String key(MapSessionId id) { + return prefix() + "query:" + id.value(); + } + + private String ledger() { + return prefix() + "admission"; + } + + private String prefix() { + return redis.shared() ? SHARED_PREFIX : PREFIX; + } + + private String sharedArgument() { + return redis.shared() ? "1" : "0"; + } + + private static DefaultRedisScript script(String name) { + try { + String shared = new ClassPathResource("map/shared.lua").getContentAsString(StandardCharsets.UTF_8); + String operation = new ClassPathResource("map/" + name + ".lua") + .getContentAsString(StandardCharsets.UTF_8); + return new DefaultRedisScript<>(shared + "\n" + operation, Long.class); + } catch (IOException exception) { + throw new IllegalStateException("Map Redis scripts are missing", exception); + } + } + + private static BusinessException unavailable() { + return new BusinessException(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + + private static BusinessException expired() { + return new BusinessException(RestaurantErrorCode.MAP_SESSION_EXPIRED); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/RestaurantLocationWorker.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/RestaurantLocationWorker.java new file mode 100644 index 00000000..a04cefe0 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/RestaurantLocationWorker.java @@ -0,0 +1,125 @@ +package org.sopt.hashi.restaurant.internal.map; + +import lombok.extern.slf4j.Slf4j; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Candidates; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.sopt.hashi.restaurant.service.LocationAdoptionPolicy; +import org.sopt.hashi.restaurant.service.LocationJobTransactions; +import org.sopt.hashi.restaurant.service.LocationJobTransactions.Outcome; +import org.sopt.hashi.restaurant.service.LocationJobTransactions.PlacesOutcome; +import org.sopt.hashi.restaurant.service.LocationJobTransactions.Target; +import org.sopt.hashi.restaurant.service.PlacesCandidatePolicy; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob.Operation; +import org.sopt.hashi.restaurant.internal.map.places.PlaceDetailsResult; +import org.sopt.hashi.restaurant.internal.map.places.PlacesProvider; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** HTTP 대기 구간에는 transaction이 없다. DB 단계는 별도 proxy Bean을 호출한다. */ +@Component +@Slf4j +public class RestaurantLocationWorker { + private final LocationJobTransactions transactions; + private final GeocodingProvider provider; + private final LocationAdoptionPolicy adoption; + private final PlacesProvider placesProvider; + private final PlacesCandidatePolicy placesPolicy; + + public RestaurantLocationWorker(LocationJobTransactions transactions, GeocodingProvider provider, + LocationAdoptionPolicy adoption, PlacesProvider placesProvider, + PlacesCandidatePolicy placesPolicy) { + this.transactions = transactions; + this.provider = provider; + this.adoption = adoption; + this.placesProvider = placesProvider; + this.placesPolicy = placesPolicy; + } + + public void runOnce() { + requireNoTransaction(); + for (Target target : transactions.candidates()) { + if (Thread.currentThread().isInterrupted()) { + return; + } + process(target); + } + } + + public void process(Target target) { + requireNoTransaction(); + var claimed = transactions.claim(target); + if (claimed.isEmpty()) { + return; + } + var claim = claimed.orElseThrow(); + if (claim.operation() == Operation.PLACE_DETAILS) { + processDetails(claim); + return; + } + processGeocoding(claim); + } + + private void processGeocoding(LocationJobTransactions.Claim claim) { + GeocodingResult result; + try { + result = provider.geocode(claim.geocodingAddress()); + } catch (RuntimeException exception) { + // No provider message/cause is logged or retained. Unknown transmission is not refunded. + log.warn("Location worker failure operation=location-provider-call exceptionType={}", + exception.getClass().getName()); + result = new Failure(FailureKind.TRANSIENT_ERROR, null); + } + Outcome outcome = switch (result) { + case Candidates candidates -> { + var decision = adoption.evaluate(claim.geocodingAddress(), candidates); + yield new Outcome(decision.coordinates(), null, decision.failureCode()); + } + case GeocodingResult.NoResults ignored -> new Outcome(null, null, "NO_RESULTS"); + case Failure failure -> Outcome.failure(failure.kind()); + }; + // An interrupted executor can stop before a DB write; its durable lease remains recoverable. + if (!Thread.currentThread().isInterrupted()) { + transactions.complete(claim, outcome); + } + } + + private void processDetails(LocationJobTransactions.Claim claim) { + PlaceDetailsResult result; + try { + result = placesProvider.details(claim.googlePlaceId()); + } catch (RuntimeException exception) { + log.warn("Location worker failure operation=places-details exceptionType={}", + exception.getClass().getName()); + result = new PlaceDetailsResult.Failure(FailureKind.TRANSIENT_ERROR, null); + } + if (result == null) { + result = new PlaceDetailsResult.Failure(FailureKind.INVALID_RESPONSE, null); + } + PlacesOutcome outcome = switch (result) { + case PlaceDetailsResult.Place place -> { + var candidate = place.candidate(); + if (!claim.googlePlaceId().equals(candidate.placeId())) { + yield PlacesOutcome.failure(FailureKind.INVALID_RESPONSE); + } + if (!placesPolicy.accepts(candidate)) { + yield new PlacesOutcome(null, null, null, null, "PLACE_CONTEXT_MISMATCH"); + } + yield new PlacesOutcome(placesPolicy.coordinates(candidate), candidate.placeId(), + placesPolicy.attributions(candidate), null, null); + } + case PlaceDetailsResult.NoResults ignored -> + new PlacesOutcome(null, null, null, null, "NO_RESULTS"); + case PlaceDetailsResult.Failure failure -> PlacesOutcome.failure(failure.kind()); + }; + if (!Thread.currentThread().isInterrupted()) { + transactions.completePlaces(claim, outcome); + } + } + + private static void requireNoTransaction() { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("Location worker must run outside a transaction"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingConfig.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingConfig.java new file mode 100644 index 00000000..ed8f635d --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingConfig.java @@ -0,0 +1,22 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import org.sopt.hashi.restaurant.internal.map.GeocodingProvider; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(GoogleGeocodingProperties.class) +public class GoogleGeocodingConfig { + + @Bean + public GeocodingProvider geocodingProvider(GoogleGeocodingProperties properties) { + if (!properties.enabled()) { + return address -> new Failure(FailureKind.DISABLED, null); + } + properties.validateEnabled(); + return new GoogleGeocodingProvider(properties); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProperties.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProperties.java new file mode 100644 index 00000000..b95b8782 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProperties.java @@ -0,0 +1,56 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import java.time.Duration; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "hashi.map.google-geocoding") +public record GoogleGeocodingProperties( + boolean enabled, + String apiKey, + Duration connectTimeout, + Duration responseTimeout, + Integer maxResponseBytes +) { + + public GoogleGeocodingProperties { + connectTimeout = connectTimeout == null ? Duration.ofSeconds(2) : connectTimeout; + responseTimeout = responseTimeout == null ? Duration.ofSeconds(5) : responseTimeout; + maxResponseBytes = maxResponseBytes == null ? 65_536 : maxResponseBytes; + } + + void validateEnabled() { + if (apiKey == null || !apiKey.matches("[A-Za-z0-9_-]{1,256}")) { + throw new IllegalStateException("Google geocoding requires a valid server API key"); + } + validateTimeout(connectTimeout, Duration.ofSeconds(10), "connect timeout"); + validateTimeout(responseTimeout, Duration.ofSeconds(30), "response timeout"); + if (responseTimeout.compareTo(connectTimeout) < 0) { + throw new IllegalStateException("Google geocoding response timeout must cover connect timeout"); + } + if (maxResponseBytes < 1024 || maxResponseBytes > 1_048_576) { + throw new IllegalStateException("Google geocoding response limit must be between 1024 and 1048576 bytes"); + } + // Reject overrides of the dedicated transport's OFF logging policy before sending a secret. + for (String logger : new String[]{"org.apache.hc.client5.http.wire", "org.apache.hc.client5.http.headers", + "org.apache.hc.client5.http.impl.classic.InternalHttpClient", + "org.apache.hc.client5.http.impl.classic.MainClientExec", + "org.apache.hc.client5.http.impl.classic.ProtocolExec"}) { + if (LoggerFactory.getLogger(logger).isDebugEnabled()) { + throw new IllegalStateException("Google geocoding requires HTTP wire logging to be disabled"); + } + } + } + + private static void validateTimeout(Duration value, Duration maximum, String name) { + boolean isOutOfBounds = value.compareTo(Duration.ofMillis(1)) < 0 || value.compareTo(maximum) > 0; + if (isOutOfBounds) { + throw new IllegalStateException("Google geocoding " + name + " is outside the allowed range"); + } + } + + @Override + public String toString() { + return "GoogleGeocodingProperties[redacted]"; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProvider.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProvider.java new file mode 100644 index 00000000..3c79f92b --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProvider.java @@ -0,0 +1,283 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.Timeout; +import org.sopt.hashi.restaurant.internal.map.GeocodingProvider; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +final class GoogleGeocodingProvider implements GeocodingProvider { + + static final String ENDPOINT = "https://geocode.googleapis.com/v4/geocode/address"; + static final String FIELD_MASK = "results.location,results.granularity,results.postalAddress.regionCode," + + "results.postalAddress.administrativeArea,results.addressComponents.longText," + + "results.addressComponents.shortText,results.addressComponents.types,results.types"; + private static final int MAX_ADDRESS_LENGTH = 255; + private static final int MAX_ERROR_STATUS_BYTES = 8192; + static final int MAX_CONCURRENT_CALLS = 4; + + private final GoogleGeocodingProperties properties; + private final GoogleGeocodingResponseParser parser = new GoogleGeocodingResponseParser(); + private final UnaryOperator requestFactoryDecorator; + private final DnsResolver dnsResolver; + private final Semaphore callSlots = new Semaphore(MAX_CONCURRENT_CALLS); + + GoogleGeocodingProvider(GoogleGeocodingProperties properties) { + this(properties, UnaryOperator.identity()); + } + + // Test seam redirects transport to loopback/mocks; the production endpoint has no configurable override. + GoogleGeocodingProvider(GoogleGeocodingProperties properties, + UnaryOperator requestFactoryDecorator) { + this(properties, requestFactoryDecorator, SystemDefaultDnsResolver.INSTANCE); + } + + GoogleGeocodingProvider(GoogleGeocodingProperties properties, + UnaryOperator requestFactoryDecorator, + DnsResolver dnsResolver) { + properties.validateEnabled(); + this.properties = properties; + this.requestFactoryDecorator = requestFactoryDecorator; + this.dnsResolver = dnsResolver; + } + + @Override + public GeocodingResult geocode(String address) { + if (!properties.enabled()) { + return new Failure(FailureKind.DISABLED, null); + } + boolean isInvalid = address == null || address.isBlank() || address.length() > MAX_ADDRESS_LENGTH; + if (isInvalid) { + return new Failure(FailureKind.INVALID_REQUEST, null); + } + CallCancellation cancellation = new CallCancellation(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + Future future = executor.submit(() -> { + if (!callSlots.tryAcquire()) { + return new Failure(FailureKind.CAPACITY_EXCEEDED, null); + } + try { + return execute(address, cancellation); + } finally { + // DNS may ignore interruption. Keep its slot until the underlying task actually finishes. + callSlots.release(); + } + }); + try { + return future.get(properties.responseTimeout().toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException ignored) { + return new Failure(FailureKind.TIMEOUT, null); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return new Failure(FailureKind.CANCELLED, null); + } catch (ExecutionException failure) { + // Log only the immediate failure type; exceptions can contain the URI, key or body. + Throwable cause = failure.getCause(); + log.warn("Google geocoding failure phase=await exceptionType={}", + (cause == null ? failure : cause).getClass().getName()); + return new Failure(FailureKind.INVALID_RESPONSE, null); + } finally { + cancellation.cancel(); + future.cancel(true); + // ExecutorService.close() would wait for a DNS resolver that ignores interruption. + executor.shutdownNow(); + } + } + + private GeocodingResult execute(String address, CallCancellation cancellation) { + CloseableHttpClient client = null; + try { + client = createHttpClient(); + cancellation.register(client); + CloseableHttpClient callClient = client; + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(client) { + @Override + protected ClassicHttpRequest createHttpUriRequest(HttpMethod method, URI uri) { + HttpUriRequestBase request = new HttpUriRequestBase(method.name(), uri); + cancellation.register(request); + return request; + } + }; + RestClient restClient = RestClient.builder() + .requestFactory(requestFactoryDecorator.apply(factory)).build(); + URI uri = UriComponentsBuilder.fromUriString(ENDPOINT) + .queryParam("address.addressLines", "{address}") + .queryParam("address.regionCode", "JP") + .queryParam("languageCode", "en").queryParam("regionCode", "JP") + .encode().buildAndExpand(address).toUri(); + return restClient.get().uri(uri).accept(MediaType.APPLICATION_JSON) + .header("X-Goog-Api-Key", properties.apiKey()) + .header("X-Goog-FieldMask", FIELD_MASK) + .exchange((request, response) -> { + try { + return readResponse(response); + } finally { + // Spring's Apache response close drains unread bytes. Abort first so oversized/error + // bodies are not downloaded during cleanup. This client belongs to only this call. + callClient.close(CloseMode.IMMEDIATE); + response.close(); + } + }, false); + } catch (ResourceAccessException failure) { + return new Failure(isTimeout(failure) + ? FailureKind.TIMEOUT : FailureKind.CONNECTION_ERROR, null); + } catch (RuntimeException failure) { + // Never pass the exception itself, its message or cause chain to the logger. + log.warn("Google geocoding failure phase=execute exceptionType={}", failure.getClass().getName()); + return new Failure(FailureKind.INVALID_RESPONSE, null); + } finally { + if (client != null) { + client.close(CloseMode.IMMEDIATE); + } + } + } + + private CloseableHttpClient createHttpClient() { + ConnectionConfig connection = ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(properties.connectTimeout().toMillis())) + .setSocketTimeout(Timeout.ofMilliseconds(properties.responseTimeout().toMillis())).build(); + RequestConfig request = RequestConfig.custom().setAuthenticationEnabled(false) + .setConnectionRequestTimeout(Timeout.ofMilliseconds(properties.connectTimeout().toMillis())) + .setResponseTimeout(Timeout.ofMilliseconds(properties.responseTimeout().toMillis())).build(); + // No system proxy/credentials, cookies, transparent compression, redirects or transport retries. + return HttpClients.custom().disableAutomaticRetries().disableRedirectHandling() + .disableCookieManagement().disableAuthCaching().disableContentCompression() + .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setDnsResolver(dnsResolver).setDefaultConnectionConfig(connection).build()) + .setDefaultRequestConfig(request).build(); + } + + private GeocodingResult readResponse(ClientHttpResponse response) throws IOException { + int status = response.getStatusCode().value(); + if (status != 200) { + if (status == 403) { + return new Failure(classifyForbidden(response), status); + } + return new Failure(classifyStatus(status), status); + } + MediaType contentType = response.getHeaders().getContentType(); + String encoding = response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); + boolean isJson = contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType); + boolean isEncoded = encoding != null && !encoding.equalsIgnoreCase("identity"); + if (!isJson || isEncoded) { + return new Failure(FailureKind.INVALID_RESPONSE, status); + } + if (response.getHeaders().getContentLength() > properties.maxResponseBytes()) { + return new Failure(FailureKind.RESPONSE_TOO_LARGE, status); + } + byte[] body = response.getBody().readNBytes(properties.maxResponseBytes() + 1); + if (body.length > properties.maxResponseBytes()) { + return new Failure(FailureKind.RESPONSE_TOO_LARGE, status); + } + return parser.parse(body); + } + + private FailureKind classifyForbidden(ClientHttpResponse response) { + MediaType contentType = response.getHeaders().getContentType(); + String encoding = response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType) + || (encoding != null && !encoding.equalsIgnoreCase("identity")) + || response.getHeaders().getContentLength() > MAX_ERROR_STATUS_BYTES) { + return FailureKind.ACCESS_DENIED; + } + try { + byte[] body = response.getBody().readNBytes(MAX_ERROR_STATUS_BYTES + 1); + return body.length > MAX_ERROR_STATUS_BYTES ? FailureKind.ACCESS_DENIED : parser.classifyForbidden(body); + } catch (IOException ignored) { + return FailureKind.ACCESS_DENIED; + } + } + + private FailureKind classifyStatus(int status) { + if (status >= 300 && status < 400) { + return FailureKind.REDIRECT_REJECTED; + } + if (status >= 500 && status <= 599) { + return FailureKind.TRANSIENT_ERROR; + } + return switch (status) { + case 400, 422 -> FailureKind.INVALID_REQUEST; + case 401, 403 -> FailureKind.ACCESS_DENIED; + case 404 -> FailureKind.CONFIGURATION_ERROR; + case 408 -> FailureKind.TIMEOUT; + case 429 -> FailureKind.QUOTA_EXCEEDED; + default -> FailureKind.INVALID_RESPONSE; + }; + } + + private boolean isTimeout(Throwable failure) { + Throwable cause = failure; + for (int depth = 0; cause != null && depth < 10; depth++, cause = cause.getCause()) { + if (cause instanceof SocketTimeoutException) { + return true; + } + } + return false; + } + + private static final class CallCancellation { + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final AtomicReference client = new AtomicReference<>(); + private final AtomicReference request = new AtomicReference<>(); + + void register(CloseableHttpClient value) { + client.set(value); + if (cancelled.get()) { + value.close(CloseMode.IMMEDIATE); + } + } + + void register(HttpUriRequestBase value) { + request.set(value); + if (cancelled.get()) { + value.cancel(); + } + } + + void cancel() { + cancelled.set(true); + HttpUriRequestBase currentRequest = request.get(); + if (currentRequest != null) { + currentRequest.cancel(); + } + CloseableHttpClient currentClient = client.get(); + if (currentClient != null) { + currentClient.close(CloseMode.IMMEDIATE); + } + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingResponseParser.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingResponseParser.java new file mode 100644 index 00000000..dd872dc1 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingResponseParser.java @@ -0,0 +1,140 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.AddressComponent; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.Granularity; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Candidates; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.NoResults; + +final class GoogleGeocodingResponseParser { + + private final ObjectMapper mapper = JsonMapper.builder(JsonFactory.builder() + .disable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION) + .streamReadConstraints(StreamReadConstraints.builder() + .maxNestingDepth(20).maxNumberLength(64).maxStringLength(8192).build()) + .build()) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY) + .build(); + + GeocodingResult parse(byte[] body) { + try { + JsonNode root = mapper.readTree(body); + require(root != null && root.isObject()); + require(!root.has("error") && !root.has("status") && !root.has("error_message")); + JsonNode results = root.get("results"); + // ProtoJSON omits an empty repeated field. A valid {} is distinct from an empty/invalid body. + if (results == null) { + return new NoResults(); + } + require(results.isArray()); + if (results.isEmpty()) { + return new NoResults(); + } + List candidates = new ArrayList<>(); + for (JsonNode result : results) { + candidates.add(parseCandidate(result)); + } + return new Candidates(candidates); + } catch (IOException | RuntimeException ignored) { + // Parser exceptions may contain response fragments. Never propagate or log the cause. + return new Failure(FailureKind.INVALID_RESPONSE, 200); + } + } + + /** 403은 권한 거부와 quota 초과가 겹친다. 원문 메시지와 details는 결과로 내보내지 않는다. */ + GeocodingResult.FailureKind classifyForbidden(byte[] body) { + try { + JsonNode root = mapper.readTree(body); + JsonNode status = root == null ? null : root.path("error").path("status"); + if (status == null || !status.isTextual()) { + return GeocodingResult.FailureKind.ACCESS_DENIED; + } + return switch (status.textValue()) { + case "RESOURCE_EXHAUSTED" -> + GeocodingResult.FailureKind.QUOTA_EXCEEDED; + default -> GeocodingResult.FailureKind.ACCESS_DENIED; + }; + } catch (IOException | RuntimeException ignored) { + return GeocodingResult.FailureKind.ACCESS_DENIED; + } + } + + private GeocodingCandidate parseCandidate(JsonNode result) { + require(result.isObject()); + JsonNode location = result.path("location"); + require(location.isObject()); + BigDecimal latitude = coordinate(location.get("latitude"), 90); + BigDecimal longitude = coordinate(location.get("longitude"), 180); + JsonNode postal = result.path("postalAddress"); + require(postal.isMissingNode() || postal.isObject()); + List components = new ArrayList<>(); + JsonNode componentNodes = result.path("addressComponents"); + require(componentNodes.isMissingNode() || componentNodes.isArray()); + for (JsonNode component : componentNodes) { + require(component.isObject()); + components.add(new AddressComponent(text(component, "longText"), text(component, "shortText"), + strings(component.path("types")))); + } + return new GeocodingCandidate(latitude, longitude, granularity(text(result, "granularity")), + text(postal, "regionCode"), text(postal, "administrativeArea"), components, + strings(result.path("types"))); + } + + private BigDecimal coordinate(JsonNode node, int limit) { + require(node != null && node.isNumber()); + BigDecimal value = node.decimalValue(); + require(value.abs().compareTo(BigDecimal.valueOf(limit)) <= 0); + return value; + } + + private String text(JsonNode object, String name) { + JsonNode value = object.path(name); + if (value.isMissingNode()) { + return ""; + } + require(value.isTextual()); + return value.textValue(); + } + + private List strings(JsonNode values) { + require(values.isMissingNode() || values.isArray()); + List result = new ArrayList<>(); + for (JsonNode value : values) { + require(value.isTextual()); + result.add(value.textValue()); + } + return List.copyOf(result); + } + + private Granularity granularity(String value) { + return switch (value) { + case "ROOFTOP" -> Granularity.ROOFTOP; + case "RANGE_INTERPOLATED" -> Granularity.RANGE_INTERPOLATED; + case "GEOMETRIC_CENTER" -> Granularity.GEOMETRIC_CENTER; + case "APPROXIMATE" -> Granularity.APPROXIMATE; + default -> Granularity.UNKNOWN; + }; + } + + private void require(boolean valid) { + if (!valid) { + throw new IllegalArgumentException("Invalid geocoding response"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesConfig.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesConfig.java new file mode 100644 index 00000000..70d0c0ed --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesConfig.java @@ -0,0 +1,30 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(GooglePlacesProperties.class) +public class GooglePlacesConfig { + + @Bean + public PlacesProvider placesProvider(GooglePlacesProperties properties) { + if (!properties.enabled()) { + return new PlacesProvider() { + @Override + public PlacesSearchResult search(String query) { + return new PlacesSearchResult.Failure(FailureKind.DISABLED, null); + } + + @Override + public PlaceDetailsResult details(String placeId) { + return new PlaceDetailsResult.Failure(FailureKind.DISABLED, null); + } + }; + } + properties.validateEnabled(); + return new GooglePlacesProvider(properties); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProperties.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProperties.java new file mode 100644 index 00000000..aa057ef9 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProperties.java @@ -0,0 +1,55 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import java.time.Duration; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "hashi.map.google-places") +public record GooglePlacesProperties( + boolean enabled, + String apiKey, + Duration connectTimeout, + Duration responseTimeout, + Integer maxResponseBytes +) { + + public GooglePlacesProperties { + connectTimeout = connectTimeout == null ? Duration.ofSeconds(2) : connectTimeout; + responseTimeout = responseTimeout == null ? Duration.ofSeconds(5) : responseTimeout; + maxResponseBytes = maxResponseBytes == null ? 65_536 : maxResponseBytes; + } + + void validateEnabled() { + if (apiKey == null || !apiKey.matches("[A-Za-z0-9_-]{1,256}")) { + throw new IllegalStateException("Google Places requires a valid server API key"); + } + validateTimeout(connectTimeout, Duration.ofSeconds(10), "connect timeout"); + validateTimeout(responseTimeout, Duration.ofSeconds(30), "response timeout"); + if (responseTimeout.compareTo(connectTimeout) < 0) { + throw new IllegalStateException("Google Places response timeout must cover connect timeout"); + } + if (maxResponseBytes < 1024 || maxResponseBytes > 1_048_576) { + throw new IllegalStateException("Google Places response limit must be between 1024 and 1048576 bytes"); + } + for (String logger : new String[]{"org.apache.hc.client5.http.wire", "org.apache.hc.client5.http.headers", + "org.apache.hc.client5.http.impl.classic.InternalHttpClient", + "org.apache.hc.client5.http.impl.classic.MainClientExec", + "org.apache.hc.client5.http.impl.classic.ProtocolExec"}) { + if (LoggerFactory.getLogger(logger).isDebugEnabled()) { + throw new IllegalStateException("Google Places requires HTTP wire logging to be disabled"); + } + } + } + + private static void validateTimeout(Duration value, Duration maximum, String name) { + boolean isOutOfBounds = value.compareTo(Duration.ofMillis(1)) < 0 || value.compareTo(maximum) > 0; + if (isOutOfBounds) { + throw new IllegalStateException("Google Places " + name + " is outside the allowed range"); + } + } + + @Override + public String toString() { + return "GooglePlacesProperties[redacted]"; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProvider.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProvider.java new file mode 100644 index 00000000..04312db1 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProvider.java @@ -0,0 +1,337 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.Timeout; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +final class GooglePlacesProvider implements PlacesProvider { + + static final String SEARCH_ENDPOINT = "https://places.googleapis.com/v1/places:searchText"; + static final String DETAILS_ENDPOINT = "https://places.googleapis.com/v1/places"; + static final String SEARCH_FIELD_MASK = "places.id,places.displayName,places.formattedAddress,places.location," + + "places.addressComponents,places.types,places.businessStatus,places.attributions,places.googleMapsUri"; + static final String DETAILS_FIELD_MASK = "id,displayName,formattedAddress,location,addressComponents,types," + + "businessStatus,attributions,googleMapsUri"; + static final int MAX_CONCURRENT_CALLS = 4; + + private static final int MAX_PLACE_ID_LENGTH = 255; + private static final int MAX_ERROR_STATUS_BYTES = 8192; + private static final Pattern PLACE_ID = Pattern.compile("[A-Za-z0-9._~-]{1," + MAX_PLACE_ID_LENGTH + "}"); + private static final ObjectMapper REQUEST_MAPPER = JsonMapper.builder().build(); + + private final GooglePlacesProperties properties; + private final GooglePlacesResponseParser parser = new GooglePlacesResponseParser(); + private final UnaryOperator requestFactoryDecorator; + private final DnsResolver dnsResolver; + private final Semaphore callSlots = new Semaphore(MAX_CONCURRENT_CALLS); + + GooglePlacesProvider(GooglePlacesProperties properties) { + this(properties, UnaryOperator.identity()); + } + + // Test seam redirects transport to loopback/mocks; production endpoints cannot be overridden by configuration. + GooglePlacesProvider(GooglePlacesProperties properties, + UnaryOperator requestFactoryDecorator) { + this(properties, requestFactoryDecorator, SystemDefaultDnsResolver.INSTANCE); + } + + GooglePlacesProvider(GooglePlacesProperties properties, + UnaryOperator requestFactoryDecorator, + DnsResolver dnsResolver) { + properties.validateEnabled(); + this.properties = properties; + this.requestFactoryDecorator = requestFactoryDecorator; + this.dnsResolver = dnsResolver; + } + + @Override + public PlacesSearchResult search(String query) { + if (!properties.enabled()) { + return searchFailure(FailureKind.DISABLED, null); + } + String normalized = query == null ? "" : query.strip(); + if (normalized.isEmpty() || normalized.codePointCount(0, normalized.length()) > MAX_SEARCH_QUERY_LENGTH) { + return searchFailure(FailureKind.INVALID_REQUEST, null); + } + byte[] body; + try { + body = REQUEST_MAPPER.writeValueAsBytes(new SearchRequest(normalized, "en", "JP", 5, false)); + } catch (JsonProcessingException ignored) { + return searchFailure(FailureKind.INVALID_RESPONSE, null); + } + return invoke(HttpMethod.POST, URI.create(SEARCH_ENDPOINT), body, SEARCH_FIELD_MASK, + parser::parseSearch, GooglePlacesProvider::searchFailure, null); + } + + @Override + public PlaceDetailsResult details(String placeId) { + if (!properties.enabled()) { + return detailsFailure(FailureKind.DISABLED, null); + } + if (placeId == null || !PLACE_ID.matcher(placeId).matches()) { + return detailsFailure(FailureKind.INVALID_REQUEST, null); + } + URI uri = UriComponentsBuilder.fromUriString(DETAILS_ENDPOINT) + .pathSegment(placeId).build().encode().toUri(); + return invoke(HttpMethod.GET, uri, null, DETAILS_FIELD_MASK, parser::parseDetails, + GooglePlacesProvider::detailsFailure, PlaceDetailsResult.NoResults::new); + } + + private T invoke(HttpMethod method, URI uri, byte[] body, String fieldMask, + Function successParser, BiFunction failureFactory, + Supplier notFoundFactory) { + CallCancellation cancellation = new CallCancellation(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + Future future = executor.submit(() -> { + if (!callSlots.tryAcquire()) { + return failureFactory.apply(FailureKind.CAPACITY_EXCEEDED, null); + } + try { + return execute(method, uri, body, fieldMask, successParser, failureFactory, + notFoundFactory, cancellation); + } finally { + // A resolver may ignore interruption. Keep its slot until the underlying call actually exits. + callSlots.release(); + } + }); + try { + return future.get(properties.responseTimeout().toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException ignored) { + return failureFactory.apply(FailureKind.TIMEOUT, null); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return failureFactory.apply(FailureKind.CANCELLED, null); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + log.warn("Google Places failure phase=await exceptionType={}", + (cause == null ? failure : cause).getClass().getName()); + return failureFactory.apply(FailureKind.INVALID_RESPONSE, null); + } finally { + cancellation.cancel(); + future.cancel(true); + executor.shutdownNow(); + } + } + + private T execute(HttpMethod method, URI uri, byte[] body, String fieldMask, + Function successParser, BiFunction failureFactory, + Supplier notFoundFactory, CallCancellation cancellation) { + CloseableHttpClient client = null; + try { + client = createHttpClient(); + cancellation.register(client); + CloseableHttpClient callClient = client; + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(client) { + @Override + protected ClassicHttpRequest createHttpUriRequest(HttpMethod requestMethod, URI requestUri) { + HttpUriRequestBase request = new HttpUriRequestBase(requestMethod.name(), requestUri); + cancellation.register(request); + return request; + } + }; + RestClient restClient = RestClient.builder() + .requestFactory(requestFactoryDecorator.apply(factory)).build(); + RestClient.RequestBodySpec request = restClient.method(method).uri(uri) + .accept(MediaType.APPLICATION_JSON) + .header("X-Goog-Api-Key", properties.apiKey()) + .header("X-Goog-FieldMask", fieldMask); + if (body != null) { + request.contentType(MediaType.APPLICATION_JSON).body(body); + } + return request.exchange((sent, response) -> { + try { + return readResponse(response, successParser, failureFactory, notFoundFactory); + } finally { + callClient.close(CloseMode.IMMEDIATE); + response.close(); + } + }, false); + } catch (ResourceAccessException failure) { + return failureFactory.apply(isTimeout(failure) + ? FailureKind.TIMEOUT : FailureKind.CONNECTION_ERROR, null); + } catch (RuntimeException failure) { + log.warn("Google Places failure phase=execute exceptionType={}", failure.getClass().getName()); + return failureFactory.apply(FailureKind.INVALID_RESPONSE, null); + } finally { + if (client != null) { + client.close(CloseMode.IMMEDIATE); + } + } + } + + private CloseableHttpClient createHttpClient() { + ConnectionConfig connection = ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(properties.connectTimeout().toMillis())) + .setSocketTimeout(Timeout.ofMilliseconds(properties.responseTimeout().toMillis())).build(); + RequestConfig request = RequestConfig.custom().setAuthenticationEnabled(false) + .setConnectionRequestTimeout(Timeout.ofMilliseconds(properties.connectTimeout().toMillis())) + .setResponseTimeout(Timeout.ofMilliseconds(properties.responseTimeout().toMillis())).build(); + return HttpClients.custom().disableAutomaticRetries().disableRedirectHandling() + .disableCookieManagement().disableAuthCaching().disableContentCompression() + .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setDnsResolver(dnsResolver).setDefaultConnectionConfig(connection).build()) + .setDefaultRequestConfig(request).build(); + } + + private T readResponse(ClientHttpResponse response, Function successParser, + BiFunction failureFactory, + Supplier notFoundFactory) throws IOException { + int status = response.getStatusCode().value(); + if (status != 200) { + if (status == 404 && notFoundFactory != null) { + return notFoundFactory.get(); + } + if (status == 403) { + return failureFactory.apply(classifyForbidden(response), status); + } + return failureFactory.apply(classifyStatus(status), status); + } + MediaType contentType = response.getHeaders().getContentType(); + String encoding = response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); + boolean isJson = contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType); + boolean isEncoded = encoding != null && !encoding.equalsIgnoreCase("identity"); + if (!isJson || isEncoded) { + return failureFactory.apply(FailureKind.INVALID_RESPONSE, status); + } + if (response.getHeaders().getContentLength() > properties.maxResponseBytes()) { + return failureFactory.apply(FailureKind.RESPONSE_TOO_LARGE, status); + } + byte[] responseBody = response.getBody().readNBytes(properties.maxResponseBytes() + 1); + if (responseBody.length > properties.maxResponseBytes()) { + return failureFactory.apply(FailureKind.RESPONSE_TOO_LARGE, status); + } + return successParser.apply(responseBody); + } + + private FailureKind classifyForbidden(ClientHttpResponse response) { + MediaType contentType = response.getHeaders().getContentType(); + String encoding = response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType) + || (encoding != null && !encoding.equalsIgnoreCase("identity")) + || response.getHeaders().getContentLength() > MAX_ERROR_STATUS_BYTES) { + return FailureKind.ACCESS_DENIED; + } + try { + byte[] body = response.getBody().readNBytes(MAX_ERROR_STATUS_BYTES + 1); + return body.length > MAX_ERROR_STATUS_BYTES ? FailureKind.ACCESS_DENIED : parser.classifyForbidden(body); + } catch (IOException ignored) { + return FailureKind.ACCESS_DENIED; + } + } + + private FailureKind classifyStatus(int status) { + if (status >= 300 && status < 400) { + return FailureKind.REDIRECT_REJECTED; + } + if (status >= 500 && status <= 599) { + return FailureKind.TRANSIENT_ERROR; + } + return switch (status) { + case 400, 422 -> FailureKind.INVALID_REQUEST; + case 401, 403 -> FailureKind.ACCESS_DENIED; + case 404 -> FailureKind.CONFIGURATION_ERROR; + case 408 -> FailureKind.TIMEOUT; + case 429 -> FailureKind.QUOTA_EXCEEDED; + default -> FailureKind.INVALID_RESPONSE; + }; + } + + private boolean isTimeout(Throwable failure) { + Throwable cause = failure; + for (int depth = 0; cause != null && depth < 10; depth++, cause = cause.getCause()) { + if (cause instanceof SocketTimeoutException) { + return true; + } + } + return false; + } + + private static PlacesSearchResult searchFailure(FailureKind kind, Integer status) { + return new PlacesSearchResult.Failure(kind, status); + } + + private static PlaceDetailsResult detailsFailure(FailureKind kind, Integer status) { + return new PlaceDetailsResult.Failure(kind, status); + } + + private record SearchRequest( + String textQuery, + String languageCode, + String regionCode, + int pageSize, + boolean includePureServiceAreaBusinesses + ) { + } + + private static final class CallCancellation { + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final AtomicReference client = new AtomicReference<>(); + private final AtomicReference request = new AtomicReference<>(); + + void register(CloseableHttpClient value) { + client.set(value); + if (cancelled.get()) { + value.close(CloseMode.IMMEDIATE); + } + } + + void register(HttpUriRequestBase value) { + request.set(value); + if (cancelled.get()) { + value.cancel(); + } + } + + void cancel() { + cancelled.set(true); + HttpUriRequestBase currentRequest = request.get(); + if (currentRequest != null) { + currentRequest.cancel(); + } + CloseableHttpClient currentClient = client.get(); + if (currentClient != null) { + currentClient.close(CloseMode.IMMEDIATE); + } + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesResponseParser.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesResponseParser.java new file mode 100644 index 00000000..f6c2cc4c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesResponseParser.java @@ -0,0 +1,178 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; + +final class GooglePlacesResponseParser { + + private static final int MAX_SEARCH_CANDIDATES = 5; + private static final int MAX_TYPES = 64; + private static final int MAX_ATTRIBUTIONS = 32; + private static final int MAX_SHORT_TEXT_LENGTH = 1024; + private static final int MAX_LONG_TEXT_LENGTH = 8192; + private static final int MAX_URI_LENGTH = 4096; + private static final Pattern PLACE_ID = Pattern.compile("[A-Za-z0-9._~-]{1,255}"); + + private final ObjectMapper mapper = JsonMapper.builder(JsonFactory.builder() + .disable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION) + .streamReadConstraints(StreamReadConstraints.builder() + .maxNestingDepth(20).maxNumberLength(64).maxStringLength(MAX_LONG_TEXT_LENGTH).build()) + .build()) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY) + .build(); + + PlacesSearchResult parseSearch(byte[] body) { + try { + JsonNode root = parseRoot(body); + JsonNode places = root.get("places"); + if (places == null) { + return new PlacesSearchResult.NoResults(); + } + require(places.isArray() && places.size() <= MAX_SEARCH_CANDIDATES); + if (places.isEmpty()) { + return new PlacesSearchResult.NoResults(); + } + List candidates = new ArrayList<>(); + for (JsonNode place : places) { + candidates.add(parseCandidate(place)); + } + return new PlacesSearchResult.Candidates(candidates); + } catch (IOException | RuntimeException ignored) { + return new PlacesSearchResult.Failure(FailureKind.INVALID_RESPONSE, 200); + } + } + + PlaceDetailsResult parseDetails(byte[] body) { + try { + return new PlaceDetailsResult.Place(parseCandidate(parseRoot(body))); + } catch (IOException | RuntimeException ignored) { + return new PlaceDetailsResult.Failure(FailureKind.INVALID_RESPONSE, 200); + } + } + + FailureKind classifyForbidden(byte[] body) { + try { + JsonNode root = mapper.readTree(body); + JsonNode status = root == null ? null : root.path("error").path("status"); + if (status == null || !status.isTextual()) { + return FailureKind.ACCESS_DENIED; + } + return "RESOURCE_EXHAUSTED".equals(status.textValue()) + ? FailureKind.QUOTA_EXCEEDED : FailureKind.ACCESS_DENIED; + } catch (IOException | RuntimeException ignored) { + return FailureKind.ACCESS_DENIED; + } + } + + private JsonNode parseRoot(byte[] body) throws IOException { + JsonNode root = mapper.readTree(body); + require(root != null && root.isObject()); + require(!root.has("error") && !root.has("status") && !root.has("error_message")); + return root; + } + + private PlacesCandidate parseCandidate(JsonNode place) { + require(place.isObject()); + String placeId = requiredText(place, "id", 255); + require(PLACE_ID.matcher(placeId).matches()); + JsonNode displayName = place.path("displayName"); + require(displayName.isObject()); + String name = requiredText(displayName, "text", MAX_SHORT_TEXT_LENGTH); + String address = requiredText(place, "formattedAddress", MAX_LONG_TEXT_LENGTH); + JsonNode location = place.path("location"); + require(location.isObject()); + BigDecimal latitude = coordinate(location.get("latitude"), 90); + BigDecimal longitude = coordinate(location.get("longitude"), 180); + AddressRegion region = parseRegion(place.path("addressComponents")); + List types = strings(place.path("types"), MAX_TYPES, MAX_SHORT_TEXT_LENGTH); + String businessStatus = optionalText(place, "businessStatus", MAX_SHORT_TEXT_LENGTH); + List attributions = parseAttributions(place.path("attributions")); + String googleMapsUri = requiredText(place, "googleMapsUri", MAX_URI_LENGTH); + return new PlacesCandidate(placeId, name, address, latitude, longitude, region.countryCode(), + region.administrativeArea(), types, businessStatus, attributions, googleMapsUri); + } + + private AddressRegion parseRegion(JsonNode components) { + require(components.isMissingNode() || components.isArray()); + String countryCode = ""; + String administrativeArea = ""; + int count = 0; + for (JsonNode component : components) { + require(++count <= MAX_TYPES && component.isObject()); + List types = strings(component.path("types"), MAX_TYPES, MAX_SHORT_TEXT_LENGTH); + if (types.contains("country") && countryCode.isEmpty()) { + countryCode = optionalText(component, "shortText", MAX_SHORT_TEXT_LENGTH); + } + if (types.contains("administrative_area_level_1") && administrativeArea.isEmpty()) { + administrativeArea = optionalText(component, "longText", MAX_SHORT_TEXT_LENGTH); + } + } + return new AddressRegion(countryCode, administrativeArea); + } + + private List parseAttributions(JsonNode values) { + require(values.isMissingNode() || values.isArray()); + List result = new ArrayList<>(); + for (JsonNode value : values) { + require(result.size() < MAX_ATTRIBUTIONS && value.isObject()); + result.add(new PlacesAttribution(requiredText(value, "provider", MAX_SHORT_TEXT_LENGTH), + requiredText(value, "providerUri", MAX_URI_LENGTH))); + } + return List.copyOf(result); + } + + private BigDecimal coordinate(JsonNode node, int limit) { + require(node != null && node.isNumber()); + BigDecimal value = node.decimalValue(); + require(value.abs().compareTo(BigDecimal.valueOf(limit)) <= 0); + return value; + } + + private String requiredText(JsonNode object, String name, int maximumLength) { + String value = optionalText(object, name, maximumLength); + require(!value.isBlank()); + return value; + } + + private String optionalText(JsonNode object, String name, int maximumLength) { + JsonNode value = object.path(name); + if (value.isMissingNode()) { + return ""; + } + require(value.isTextual() && value.textValue().length() <= maximumLength); + return value.textValue(); + } + + private List strings(JsonNode values, int maximumSize, int maximumTextLength) { + require(values.isMissingNode() || values.isArray()); + List result = new ArrayList<>(); + for (JsonNode value : values) { + require(result.size() < maximumSize && value.isTextual() + && value.textValue().length() <= maximumTextLength); + result.add(value.textValue()); + } + return List.copyOf(result); + } + + private void require(boolean valid) { + if (!valid) { + throw new IllegalArgumentException("Invalid Google Places response"); + } + } + + private record AddressRegion(String countryCode, String administrativeArea) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlaceDetailsResult.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlaceDetailsResult.java new file mode 100644 index 00000000..f010e869 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlaceDetailsResult.java @@ -0,0 +1,27 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import java.util.Objects; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; + +public sealed interface PlaceDetailsResult { + + record Place(PlacesCandidate candidate) implements PlaceDetailsResult { + public Place { + Objects.requireNonNull(candidate, "candidate must not be null"); + } + + @Override + public String toString() { + return "PlaceDetails[redacted]"; + } + } + + record NoResults() implements PlaceDetailsResult { + } + + record Failure(FailureKind kind, Integer httpStatus) implements PlaceDetailsResult { + public Failure { + Objects.requireNonNull(kind, "kind must not be null"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesAttribution.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesAttribution.java new file mode 100644 index 00000000..dd4b10f8 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesAttribution.java @@ -0,0 +1,16 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import java.util.Objects; + +public record PlacesAttribution(String displayName, String uri) { + + public PlacesAttribution { + Objects.requireNonNull(displayName, "displayName must not be null"); + Objects.requireNonNull(uri, "uri must not be null"); + } + + @Override + public String toString() { + return "PlacesAttribution[redacted]"; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesCandidate.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesCandidate.java new file mode 100644 index 00000000..f8213937 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesCandidate.java @@ -0,0 +1,39 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +public record PlacesCandidate( + String placeId, + String displayName, + String address, + BigDecimal latitude, + BigDecimal longitude, + String countryCode, + String administrativeArea, + List types, + String businessStatus, + List attributions, + String googleMapsUri +) { + + public PlacesCandidate { + Objects.requireNonNull(placeId, "placeId must not be null"); + Objects.requireNonNull(displayName, "displayName must not be null"); + Objects.requireNonNull(address, "address must not be null"); + Objects.requireNonNull(latitude, "latitude must not be null"); + Objects.requireNonNull(longitude, "longitude must not be null"); + Objects.requireNonNull(countryCode, "countryCode must not be null"); + Objects.requireNonNull(administrativeArea, "administrativeArea must not be null"); + types = List.copyOf(types); + Objects.requireNonNull(businessStatus, "businessStatus must not be null"); + attributions = List.copyOf(attributions); + Objects.requireNonNull(googleMapsUri, "googleMapsUri must not be null"); + } + + @Override + public String toString() { + return "PlacesCandidate[redacted]"; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesProvider.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesProvider.java new file mode 100644 index 00000000..6b629d95 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesProvider.java @@ -0,0 +1,11 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +public interface PlacesProvider { + + // Existing admin input limits: local name (100) + separator (1) + address (255). + int MAX_SEARCH_QUERY_LENGTH = 356; + + PlacesSearchResult search(String query); + + PlaceDetailsResult details(String placeId); +} diff --git a/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesSearchResult.java b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesSearchResult.java new file mode 100644 index 00000000..169e0636 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/internal/map/places/PlacesSearchResult.java @@ -0,0 +1,31 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import java.util.List; +import java.util.Objects; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; + +public sealed interface PlacesSearchResult { + + record Candidates(List candidates) implements PlacesSearchResult { + public Candidates { + candidates = List.copyOf(candidates); + if (candidates.isEmpty()) { + throw new IllegalArgumentException("places candidates must not be empty"); + } + } + + @Override + public String toString() { + return "PlacesSearchCandidates[redacted]"; + } + } + + record NoResults() implements PlacesSearchResult { + } + + record Failure(FailureKind kind, Integer httpStatus) implements PlacesSearchResult { + public Failure { + Objects.requireNonNull(kind, "kind must not be null"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceCli.java b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceCli.java new file mode 100644 index 00000000..436eaa45 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceCli.java @@ -0,0 +1,76 @@ +package org.sopt.hashi.restaurant.migration; + +import java.time.Clock; +import java.util.Map; +import org.sopt.hashi.config.JpaAuditingConfig; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionReader; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionService; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionTransactions; +import org.sopt.hashi.restaurant.service.RestaurantLocationService; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.MapPropertySource; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** Boot PropertiesLauncher entrypoint; deliberately excludes Flyway, web, workers and provider adapters. */ +public final class LocationMaintenanceCli { + private LocationMaintenanceCli() { + } + + public static void main(String[] args) { + try (var context = open(args)) { + Object result = context.getBean(LocationMaintenanceRunner.class) + .execute(context.getBean(LocationMaintenanceProperties.class)); + System.out.println(result); + } catch (RuntimeException exception) { + System.err.println("Location maintenance failed. Check approved configuration and database access."); + System.exit(1); + } + } + + static ConfigurableApplicationContext open(String... args) { + SpringApplication app = new SpringApplication(CliConfiguration.class); + app.setWebApplicationType(WebApplicationType.NONE); + app.setAdditionalProfiles("location-maintenance-cli"); + app.setLogStartupInfo(false); + app.setDefaultProperties(Map.of("spring.config.name", "location-maintenance", + "spring.main.banner-mode", "off", "logging.level.root", "OFF")); + app.addInitializers(context -> context.getEnvironment().getPropertySources().addFirst( + new MapPropertySource("maintenanceSafety", Map.of( + "spring.jpa.hibernate.ddl-auto", "validate", "spring.jpa.show-sql", "false", + "spring.sql.init.mode", "never", "spring.flyway.enabled", "false")))); + return app.run(args); + } + + // This profile keeps the CLI-only bootstrap out of normal application component scanning. + @Configuration(proxyBeanMethods = false) + @Profile("location-maintenance-cli") + @ImportAutoConfiguration({DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class}) + @EntityScan(basePackageClasses = Restaurant.class) + @EnableJpaRepositories(basePackageClasses = RestaurantRepository.class) + @Import({JpaAuditingConfig.class, LocationMaintenanceConfiguration.class, LocationMaintenanceReader.class, + LocationMaintenanceStore.class, LocationMaintenanceTransactions.class, + LocationMaintenanceInspection.class, LocationMaintenanceRunner.class, + LocationRetentionReader.class, LocationRetentionService.class, LocationRetentionTransactions.class, + RestaurantLocationService.class}) + static class CliConfiguration { + @Bean("japanClock") + Clock clock() { + return Clock.systemUTC(); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceConfiguration.java b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceConfiguration.java new file mode 100644 index 00000000..357cc2a2 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceConfiguration.java @@ -0,0 +1,9 @@ +package org.sopt.hashi.restaurant.migration; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(LocationMaintenanceProperties.class) +public class LocationMaintenanceConfiguration { +} diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceInspection.java b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceInspection.java new file mode 100644 index 00000000..acf6e186 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceInspection.java @@ -0,0 +1,76 @@ +package org.sopt.hashi.restaurant.migration; + +import java.time.Instant; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class LocationMaintenanceInspection { + private final LocationMaintenanceReader reader; + + public LocationMaintenanceInspection(LocationMaintenanceReader reader) { + this.reader = reader; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public Report inspect(LocationMaintenanceProperties options) { + LocalDateTime now = reader.now(); + long upper = options.upperId() == null ? Math.max(options.afterId(), reader.upperId()) : options.upperId(); + long cursor = options.afterId(); + long scanned = 0; + long purgeDue = 0; + long deletedGoogle = 0; + long eligible = 0; + Map counts = new LinkedHashMap<>(); + for (String category : new String[]{"UNRESOLVED", "VALID", "REFRESH_DUE", "EXPIRED", "PENDING", + "RETRY_WAIT", "REVIEW_REQUIRED", "FAILED", "DELETED"}) { + counts.put(category, 0L); + } + for (int batch = 0; batch < options.maxBatches(); batch++) { + var candidates = reader.page(cursor, upper, options.batchSize()); + for (var candidate : candidates) { + counts.merge(candidate.category(now, now.plus(options.refreshAhead())), 1L, Long::sum); + boolean google = candidate.isGoogleSource(); + boolean eligibleRefresh = google && "READY".equals(candidate.status()) + && !candidate.obtainedAt().isAfter(now) + && !candidate.validUntil().isAfter(now.plus(options.refreshAhead())) + && options.refreshAhead().compareTo(Duration.between(candidate.obtainedAt(), candidate.validUntil())) < 0; + boolean eligibleState = options.mode() == LocationMaintenanceProperties.Mode.BACKFILL + ? candidate.status() == null : eligibleRefresh; + if (!candidate.deleted() && eligibleState) { + eligible++; + } + if (google && !candidate.validUntil().isAfter(now.plus(options.purgeAhead()))) { + purgeDue++; + } + if (google && candidate.deleted()) { + deletedGoogle++; + } + cursor = candidate.restaurantId(); + scanned++; + } + if (candidates.size() < options.batchSize()) { + break; + } + } + boolean partial = reader.nextId(cursor, upper) != null; + long registrationEstimate = Math.min(eligible, Math.min(options.maxRegistrations(), + options.maxCalls() / LocationMaintenanceProperties.CALLS_PER_JOB)); + return new Report(now.toInstant(ZoneOffset.UTC), options.afterId(), upper, cursor, scanned, + options.batchSize() * options.maxBatches(), partial, Map.copyOf(counts), purgeDue, deletedGoogle, + options.mode(), eligible, registrationEstimate, registrationEstimate * LocationMaintenanceProperties.CALLS_PER_JOB); + } + + public record Report(Instant asOfUtc, long afterId, long upperId, long lastInspectedId, long inspected, + int inspectionLimit, boolean partial, Map categories, + long googlePurgeDueInInspectedRows, long deletedGoogleInInspectedRows, + LocationMaintenanceProperties.Mode mode, long eligibleByLocationStateInInspectedRows, + long registrationEstimateInInspectedRows, long reservedCallCeilingForEstimate) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceProperties.java b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceProperties.java new file mode 100644 index 00000000..5a925243 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceProperties.java @@ -0,0 +1,70 @@ +package org.sopt.hashi.restaurant.migration; + +import java.time.Duration; +import java.util.UUID; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "hashi.map.maintenance") +public record LocationMaintenanceProperties( + Command command, Mode mode, boolean execute, UUID runId, long afterId, Long upperId, + Integer batchSize, Integer maxBatches, Integer maxRegistrations, Integer maxCalls, + Duration refreshAhead, Duration purgeAhead, boolean retentionEnabled, Duration pollDelay +) { + /** #224 validates max-attempts in [1,8], including after application restarts. */ + public static final int CALLS_PER_JOB = 8; + + public LocationMaintenanceProperties { + command = command == null ? Command.DRY_RUN : command; + mode = mode == null ? Mode.BACKFILL : mode; + var retention = new LocationRetentionProperties(batchSize, maxBatches, refreshAhead, purgeAhead, + retentionEnabled, pollDelay); + batchSize = retention.batchSize(); + maxBatches = retention.maxBatches(); + maxRegistrations = maxRegistrations == null ? 10 : maxRegistrations; + maxCalls = maxCalls == null ? 80 : maxCalls; + refreshAhead = retention.refreshAhead(); + purgeAhead = retention.purgeAhead(); + pollDelay = retention.pollDelay(); + range(maxRegistrations, 1, 10000, "max-registrations"); + range(maxCalls, 0, 80000, "max-calls"); + if (afterId < 0 || (upperId != null && upperId < afterId)) { + throw new IllegalArgumentException("Invalid maintenance ID range"); + } + } + + public LocationRetentionProperties retentionOptions() { + return new LocationRetentionProperties(batchSize, maxBatches, refreshAhead, purgeAhead, + retentionEnabled, pollDelay); + } + + void requireWriteOptIn() { + if (!execute) { + throw new IllegalArgumentException("Mutation requires hashi.map.maintenance.execute=true"); + } + } + + UUID requiredRunId() { + if (runId == null) { + throw new IllegalArgumentException("run-id is required"); + } + return runId; + } + + long requiredUpperId() { + if (upperId == null) { + throw new IllegalArgumentException("START requires the upper-id reviewed in DRY_RUN"); + } + return upperId; + } + + private static void range(int value, int min, int max, String name) { + if (value < min || value > max) { + throw new IllegalArgumentException(name + " is outside the allowed range"); + } + } + + public enum Command { DRY_RUN, START, RESUME, STATUS, STOP, PURGE } + + public enum Mode { BACKFILL, REFRESH } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceReader.java b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceReader.java new file mode 100644 index 00000000..fb90ac1b --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceReader.java @@ -0,0 +1,80 @@ +package org.sopt.hashi.restaurant.migration; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** Scalar, bounded keyset reads. Addresses and coordinates never enter inspection DTOs. */ +@Repository +public class LocationMaintenanceReader { + static final String PAGE_SQL = """ + SELECT r.id, r.deleted, l.address_revision, l.request_id, l.status, l.source, + l.obtained_at, l.valid_until + FROM restaurant r LEFT JOIN restaurant_location l ON l.id = r.location_id + WHERE r.id > ? AND r.id <= ? ORDER BY r.id LIMIT ? + """; + private static final DateTimeFormatter SQL_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); + private final JdbcTemplate jdbc; + + public LocationMaintenanceReader(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public LocalDateTime now() { + return LocalDateTime.parse(jdbc.queryForObject( + "SELECT DATE_FORMAT(UTC_TIMESTAMP(6), '%Y-%m-%dT%H:%i:%s.%f')", String.class)); + } + + long upperId() { + return jdbc.queryForObject("SELECT COALESCE(MAX(id), 0) FROM restaurant", Long.class); + } + + List page(long after, long upper, int limit) { + return jdbc.query(PAGE_SQL, LocationMaintenanceReader::candidate, after, upper, limit); + } + + Long nextId(long after, long upper) { + return jdbc.query("SELECT id FROM restaurant WHERE id > ? AND id <= ? ORDER BY id LIMIT 1", + (rs, row) -> rs.getLong(1), after, upper).stream().findFirst().orElse(null); + } + + static String sqlTime(LocalDateTime time) { + return time.format(SQL_TIME); + } + + private static Candidate candidate(ResultSet rs, int row) throws SQLException { + String request = rs.getString("request_id"); + return new Candidate(rs.getLong("id"), rs.getBoolean("deleted"), rs.getLong("address_revision"), + request == null ? null : UUID.fromString(request), rs.getString("status"), rs.getString("source"), + rs.getObject("obtained_at", LocalDateTime.class), rs.getObject("valid_until", LocalDateTime.class)); + } + + public record Candidate(long restaurantId, boolean deleted, long revision, UUID requestId, + String status, String source, LocalDateTime obtainedAt, LocalDateTime validUntil) { + String category(LocalDateTime asOf, LocalDateTime refreshBefore) { + if (deleted) { + return "DELETED"; + } + if (status == null) { + return "UNRESOLVED"; + } + if (!"READY".equals(status)) { + return status; + } + if (!validUntil.isAfter(asOf)) { + return "EXPIRED"; + } + return isGoogleSource() && !validUntil.isAfter(refreshBefore) + ? "REFRESH_DUE" : "VALID"; + } + + boolean isGoogleSource() { + return "GOOGLE_GEOCODING".equals(source) || "GOOGLE_PLACES".equals(source); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceRunner.java b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceRunner.java new file mode 100644 index 00000000..67a8fc60 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceRunner.java @@ -0,0 +1,54 @@ +package org.sopt.hashi.restaurant.migration; + +import org.sopt.hashi.restaurant.internal.map.LocationRetentionService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** Invoked only by the separate CLI. Normal application startup does not run a backfill. */ +@Component +public class LocationMaintenanceRunner { + private final LocationMaintenanceInspection inspection; + private final LocationMaintenanceTransactions transactions; + private final LocationRetentionService retention; + + public LocationMaintenanceRunner(LocationMaintenanceInspection inspection, + LocationMaintenanceTransactions transactions, LocationRetentionService retention) { + this.inspection = inspection; + this.transactions = transactions; + this.retention = retention; + } + + public Object execute(LocationMaintenanceProperties options) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("Maintenance runner must run outside a transaction"); + } + return switch (options.command()) { + case DRY_RUN -> inspection.inspect(options); + case STATUS -> transactions.status(options.requiredRunId()); + case PURGE -> { + options.requireWriteOptIn(); + yield retention.purge(options.retentionOptions()); + } + case STOP -> { + options.requireWriteOptIn(); + transactions.stop(options.requiredRunId()); + yield transactions.status(options.requiredRunId()); + } + case START, RESUME -> { + options.requireWriteOptIn(); + if (options.command() == LocationMaintenanceProperties.Command.START) { + transactions.start(options); + } else { + transactions.resume(options.requiredRunId()); + } + int limit = options.batchSize() * options.maxBatches(); + for (int index = 0; index < limit && !Thread.currentThread().isInterrupted(); index++) { + if (!transactions.advance(options.requiredRunId())) { + break; + } + } + yield transactions.status(options.requiredRunId()); + } + }; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceStore.java b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceStore.java new file mode 100644 index 00000000..a2b9ac8c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceStore.java @@ -0,0 +1,95 @@ +package org.sopt.hashi.restaurant.migration; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class LocationMaintenanceStore { + private final JdbcTemplate jdbc; + + public LocationMaintenanceStore(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + void create(LocationMaintenanceProperties options, LocalDateTime now) { + jdbc.update(""" + INSERT INTO restaurant_location_maintenance_run + (id, mode, state, after_id, upper_id, cursor_id, as_of, refresh_before, + max_registrations, max_calls, updated_at) + VALUES (?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE id=id + """, options.requiredRunId().toString(), options.mode().name(), options.afterId(), + options.requiredUpperId(), options.afterId(), LocationMaintenanceReader.sqlTime(now), + LocationMaintenanceReader.sqlTime(now.plus(options.refreshAhead())), + options.maxRegistrations(), options.maxCalls(), LocationMaintenanceReader.sqlTime(now)); + } + + Run read(UUID id, boolean lock) { + return jdbc.query("SELECT * FROM restaurant_location_maintenance_run WHERE id=?" + + (lock ? " FOR UPDATE" : ""), (rs, row) -> new Run(id, + LocationMaintenanceProperties.Mode.valueOf(rs.getString("mode")), rs.getString("state"), + rs.getLong("after_id"), rs.getLong("upper_id"), rs.getLong("cursor_id"), + rs.getObject("as_of", LocalDateTime.class), rs.getObject("refresh_before", LocalDateTime.class), + rs.getInt("max_registrations"), rs.getInt("max_calls"), rs.getLong("scanned"), rs.getInt("enqueued")), + id.toString()).stream().findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown run-id")); + } + + void state(UUID id, String state) { + jdbc.update("UPDATE restaurant_location_maintenance_run SET state=?, updated_at=UTC_TIMESTAMP(6) WHERE id=?", + state, id.toString()); + } + + void progress(UUID id, long cursor, Long jobId) { + if (jobId != null) { + jdbc.update("INSERT INTO restaurant_location_maintenance_job (run_id, job_id) VALUES (?, ?)", + id.toString(), jobId); + } + jdbc.update(""" + UPDATE restaurant_location_maintenance_run SET cursor_id=?, scanned=scanned+1, + enqueued=enqueued+?, updated_at=UTC_TIMESTAMP(6) WHERE id=? + """, cursor, jobId == null ? 0 : 1, id.toString()); + } + + Completion completion(UUID id, LocalDateTime now) { + Map states = new TreeMap<>(); + jdbc.query(""" + SELECT COALESCE(j.state, 'MISSING_JOB') AS state, COUNT(*) AS amount + FROM restaurant_location_maintenance_job m LEFT JOIN restaurant_location_job j ON j.id=m.job_id + WHERE m.run_id=? GROUP BY j.state + """, rs -> { states.put(rs.getString("state"), rs.getLong("amount")); }, id.toString()); + long attempts = jdbc.queryForObject(""" + SELECT COALESCE(SUM(j.attempt), 0) FROM restaurant_location_maintenance_job m + JOIN restaurant_location_job j ON j.id=m.job_id WHERE m.run_id=? + """, Long.class, id.toString()); + long usable = jdbc.queryForObject(""" + SELECT COUNT(*) FROM restaurant_location_maintenance_job m + JOIN restaurant_location_job j ON j.id=m.job_id + JOIN restaurant r ON r.id=j.restaurant_id JOIN restaurant_location l ON l.id=r.location_id + WHERE m.run_id=? AND r.deleted=false AND l.latitude IS NOT NULL AND l.longitude IS NOT NULL + AND l.address_revision=j.address_revision AND l.request_id=j.request_id AND l.valid_until > ? + """, Long.class, id.toString(), LocationMaintenanceReader.sqlTime(now)); + return new Completion(now.toInstant(ZoneOffset.UTC), Map.copyOf(states), attempts, usable); + } + + public record Run(UUID id, LocationMaintenanceProperties.Mode mode, String state, + long afterId, long upperId, long cursorId, LocalDateTime asOf, LocalDateTime refreshBefore, + int maxRegistrations, int maxCalls, long scanned, int enqueued) { + boolean hasCapacity() { + return enqueued < maxRegistrations + && enqueued < maxCalls / LocationMaintenanceProperties.CALLS_PER_JOB; + } + } + + public record Completion(Instant asOfUtc, Map jobStates, long reservedAttempts, + long currentlyUsableLocations) { + } + + public record Status(Run registration, Completion completion) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceTransactions.java b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceTransactions.java new file mode 100644 index 00000000..f57f3445 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceTransactions.java @@ -0,0 +1,126 @@ +package org.sopt.hashi.restaurant.migration; + +import java.time.Duration; +import java.util.UUID; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantLocation; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantLocationStatus; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.migration.LocationMaintenanceStore.Run; +import org.sopt.hashi.restaurant.migration.LocationMaintenanceStore.Status; +import org.sopt.hashi.restaurant.service.RestaurantLocationService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** One short commit per restaurant. Lock order: run -> restaurant -> job; no HTTP or budget lock. */ +@Service +public class LocationMaintenanceTransactions { + private final LocationMaintenanceStore store; + private final LocationMaintenanceReader reader; + private final RestaurantRepository restaurants; + private final RestaurantLocationJobRepository jobs; + private final RestaurantLocationService locations; + + public LocationMaintenanceTransactions(LocationMaintenanceStore store, LocationMaintenanceReader reader, + RestaurantRepository restaurants, RestaurantLocationJobRepository jobs, + RestaurantLocationService locations) { + this.store = store; + this.reader = reader; + this.restaurants = restaurants; + this.jobs = jobs; + this.locations = locations; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED, timeout = 10) + public void start(LocationMaintenanceProperties options) { + options.requireWriteOptIn(); + store.create(options, reader.now()); + Run run = store.read(options.requiredRunId(), true); + boolean same = run.mode() == options.mode() && run.afterId() == options.afterId() + && run.upperId() == options.requiredUpperId() && run.maxCalls() == options.maxCalls() + && run.maxRegistrations() == options.maxRegistrations() + && Duration.between(run.asOf(), run.refreshBefore()).equals(options.refreshAhead()); + if (!same) { + throw new IllegalArgumentException("Existing run-id has a different immutable scope or limit"); + } + // Repeating START never resets counts, scope, or an explicit STOP. + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED, timeout = 10) + public boolean advance(UUID id) { + Run run = store.read(id, true); + if (!"ACTIVE".equals(run.state())) { + return false; + } + Long next = reader.nextId(run.cursorId(), run.upperId()); + if (next == null) { + store.state(id, "SCANNED"); + return false; + } + if (!run.hasCapacity()) { + store.state(id, "LIMIT_REACHED"); + return false; + } + Restaurant restaurant = restaurants.findByIdForUpdate(next).orElse(null); + Long jobId = null; + boolean eligible = restaurant != null && !restaurant.isDeleted() && eligible(restaurant.getLocation(), run); + if (eligible && jobs.findActiveForUpdate(next).isEmpty()) { + RestaurantLocation location = restaurant.getLocation(); + RestaurantLocationSource source = location == null ? null : location.getSource(); + String googlePlaceId = location == null ? null : location.getGooglePlaceId(); + if (location != null) { + restaurant.refreshLocation(); + } + if (source == RestaurantLocationSource.GOOGLE_PLACES) { + jobs.save(RestaurantLocationJob.pendingDetails(restaurant, googlePlaceId, reader.now())); + } else { + locations.enqueue(restaurant); + } + restaurants.flush(); + jobId = jobs.findByRestaurantIdAndRequestId(next, restaurant.getLocation().getRequestId()) + .orElseThrow().getId(); + } + // The checkpoint and job link roll back together with the entity/job writes on any failure. + store.progress(id, next, jobId); + return true; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED, timeout = 10) + public void stop(UUID id) { + Run run = store.read(id, true); + if ("ACTIVE".equals(run.state())) { + store.state(id, "STOPPED"); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED, timeout = 10) + public void resume(UUID id) { + Run run = store.read(id, true); + if ("STOPPED".equals(run.state())) { + store.state(id, "ACTIVE"); + } + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public Status status(UUID id) { + return new Status(store.read(id, false), store.completion(id, reader.now())); + } + + private boolean eligible(RestaurantLocation location, Run run) { + if (run.mode() == LocationMaintenanceProperties.Mode.BACKFILL) { + return location == null; + } + return location != null && location.getStatus() == RestaurantLocationStatus.READY + && (location.getSource() == RestaurantLocationSource.GOOGLE_GEOCODING + || location.getSource() == RestaurantLocationSource.GOOGLE_PLACES) + && !location.getObtainedAt().isAfter(run.asOf()) + && Duration.between(run.asOf(), run.refreshBefore()) + .compareTo(Duration.between(location.getObtainedAt(), location.getValidUntil())) < 0 + && !location.getValidUntil().isAfter(run.refreshBefore()); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/LocationAdoptionPolicy.java b/src/main/java/org/sopt/hashi/restaurant/service/LocationAdoptionPolicy.java new file mode 100644 index 00000000..1da1b1a7 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/LocationAdoptionPolicy.java @@ -0,0 +1,345 @@ +package org.sopt.hashi.restaurant.service; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.AddressComponent; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.Granularity; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Candidates; +import org.sopt.hashi.restaurant.internal.map.LocationJobProperties; +import org.springframework.stereotype.Service; + +/** 도쿄의 유일한 rooftop 후보만 채택한다. 주소 문자열은 확실한 우편번호·본번 충돌을 막는 데만 사용한다. */ +@Service +public class LocationAdoptionPolicy { + private static final Set SUPPORTED_ADMINISTRATIVE_AREAS = Set.of("東京都", "Tokyo"); + private static final Pattern POSTAL_CODE = Pattern.compile( + "(? qualifying = new ArrayList<>(); + String singleFailure = null; + for (GeocodingCandidate candidate : result.candidates()) { + String failure = rejectionReason(originalAddress, candidate); + if (failure == null) { + if (qualifying.stream().noneMatch(existing -> sameCoordinates(existing, candidate))) { + qualifying.add(candidate); + } + } else if (result.candidates().size() == 1) { + singleFailure = failure; + } + } + if (qualifying.size() != 1) { + return Decision.review(qualifying.isEmpty() && singleFailure != null + ? singleFailure : "AMBIGUOUS_RESULTS"); + } + GeocodingCandidate candidate = qualifying.getFirst(); + MapCoordinates coordinates = MapCoordinates.of(candidate.latitude().setScale(6, RoundingMode.HALF_UP), + candidate.longitude().setScale(6, RoundingMode.HALF_UP)); + return new Decision(coordinates, null); + } + + private String rejectionReason(String originalAddress, GeocodingCandidate candidate) { + if (!validCoordinate(candidate.latitude(), 90) || !validCoordinate(candidate.longitude(), 180)) { + return "INVALID_COORDINATES"; + } + if (candidate.countryCode() == null || candidate.countryCode().isBlank()) { + return "COUNTRY_MISSING"; + } + if (!"JP".equals(candidate.countryCode())) { + return "COUNTRY_MISMATCH"; + } + if (!SUPPORTED_ADMINISTRATIVE_AREAS.contains(candidate.administrativeArea()) + || !insideSupportedBounds(candidate)) { + return "OUTSIDE_SUPPORTED_AREA"; + } + if (candidate.granularity() != Granularity.ROOFTOP) { + return "INSUFFICIENT_PRECISION"; + } + if (hasConflictingPostalCode(originalAddress, candidate.addressComponents()) + || hasConflictingMainNumber(originalAddress, candidate.addressComponents())) { + return "ADDRESS_MISMATCH"; + } + return null; + } + + private static boolean hasConflictingPostalCode(String input, List components) { + Set inputPostalCodes = postalCodes(input == null ? List.of() : List.of(input)); + List providerPostalValues = components.stream() + .filter(component -> component.types().contains("postal_code")) + .flatMap(component -> java.util.stream.Stream.of(component.longText(), component.shortText())) + .toList(); + Set providerPostalCodes = postalCodes(providerPostalValues); + if (inputPostalCodes.size() > 1 || providerPostalCodes.size() > 1) { + return true; + } + return inputPostalCodes.size() == 1 && providerPostalCodes.size() == 1 + && !inputPostalCodes.iterator().next().equals(providerPostalCodes.iterator().next()); + } + + private static Set postalCodes(List values) { + Set postalCodes = new LinkedHashSet<>(); + for (String value : values) { + if (value == null) { + continue; + } + Matcher matcher = POSTAL_CODE.matcher(normalize(value)); + while (matcher.find()) { + postalCodes.add(matcher.group(1) + matcher.group(2)); + } + } + return postalCodes; + } + + private static boolean hasConflictingMainNumber(String input, List components) { + InputNumber inputNumber = inputMainNumber(input); + ProviderNumber providerNumber = providerMainNumber(components); + if (inputNumber.conflicting() || providerNumber.conflicting()) { + return true; + } + if (inputNumber.evidence().isEmpty() || providerNumber.evidence().isEmpty()) { + return false; + } + List inputParts = inputNumber.evidence().orElseThrow(); + NumberEvidence provider = providerNumber.evidence().orElseThrow(); + if (provider.fullTuple()) { + return !inputParts.equals(provider.parts()); + } + return !endsWith(inputParts, provider.parts()); + } + + private static InputNumber inputMainNumber(String value) { + if (value == null) { + return new InputNumber(Optional.empty(), false); + } + String normalized = normalize(value); + normalized = POSTAL_CODE.matcher(normalized).replaceAll(" "); + normalized = FLOOR.matcher(normalized).replaceAll(" "); + normalized = normalizeNumberSeparators(normalized); + Set> matches = numberSequences(normalized); + if (matches.size() > 1) { + return new InputNumber(Optional.empty(), true); + } + Optional> evidence = matches.size() == 1 + ? Optional.of(matches.iterator().next()) : Optional.empty(); + return new InputNumber(evidence, false); + } + + private static ProviderNumber providerMainNumber(List components) { + Set> directTuples = new LinkedHashSet<>(); + Set chome = new LinkedHashSet<>(); + Set block = new LinkedHashSet<>(); + Set building = new LinkedHashSet<>(); + for (AddressComponent component : components) { + if (component.types().contains("subpremise")) { + continue; + } + List values = java.util.stream.Stream.of(component.longText(), component.shortText()) + .filter(value -> value != null && !value.isBlank()) + .map(LocationAdoptionPolicy::normalize) + .toList(); + Set componentChome = matchedNumbers(values, CHOME); + Set componentBlock = matchedNumbers(values, BLOCK); + Set componentBuilding = matchedNumbers(values, BUILDING); + long explicitRoles = java.util.stream.Stream.of(componentChome, componentBlock, componentBuilding) + .filter(valuesForRole -> !valuesForRole.isEmpty()) + .count(); + if (explicitRoles > 1 || componentChome.size() > 1 || componentBlock.size() > 1 + || componentBuilding.size() > 1) { + return ProviderNumber.conflictingNumber(); + } + Set componentExplicitNumbers = new LinkedHashSet<>(); + componentExplicitNumbers.addAll(componentChome); + componentExplicitNumbers.addAll(componentBlock); + componentExplicitNumbers.addAll(componentBuilding); + chome.addAll(componentChome); + block.addAll(componentBlock); + building.addAll(componentBuilding); + for (String normalized : values) { + if (CHOME.matcher(normalized).matches() || BLOCK.matcher(normalized).matches() + || BUILDING.matcher(normalized).matches()) { + continue; + } + if (POSITIVE_NUMBER.matcher(normalized).matches() && !componentExplicitNumbers.isEmpty()) { + if (!componentExplicitNumbers.contains(new BigInteger(normalized))) { + return ProviderNumber.conflictingNumber(); + } + continue; + } + if (component.types().contains("sublocality_level_4") + && POSITIVE_NUMBER.matcher(normalized).matches()) { + block.add(new BigInteger(normalized)); + } + if (component.types().contains("street_number") || component.types().contains("premise")) { + String separated = normalizeNumberSeparators(FLOOR.matcher(normalized).replaceAll(" ")); + directTuples.addAll(numberSequences(separated)); + if (POSITIVE_NUMBER.matcher(separated).matches()) { + building.add(new BigInteger(separated)); + } + } + } + } + if (chome.size() > 1 || block.size() > 1 || building.size() > 1 || directTuples.size() > 1) { + return ProviderNumber.conflictingNumber(); + } + Optional componentTuple = Optional.empty(); + if (chome.size() == 1 && block.size() == 1 && building.size() == 1) { + componentTuple = Optional.of(new NumberEvidence( + List.of(first(chome), first(block), first(building)), true)); + } + Optional directEvidence = Optional.empty(); + if (directTuples.size() == 1) { + List direct = directTuples.iterator().next(); + if (direct.size() == 3) { + directEvidence = Optional.of(new NumberEvidence(direct, true)); + } else if (direct.size() == 2 && chome.size() == 1) { + directEvidence = Optional.of(new NumberEvidence( + List.of(first(chome), direct.get(0), direct.get(1)), true)); + } else { + directEvidence = Optional.of(new NumberEvidence(direct, false)); + } + } + if (componentTuple.isPresent() && directEvidence.isPresent() + && conflictingEvidence(componentTuple.orElseThrow(), directEvidence.orElseThrow())) { + return ProviderNumber.conflictingNumber(); + } + if (componentTuple.isPresent()) { + return new ProviderNumber(componentTuple, false); + } + if (directEvidence.isPresent()) { + return new ProviderNumber(directEvidence, false); + } + Optional suffix = building.size() == 1 + ? Optional.of(new NumberEvidence(List.of(first(building)), false)) + : Optional.empty(); + return new ProviderNumber(suffix, false); + } + + private static boolean conflictingEvidence(NumberEvidence first, NumberEvidence second) { + if (first.fullTuple() && second.fullTuple()) { + return !first.parts().equals(second.parts()); + } + NumberEvidence full = first.fullTuple() ? first : second; + NumberEvidence suffix = first.fullTuple() ? second : first; + return !endsWith(full.parts(), suffix.parts()); + } + + private static void addMatchedNumber(String value, Pattern pattern, Set target) { + Matcher matcher = pattern.matcher(value); + if (matcher.matches()) { + target.add(new BigInteger(matcher.group(1))); + } + } + + private static Set matchedNumbers(List values, Pattern pattern) { + Set matches = new LinkedHashSet<>(); + values.forEach(value -> addMatchedNumber(value, pattern, matches)); + return matches; + } + + private static Set> numberSequences(String value) { + Set> matches = new LinkedHashSet<>(); + Matcher matcher = MAIN_NUMBER_SEQUENCE.matcher(value); + while (matcher.find()) { + matches.add(List.of(matcher.group(1).split("-")).stream() + .map(BigInteger::new).toList()); + } + return matches; + } + + private static String normalizeNumberSeparators(String value) { + return value.replaceAll("(?i)([1-9][0-9]*)\\s*-?\\s*(?:丁目|ch[oō]me)\\s*-?", "$1-") + .replaceAll("([1-9][0-9]*)\\s*番地?\\s*-?", "$1-") + .replaceAll("([1-9][0-9]*)\\s*号", "$1"); + } + + private static String normalize(String value) { + return Normalizer.normalize(value, Normalizer.Form.NFKC).replaceAll("[‐‑−-ー]", "-"); + } + + private static boolean endsWith(List whole, List suffix) { + if (whole.size() < suffix.size()) { + return false; + } + int offset = whole.size() - suffix.size(); + for (int index = 0; index < suffix.size(); index++) { + if (!whole.get(offset + index).equals(suffix.get(index))) { + return false; + } + } + return true; + } + + private static BigInteger first(Set values) { + return values.iterator().next(); + } + + private static boolean sameCoordinates(GeocodingCandidate left, GeocodingCandidate right) { + return left.latitude().compareTo(right.latitude()) == 0 + && left.longitude().compareTo(right.longitude()) == 0; + } + + private boolean insideSupportedBounds(GeocodingCandidate candidate) { + return properties.south() != null && properties.north() != null + && properties.west() != null && properties.east() != null + && candidate.latitude().compareTo(properties.south()) >= 0 + && candidate.latitude().compareTo(properties.north()) <= 0 + && candidate.longitude().compareTo(properties.west()) >= 0 + && candidate.longitude().compareTo(properties.east()) <= 0; + } + + private static boolean validCoordinate(BigDecimal value, int maximum) { + return value != null && value.abs().compareTo(BigDecimal.valueOf(maximum)) <= 0; + } + + private record NumberEvidence(List parts, boolean fullTuple) { + private NumberEvidence { + parts = List.copyOf(parts); + } + } + + private record ProviderNumber(Optional evidence, boolean conflicting) { + private static ProviderNumber conflictingNumber() { + return new ProviderNumber(Optional.empty(), true); + } + } + + private record InputNumber(Optional> evidence, boolean conflicting) { + } + + public record Decision(MapCoordinates coordinates, String failureCode) { + static Decision review(String code) { + return new Decision(null, code); + } + + @Override + public String toString() { + return "LocationDecision[failureCode=" + failureCode + "]"; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/LocationJobTransactions.java b/src/main/java/org/sopt/hashi/restaurant/service/LocationJobTransactions.java new file mode 100644 index 00000000..55828f45 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/LocationJobTransactions.java @@ -0,0 +1,305 @@ +package org.sopt.hashi.restaurant.service; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.sopt.hashi.restaurant.domain.GeocodingBudget; +import org.sopt.hashi.restaurant.domain.GeocodingBudgetRepository; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.domain.PlacesBudget; +import org.sopt.hashi.restaurant.domain.PlacesBudgetRepository; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob; +import org.sopt.hashi.restaurant.domain.RestaurantLocationAttribution; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob.State; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob.Operation; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantLocationStatus; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.sopt.hashi.restaurant.internal.map.LocationJobProperties; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 짧은 DB 단계만 담당한다. 쓰기 잠금 순서: restaurant -> job -> 전역 budget. */ +@Service +public class LocationJobTransactions { + private static final java.time.Duration PLACES_RETENTION = java.time.Duration.ofDays(30); + private final RestaurantRepository restaurants; + private final RestaurantLocationJobRepository jobs; + private final GeocodingBudgetRepository budgets; + private final PlacesBudgetRepository placesBudgets; + private final LocationJobProperties properties; + private final LocationRetryPolicy retries; + + public LocationJobTransactions(RestaurantRepository restaurants, RestaurantLocationJobRepository jobs, + GeocodingBudgetRepository budgets, PlacesBudgetRepository placesBudgets, + LocationJobProperties properties, + LocationRetryPolicy retries) { + this.restaurants = restaurants; + this.jobs = jobs; + this.budgets = budgets; + this.placesBudgets = placesBudgets; + this.properties = properties; + this.retries = retries; + } + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) + public List candidates() { + if (!properties.enabled()) { + return List.of(); + } + LocalDateTime now = now(); + var page = PageRequest.of(0, 50); + if (!properties.isConfigured()) { + return java.util.stream.Stream.concat( + jobs.findDue(now, Operation.GEOCODING, page).stream(), + jobs.findDue(now, Operation.PLACE_DETAILS, page).stream()).map(this::target).toList(); + } + List geocoding = dueGeocoding(now, page); + List details = dueDetails(now, page); + return interleave(details, geocoding); + } + + // READ_COMMITTED makes the reservation count fresh after acquiring the singleton budget lock. + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public Optional claim(Target target) { + if (!properties.enabled()) { + return Optional.empty(); + } + Restaurant restaurant = restaurants.findByIdForUpdate(target.restaurantId()).orElse(null); + RestaurantLocationJob job = jobs.findByIdForUpdate(target.jobId()).orElse(null); + LocalDateTime now = now(); + if (job == null || restaurant == null || !job.getRestaurantId().equals(target.restaurantId())) { + return Optional.empty(); + } + if (!job.isCurrent(restaurant)) { + job.supersede(now); + return Optional.empty(); + } + if (!job.isDue(now)) { + return Optional.empty(); + } + if (!properties.isConfigured()) { + restaurant.retryLocationWhenDue(at(now)); + job.followRequest(restaurant.getLocation().getRequestId()); + restaurant.rejectLocation(job.getAddressRevision(), job.getRequestId(), RestaurantLocationStatus.FAILED); + job.finish(State.FAILED, "CONFIGURATION_ERROR", now); + return Optional.empty(); + } + if (job.getAttempt() >= properties.maxAttempts()) { + restaurant.retryLocationWhenDue(at(now)); + job.followRequest(restaurant.getLocation().getRequestId()); + restaurant.rejectLocation(job.getAddressRevision(), restaurant.getLocation().getRequestId(), + RestaurantLocationStatus.FAILED); + job.finish(State.FAILED, "ATTEMPTS_EXHAUSTED", now); + return Optional.empty(); + } + if (!reserve(job)) { + return Optional.empty(); + } + now = now(); + restaurant.retryLocationWhenDue(at(now)); + job.claim(restaurant.getLocation().getRequestId(), now, now.plus(LocationJobProperties.LEASE)); + return Optional.of(new Claim(restaurant.getId(), job.getId(), job.getAddressRevision(), job.getRequestId(), + job.getLeaseToken(), job.getLeaseUntil(), now, job.getOperation(), job.getGooglePlaceId(), + restaurant.geocodingAddressForResolution())); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public boolean complete(Claim claim, Outcome outcome) { + if (claim.operation() != Operation.GEOCODING) { + return false; + } + Restaurant restaurant = restaurants.findByIdForUpdate(claim.restaurantId()).orElse(null); + RestaurantLocationJob job = jobs.findByIdForUpdate(claim.jobId()).orElse(null); + // Complete can change the shared quota pause. Acquire this last lock before validating the deadline. + budgets.findControlForUpdate(); + LocalDateTime now = now(); + boolean current = restaurant != null && job != null && job.isCurrent(restaurant) + && job.getAddressRevision() == claim.addressRevision() && job.getRequestId().equals(claim.requestId()) + && job.ownsLease(claim.leaseToken(), now) && job.getLeaseUntil().equals(claim.leaseUntil()); + if (!current) { + return false; + } + if (outcome.coordinates() != null) { + LocalDateTime until = claim.obtainedAt().plus(properties.retention()); + if (!until.isAfter(now)) { + deferOrFail(restaurant, job, FailureKind.TRANSIENT_ERROR, "RESULT_EXPIRED", now); + } else { + boolean applied = restaurant.completeLocation(claim.addressRevision(), claim.requestId(), + outcome.coordinates(), RestaurantLocationSource.GOOGLE_GEOCODING, + claim.obtainedAt(), until, at(now)); + if (!applied) { + return false; + } + job.finish(State.SUCCEEDED, null, now); + } + } else if (outcome.failureKind() != null) { + deferOrFail(restaurant, job, outcome.failureKind(), outcome.failureCode(), now); + } else { + restaurant.rejectLocation(claim.addressRevision(), claim.requestId(), RestaurantLocationStatus.REVIEW_REQUIRED); + job.finish(State.REVIEW_REQUIRED, outcome.failureCode(), now); + } + return true; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public boolean completePlaces(Claim claim, PlacesOutcome outcome) { + if (claim.operation() != Operation.PLACE_DETAILS || claim.googlePlaceId() == null) { + return false; + } + Restaurant restaurant = restaurants.findByIdForUpdate(claim.restaurantId()).orElse(null); + RestaurantLocationJob job = jobs.findByIdForUpdate(claim.jobId()).orElse(null); + placesBudgets.findByOperationForUpdate(PlacesBudget.Operation.DETAILS); + LocalDateTime now = now(); + boolean current = restaurant != null && job != null && job.isCurrent(restaurant) + && job.getOperation() == Operation.PLACE_DETAILS + && claim.googlePlaceId().equals(job.getGooglePlaceId()) + && job.getAddressRevision() == claim.addressRevision() && job.getRequestId().equals(claim.requestId()) + && job.ownsLease(claim.leaseToken(), now) && job.getLeaseUntil().equals(claim.leaseUntil()); + if (!current) { + return false; + } + if (outcome.coordinates() != null) { + if (!claim.googlePlaceId().equals(outcome.googlePlaceId())) { + deferOrFail(restaurant, job, FailureKind.INVALID_RESPONSE, "PLACE_ID_MISMATCH", now); + } else { + LocalDateTime until = claim.obtainedAt().plus(PLACES_RETENTION); + boolean applied = restaurant.completePlacesLocation(claim.addressRevision(), claim.requestId(), + outcome.coordinates(), claim.googlePlaceId(), outcome.attributions(), + claim.obtainedAt(), until, at(now)); + if (!applied) { + return false; + } + job.finish(State.SUCCEEDED, null, now); + } + } else if (outcome.failureKind() != null) { + deferOrFail(restaurant, job, outcome.failureKind(), outcome.failureCode(), now); + } else { + restaurant.rejectLocation(claim.addressRevision(), claim.requestId(), RestaurantLocationStatus.REVIEW_REQUIRED); + job.finish(State.REVIEW_REQUIRED, outcome.failureCode(), now); + } + return true; + } + + private void deferOrFail(Restaurant restaurant, RestaurantLocationJob job, FailureKind kind, + String code, LocalDateTime now) { + boolean retryable = retries.canRetry(kind); + boolean exhausted = job.getAttempt() >= properties.maxAttempts(); + LocalDateTime next = retryable ? now.plus(retries.delay(kind, job.getAttempt())) : null; + // Provider quota applies to all jobs even when this job has no automatic attempts left. + if (kind == FailureKind.QUOTA_EXCEEDED) { + if (job.getOperation() == Operation.GEOCODING) { + budgets.findControlForUpdate().ifPresent(budget -> budget.blockUntil(next)); + } else { + placesBudgets.findByOperationForUpdate(PlacesBudget.Operation.DETAILS) + .ifPresent(budget -> budget.blockUntil(next)); + } + } + // Cancellation also consumes an attempt; finish immediately when the durable cap is reached. + if (retryable && !exhausted) { + restaurant.deferLocation(job.getAddressRevision(), job.getRequestId(), next, at(now)); + job.defer(code, next, now); + } else { + restaurant.rejectLocation(job.getAddressRevision(), job.getRequestId(), RestaurantLocationStatus.FAILED); + job.finish(State.FAILED, retryable ? "ATTEMPTS_EXHAUSTED" : code, now); + } + } + + private List dueGeocoding(LocalDateTime now, PageRequest page) { + GeocodingBudget budget = budgets.findById(1L).orElse(null); + if (budget == null || !budget.canReserve(now, jobs.countReservations(now, Operation.GEOCODING))) { + return jobs.findDueExhausted(now, properties.maxAttempts(), Operation.GEOCODING, page); + } + return jobs.findDue(now, Operation.GEOCODING, page); + } + + private List dueDetails(LocalDateTime now, PageRequest page) { + PlacesBudget budget = placesBudgets.findById(PlacesBudget.Operation.DETAILS).orElse(null); + if (budget == null || !budget.canReserve(now)) { + return jobs.findDueExhausted(now, properties.maxAttempts(), Operation.PLACE_DETAILS, page); + } + return jobs.findDue(now, Operation.PLACE_DETAILS, page); + } + + private boolean reserve(RestaurantLocationJob job) { + if (job.getOperation() == Operation.GEOCODING) { + GeocodingBudget budget = budgets.findControlForUpdate().orElse(null); + LocalDateTime now = now(); + return budget != null && budget.reserve(now, jobs.countReservations(now, Operation.GEOCODING)); + } + PlacesBudget budget = placesBudgets.findByOperationForUpdate(PlacesBudget.Operation.DETAILS).orElse(null); + LocalDateTime now = now(); + return budget != null && budget.reserve(now); + } + + private Target target(RestaurantLocationJob job) { + return new Target(job.getRestaurantId(), job.getId()); + } + + private List interleave(List first, List second) { + List targets = new java.util.ArrayList<>(first.size() + second.size()); + int maximum = Math.max(first.size(), second.size()); + for (int index = 0; index < maximum; index++) { + if (index < first.size()) { + targets.add(target(first.get(index))); + } + if (index < second.size()) { + targets.add(target(second.get(index))); + } + } + return List.copyOf(targets); + } + + private LocalDateTime now() { + return LocalDateTime.parse(budgets.databaseUtcTime()); + } + + private static Clock at(LocalDateTime now) { + return Clock.fixed(now.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); + } + + public record Target(Long restaurantId, Long jobId) { + } + + public record Claim(Long restaurantId, Long jobId, long addressRevision, UUID requestId, UUID leaseToken, + LocalDateTime leaseUntil, LocalDateTime obtainedAt, Operation operation, + String googlePlaceId, String geocodingAddress) { + @Override + public String toString() { + return "LocationClaim[restaurantId=" + restaurantId + ",jobId=" + jobId + "]"; + } + } + + public record Outcome(MapCoordinates coordinates, FailureKind failureKind, String failureCode) { + public static Outcome failure(FailureKind kind) { + return new Outcome(null, kind, kind.name()); + } + + @Override + public String toString() { + return "LocationOutcome[failureCode=" + failureCode + "]"; + } + } + + public record PlacesOutcome(MapCoordinates coordinates, String googlePlaceId, + List attributions, + FailureKind failureKind, String failureCode) { + public static PlacesOutcome failure(FailureKind kind) { + return new PlacesOutcome(null, null, null, kind, kind.name()); + } + + @Override + public String toString() { + return "PlacesLocationOutcome[failureCode=" + failureCode + "]"; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/LocationRetryPolicy.java b/src/main/java/org/sopt/hashi/restaurant/service/LocationRetryPolicy.java new file mode 100644 index 00000000..b0fd821e --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/LocationRetryPolicy.java @@ -0,0 +1,28 @@ +package org.sopt.hashi.restaurant.service; + +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.stereotype.Service; + +@Service +public class LocationRetryPolicy { + private static final long[] DELAY_SECONDS = {60, 300, 1800, 7200, 21600, 43200, 86400}; + + public boolean canRetry(FailureKind kind) { + return switch (kind) { + case TIMEOUT, CONNECTION_ERROR, TRANSIENT_ERROR, QUOTA_EXCEEDED, CAPACITY_EXCEEDED, CANCELLED -> true; + case DISABLED, INVALID_REQUEST, ACCESS_DENIED, CONFIGURATION_ERROR, INVALID_RESPONSE, + RESPONSE_TOO_LARGE, REDIRECT_REJECTED -> false; + }; + } + + public Duration delay(FailureKind kind, int attempt) { + if (!canRetry(kind) || attempt < 1 || attempt > 8) { + throw new IllegalArgumentException("Retry delay requires a retryable failure and attempt between 1 and 8"); + } + // The final quota failure still pauses other jobs, using the last interval. + long seconds = DELAY_SECONDS[Math.min(attempt, DELAY_SECONDS.length) - 1]; + return Duration.ofSeconds(seconds + ThreadLocalRandom.current().nextLong(seconds / 10 + 1)); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/PlacesCandidatePolicy.java b/src/main/java/org/sopt/hashi/restaurant/service/PlacesCandidatePolicy.java new file mode 100644 index 00000000..17016125 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/PlacesCandidatePolicy.java @@ -0,0 +1,58 @@ +package org.sopt.hashi.restaurant.service; + +import java.math.RoundingMode; +import java.net.URI; +import java.util.Set; +import org.sopt.hashi.restaurant.domain.MapBounds; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.domain.RestaurantLocationAttribution; +import org.sopt.hashi.restaurant.internal.map.LocationJobProperties; +import org.sopt.hashi.restaurant.internal.map.places.PlacesCandidate; +import org.springframework.stereotype.Service; + +/** Places 후보는 일본·도쿄·서비스 지도 경계를 모두 만족할 때만 선택 토큰을 받을 수 있다. */ +@Service +public class PlacesCandidatePolicy { + private static final Set TOKYO = Set.of("Tokyo", "東京都"); + private final LocationJobProperties properties; + + public PlacesCandidatePolicy(LocationJobProperties properties) { + this.properties = properties; + } + + public boolean accepts(PlacesCandidate candidate) { + if (!properties.isConfigured() || !"JP".equals(candidate.countryCode()) + || !TOKYO.contains(candidate.administrativeArea()) + || !isHttps(candidate.googleMapsUri())) { + return false; + } + try { + MapCoordinates coordinates = coordinates(candidate); + attributions(candidate); + return MapBounds.of(properties.south(), properties.north(), properties.west(), properties.east()) + .contains(coordinates); + } catch (IllegalArgumentException exception) { + return false; + } + } + + public MapCoordinates coordinates(PlacesCandidate candidate) { + return MapCoordinates.of(candidate.latitude().setScale(6, RoundingMode.HALF_UP), + candidate.longitude().setScale(6, RoundingMode.HALF_UP)); + } + + public java.util.List attributions(PlacesCandidate candidate) { + return candidate.attributions().stream() + .map(value -> new RestaurantLocationAttribution(value.displayName(), value.uri())) + .toList(); + } + + private boolean isHttps(String value) { + try { + URI uri = URI.create(value); + return "https".equalsIgnoreCase(uri.getScheme()) && uri.getHost() != null; + } catch (IllegalArgumentException exception) { + return false; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/PlacesLocationService.java b/src/main/java/org/sopt/hashi/restaurant/service/PlacesLocationService.java new file mode 100644 index 00000000..e8f15edc --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/PlacesLocationService.java @@ -0,0 +1,195 @@ +package org.sopt.hashi.restaurant.service; + +import java.util.List; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.restaurant.RestaurantLocationPreviewCommand; +import org.sopt.hashi.restaurant.RestaurantLocationInfo; +import org.sopt.hashi.restaurant.RestaurantPlacesCandidateInfo; +import org.sopt.hashi.restaurant.RestaurantPlacesCandidateInfo.Attribution; +import org.sopt.hashi.restaurant.RestaurantPlacesSearchInfo; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.sopt.hashi.restaurant.internal.map.LocationJobProperties; +import org.sopt.hashi.restaurant.internal.map.PlacesSelectionToken; +import org.sopt.hashi.restaurant.internal.map.PlacesSelectionToken.Claims; +import org.sopt.hashi.restaurant.internal.map.places.GooglePlacesProperties; +import org.sopt.hashi.restaurant.internal.map.places.PlacesCandidate; +import org.sopt.hashi.restaurant.internal.map.places.PlacesProvider; +import org.sopt.hashi.restaurant.internal.map.places.PlacesSearchResult; +import org.sopt.hashi.restaurant.internal.map.places.PlacesSearchResult.Candidates; +import org.sopt.hashi.restaurant.internal.map.places.PlacesSearchResult.Failure; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** 관리자 Places 후보 검색과 서명 후보 선택을 조정한다. provider 대기 중에는 transaction이 없다. */ +@Service +public class PlacesLocationService { + private final PlacesLocationTransactions transactions; + private final PlacesProvider provider; + private final PlacesCandidatePolicy policy; + private final PlacesSelectionToken tokens; + private final GooglePlacesProperties providerProperties; + private final LocationJobProperties jobProperties; + private final RestaurantLocationService locations; + private final CurrentActorProvider currentActorProvider; + + public PlacesLocationService(PlacesLocationTransactions transactions, PlacesProvider provider, + PlacesCandidatePolicy policy, PlacesSelectionToken tokens, + GooglePlacesProperties providerProperties, LocationJobProperties jobProperties, + RestaurantLocationService locations, CurrentActorProvider currentActorProvider) { + this.transactions = transactions; + this.provider = provider; + this.policy = policy; + this.tokens = tokens; + this.providerProperties = providerProperties; + this.jobProperties = jobProperties; + this.locations = locations; + this.currentActorProvider = currentActorProvider; + } + + public RestaurantPlacesSearchInfo search(Long restaurantId, long expectedAddressRevision) { + requireNoTransaction(); + requireConfigured(); + Long adminId = requireAdmin(); + var context = transactions.prepareSearch(restaurantId, expectedAddressRevision); + PlacesSearchResult result = searchProvider(context.query()); + transactions.requireCurrent(context); + if (result instanceof PlacesSearchResult.NoResults) { + return new RestaurantPlacesSearchInfo(restaurantId, context.addressRevision(), List.of()); + } + List candidates = ((Candidates) result).candidates().stream() + .filter(policy::accepts) + .map(candidate -> toInfo(candidate, tokens.issue(context.restaurantId(), context.addressRevision(), + context.requestId(), candidate.placeId(), adminId))) + .toList(); + return new RestaurantPlacesSearchInfo(restaurantId, context.addressRevision(), candidates); + } + + public RestaurantLocationInfo select(Long restaurantId, long expectedAddressRevision, String selectionToken) { + requireNoTransaction(); + requireConfigured(); + Long adminId = requireAdmin(); + Claims claims = tokens.verify(selectionToken) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.PLACE_SELECTION_INVALID)); + if (claims.preview() || !adminId.equals(claims.adminId()) + || !restaurantId.equals(claims.restaurantId()) || expectedAddressRevision != claims.addressRevision()) { + throw new BusinessException(RestaurantErrorCode.PLACE_SELECTION_CONFLICT); + } + transactions.select(restaurantId, expectedAddressRevision, claims.requestId(), claims.placeId(), adminId); + return locations.get(restaurantId); + } + + public List preview(RestaurantLocationPreviewCommand input) { + requireNoTransaction(); + requireConfigured(); + Long adminId = requireAdmin(); + validatePreviewInput(input); + String address = Restaurant.normalizeGeocodingAddress(input.geocodingAddress()); + String query = (input.localName() + " " + (address == null ? input.address() : address)).strip(); + transactions.reservePreviewSearch(query); + PlacesSearchResult result = searchProvider(query); + if (result instanceof PlacesSearchResult.NoResults) { + return List.of(); + } + return ((Candidates) result).candidates().stream() + .filter(policy::accepts) + .map(candidate -> toInfo(candidate, tokens.issuePreview(input, candidate.placeId(), adminId))) + .toList(); + } + + /** 최종 저장 직전 서명·입력·관리자 검증만 수행한다. 이 단계는 외부 API를 호출하지 않는다. */ + public CreationSelection verifyForCreate(RestaurantLocationPreviewCommand input, String selectionToken) { + requireConfigured(); + Long adminId = requireAdmin(); + validatePreviewInput(input); + Claims claims = tokens.verify(selectionToken) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.PLACE_SELECTION_INVALID)); + if (!claims.preview() || !adminId.equals(claims.adminId()) + || !PlacesSelectionToken.fingerprint(input).equals(claims.fingerprint())) { + throw new BusinessException(RestaurantErrorCode.PLACE_SELECTION_CONFLICT); + } + return new CreationSelection(claims.placeId(), adminId); + } + + private PlacesSearchResult searchProvider(String query) { + PlacesSearchResult result; + try { + result = provider.search(query); + } catch (RuntimeException exception) { + throw new BusinessException(RestaurantErrorCode.PLACES_PROVIDER_FAILED); + } + if (result == null) { + throw new BusinessException(RestaurantErrorCode.PLACES_PROVIDER_FAILED); + } + if (result instanceof Failure failure) { + if (failure.kind() == FailureKind.QUOTA_EXCEEDED) { + transactions.blockSearchForQuota(); + } + throw providerFailure(failure); + } + return result; + } + + private Long requireAdmin() { + var actor = currentActorProvider.currentActor(); + if (actor.type() != ActorType.ADMIN || actor.subjectId() < 1) { + throw new BusinessException(CommonErrorCode.FORBIDDEN); + } + return actor.subjectId(); + } + + private void validatePreviewInput(RestaurantLocationPreviewCommand input) { + if (input == null || invalidRequired(input.name(), 100) || invalidRequired(input.localName(), 100) + || invalidRequired(input.address(), 255) + || (input.geocodingAddress() != null && invalidRequired(input.geocodingAddress(), 255))) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } + + private boolean invalidRequired(String value, int maxLength) { + return value == null || value.length() > maxLength + || value.codePoints().allMatch(codePoint -> Character.isWhitespace(codePoint) + || Character.isSpaceChar(codePoint)); + } + + private RestaurantPlacesCandidateInfo toInfo(PlacesCandidate candidate, PlacesSelectionToken.Issued issued) { + return new RestaurantPlacesCandidateInfo(candidate.displayName(), candidate.address(), + candidate.latitude(), candidate.longitude(), candidate.countryCode(), candidate.administrativeArea(), + candidate.types(), candidate.businessStatus(), candidate.attributions().stream() + .map(attribution -> new Attribution(attribution.displayName(), attribution.uri())).toList(), + candidate.googleMapsUri(), issued.token(), issued.expiresAt()); + } + + private void requireConfigured() { + if (!providerProperties.enabled() || !tokens.isConfigured() || !jobProperties.isConfigured()) { + throw new BusinessException(RestaurantErrorCode.PLACES_UNAVAILABLE); + } + } + + private BusinessException providerFailure(Failure failure) { + return switch (failure.kind()) { + case QUOTA_EXCEEDED, CAPACITY_EXCEEDED -> + new BusinessException(RestaurantErrorCode.PLACES_BUDGET_EXHAUSTED); + case DISABLED, CONFIGURATION_ERROR -> + new BusinessException(RestaurantErrorCode.PLACES_UNAVAILABLE); + default -> new BusinessException(RestaurantErrorCode.PLACES_PROVIDER_FAILED); + }; + } + + public record CreationSelection(String placeId, Long adminId) { + @Override + public String toString() { + return "CreationSelection[redacted]"; + } + } + + private static void requireNoTransaction() { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("Places provider calls must run outside a transaction"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/PlacesLocationTransactions.java b/src/main/java/org/sopt/hashi/restaurant/service/PlacesLocationTransactions.java new file mode 100644 index 00000000..bfa67b37 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/PlacesLocationTransactions.java @@ -0,0 +1,135 @@ +package org.sopt.hashi.restaurant.service; + +import java.time.LocalDateTime; +import java.util.Set; +import java.util.UUID; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.GeocodingBudgetRepository; +import org.sopt.hashi.restaurant.domain.PlacesBudget.Operation; +import org.sopt.hashi.restaurant.domain.PlacesBudgetRepository; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantLocation; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantLocationStatus; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.restaurant.internal.map.places.PlacesProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** Places HTTP 전후의 짧은 DB 단계. 잠금 순서는 restaurant -> job -> operation budget이다. */ +@Service +public class PlacesLocationTransactions { + private static final Set SELECTABLE = Set.of( + RestaurantLocationStatus.READY, RestaurantLocationStatus.RETRY_WAIT, + RestaurantLocationStatus.REVIEW_REQUIRED, RestaurantLocationStatus.FAILED); + private final RestaurantRepository restaurants; + private final RestaurantLocationJobRepository jobs; + private final PlacesBudgetRepository budgets; + private final GeocodingBudgetRepository databaseClock; + + public PlacesLocationTransactions(RestaurantRepository restaurants, RestaurantLocationJobRepository jobs, + PlacesBudgetRepository budgets, GeocodingBudgetRepository databaseClock) { + this.restaurants = restaurants; + this.jobs = jobs; + this.budgets = budgets; + this.databaseClock = databaseClock; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public SearchContext prepareSearch(Long restaurantId, long expectedAddressRevision) { + Restaurant restaurant = activeForUpdate(restaurantId); + RestaurantLocation location = requireSelectable(restaurant, expectedAddressRevision); + String query = (restaurant.getLocalName() + " " + restaurant.geocodingAddressForResolution()).strip(); + if (query.isEmpty() || query.codePointCount(0, query.length()) > PlacesProvider.MAX_SEARCH_QUERY_LENGTH) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + reserveSearchBudget(); + return new SearchContext(restaurant.getId(), location.getAddressRevision(), location.getRequestId(), + query); + } + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) + public void requireCurrent(SearchContext context) { + Restaurant restaurant = restaurants.findByIdAndDeletedFalse(context.restaurantId()) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.PLACE_SELECTION_CONFLICT)); + RestaurantLocation location = restaurant.getLocation(); + if (location == null || !SELECTABLE.contains(location.getStatus()) + || location.getAddressRevision() != context.addressRevision() + || !location.getRequestId().equals(context.requestId())) { + throw new BusinessException(RestaurantErrorCode.PLACE_SELECTION_CONFLICT); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public Restaurant select(Long restaurantId, long expectedAddressRevision, UUID expectedRequestId, + String googlePlaceId, Long adminId) { + Restaurant restaurant = activeForUpdate(restaurantId); + RestaurantLocation location = requireSelectable(restaurant, expectedAddressRevision); + if (!location.getRequestId().equals(expectedRequestId)) { + throw new BusinessException(RestaurantErrorCode.PLACE_SELECTION_CONFLICT); + } + LocalDateTime now = now(); + jobs.findActiveForUpdate(restaurantId).forEach(job -> job.supersede(now)); + restaurant.selectPlaceForLocation(); + restaurant.confirmPlaceSelection(adminId, now); + jobs.save(RestaurantLocationJob.pendingDetails(restaurant, googlePlaceId, now)); + return restaurant; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public void reservePreviewSearch(String query) { + if (query == null || query.isBlank() + || query.codePointCount(0, query.length()) > PlacesProvider.MAX_SEARCH_QUERY_LENGTH) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + reserveSearchBudget(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public void blockSearchForQuota() { + var budget = budgets.findByOperationForUpdate(Operation.SEARCH).orElse(null); + if (budget != null) { + budget.blockUntil(now().plusMinutes(1)); + } + } + + private void reserveSearchBudget() { + var budget = budgets.findByOperationForUpdate(Operation.SEARCH).orElse(null); + // Read after the budget lock: a waiter may have crossed the minute/day boundary. + if (budget == null || !budget.reserve(now())) { + throw new BusinessException(RestaurantErrorCode.PLACES_BUDGET_EXHAUSTED); + } + } + + private Restaurant activeForUpdate(Long restaurantId) { + return restaurants.findByIdForUpdate(restaurantId) + .filter(restaurant -> !restaurant.isDeleted()) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND)); + } + + private RestaurantLocation requireSelectable(Restaurant restaurant, long expectedAddressRevision) { + RestaurantLocation location = restaurant.getLocation(); + if (expectedAddressRevision < 1 || location == null + || location.getAddressRevision() != expectedAddressRevision + || !SELECTABLE.contains(location.getStatus())) { + throw new BusinessException(RestaurantErrorCode.PLACE_SELECTION_CONFLICT); + } + return location; + } + + private LocalDateTime now() { + return LocalDateTime.parse(databaseClock.databaseUtcTime()); + } + + public record SearchContext(Long restaurantId, long addressRevision, UUID requestId, String query) { + @Override + public String toString() { + return "PlacesSearchContext[restaurantId=" + restaurantId + ",addressRevision=" + addressRevision + "]"; + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantLocationService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantLocationService.java new file mode 100644 index 00000000..fce1047c --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantLocationService.java @@ -0,0 +1,188 @@ +package org.sopt.hashi.restaurant.service; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Set; +import org.sopt.hashi.restaurant.RestaurantLocationInfo; +import org.sopt.hashi.restaurant.RestaurantLocationReviewInfo; +import org.sopt.hashi.restaurant.RestaurantLocationReviewPage; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantLocation; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantLocationStatus; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.domain.RestaurantRepository.RestaurantLocationReviewProjection; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class RestaurantLocationService { + private static final int MAX_REVIEW_PAGE_SIZE = 100; + private static final Set REVIEW_STATUSES = Set.of( + "UNRESOLVED", "PENDING", "READY", "RETRY_WAIT", "REVIEW_REQUIRED", "FAILED"); + private static final Set LOCATION_SOURCES = Set.of("GOOGLE_GEOCODING", "GOOGLE_PLACES", "ADMIN"); + private final RestaurantRepository restaurants; + private final RestaurantLocationJobRepository jobs; + private final Clock clock; + + public RestaurantLocationService(RestaurantRepository restaurants, RestaurantLocationJobRepository jobs, + @Qualifier("japanClock") Clock clock) { + this.restaurants = restaurants; + this.jobs = jobs; + this.clock = clock; + } + + /** 호출자는 READ_COMMITTED transaction에서 식당을 먼저 잠근다. 신규 식당은 아직 외부에서 보이지 않는다. */ + @Transactional(propagation = Propagation.MANDATORY) + public void enqueue(Restaurant restaurant) { + LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + supersede(restaurant.getId(), now); + restaurant.requestLocationResolution(); + jobs.save(RestaurantLocationJob.pending(restaurant, now)); + } + + /** 등록 전 선택은 식당 생성 transaction에 함께 저장한다. Google 상세 조회는 worker가 수행한다. */ + @Transactional(propagation = Propagation.MANDATORY) + public void enqueueSelectedPlace(Restaurant restaurant, String placeId, Long adminId) { + LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + restaurant.requestLocationResolution(); + restaurant.confirmPlaceSelection(adminId, now); + jobs.save(RestaurantLocationJob.pendingDetails(restaurant, placeId, now)); + } + + @Transactional(propagation = Propagation.MANDATORY) + public void cancel(Restaurant restaurant) { + supersede(restaurant.getId(), LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC)); + } + + @Transactional(readOnly = true) + public RestaurantLocationInfo get(Long restaurantId) { + return info(restaurants.findByIdAndDeletedFalse(restaurantId) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND))); + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public RestaurantLocationReviewPage findReviews(String status, String source, int page, int size) { + validateReviewQuery(status, source, page, size); + Page rows = restaurants.findLocationReviews( + status, source, PageRequest.of(page, size)); + return new RestaurantLocationReviewPage( + rows.getContent().stream().map(this::toReviewInfo).toList(), + rows.getNumber(), + rows.getSize(), + rows.getTotalElements(), + rows.getTotalPages()); + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public RestaurantLocationInfo retry(Long restaurantId, long expectedRevision) { + if (expectedRevision < 0) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + Restaurant restaurant = restaurants.findByIdForUpdate(restaurantId) + .filter(value -> !value.isDeleted()) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND)); + RestaurantLocation location = restaurant.getLocation(); + long revision = location == null ? 0 : location.getAddressRevision(); + if (revision != expectedRevision || (location != null && location.getStatus() == RestaurantLocationStatus.READY)) { + throw new BusinessException(RestaurantErrorCode.LOCATION_RETRY_CONFLICT); + } + if (location == null) { + enqueue(restaurant); + return info(restaurant); + } + RestaurantLocationJob current = jobs.findCurrentForUpdate(restaurantId, location.getRequestId()) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.LOCATION_RETRY_CONFLICT)); + if (!current.isCurrent(restaurant)) { + throw new BusinessException(RestaurantErrorCode.LOCATION_RETRY_CONFLICT); + } + if (location.getStatus() != RestaurantLocationStatus.PENDING) { + LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + supersede(restaurant.getId(), now); + restaurant.requestLocationResolution(); + RestaurantLocationJob retry = current.getOperation() == RestaurantLocationJob.Operation.PLACE_DETAILS + ? RestaurantLocationJob.pendingDetails(restaurant, requirePlaceId(current), now) + : RestaurantLocationJob.pending(restaurant, now); + jobs.save(retry); + } + return info(restaurant); + } + + private RestaurantLocationInfo info(Restaurant restaurant) { + RestaurantLocation location = restaurant.getLocation(); + if (location == null) { + return new RestaurantLocationInfo(restaurant.getId(), "UNRESOLVED", 0, null, null, + null, 0, null, null, true); + } + RestaurantLocationJob job = jobs.findByRestaurantIdAndRequestId(restaurant.getId(), location.getRequestId()) + .orElse(null); + boolean canRetry = location.getStatus() != RestaurantLocationStatus.PENDING + && location.getStatus() != RestaurantLocationStatus.READY; + return new RestaurantLocationInfo(restaurant.getId(), location.getStatus().name(), location.getAddressRevision(), + location.getSource() == null ? null : location.getSource().name(), + job == null ? null : job.getOperation().name(), + location.getValidUntil() == null ? null : location.getValidUntil().toInstant(ZoneOffset.UTC), + job == null ? 0 : job.getAttempt(), + location.getNextAttemptAt() == null ? null : location.getNextAttemptAt().toInstant(ZoneOffset.UTC), + job == null ? null : job.getFailureCode(), canRetry, + location.getConfirmationMethod() == null ? null : location.getConfirmationMethod().name(), + location.getConfirmedByAdminId(), + location.getConfirmedAt() == null ? null : location.getConfirmedAt().toInstant(ZoneOffset.UTC), + location.getConfirmedRevision()); + } + + private RestaurantLocationReviewInfo toReviewInfo(RestaurantLocationReviewProjection row) { + boolean canRetry = !"PENDING".equals(row.getLocationStatus()) && !"READY".equals(row.getLocationStatus()); + return new RestaurantLocationReviewInfo( + row.getRestaurantId(), + row.getRestaurantName(), + row.getAddress(), + row.getGeocodingAddress(), + row.getLocationStatus(), + row.getLocationSource(), + row.getVerificationMode(), + row.getAddressRevision(), + parseUtc(row.getValidUntilUtc()), + row.getAttempt(), + parseUtc(row.getNextAttemptAtUtc()), + row.getFailureCode(), + canRetry, + row.getConfirmedByAdminId()); + } + + private void validateReviewQuery(String status, String source, int page, int size) { + if (status == null || !REVIEW_STATUSES.contains(status) + || (source != null && !LOCATION_SOURCES.contains(source)) + || page < 0 + || (long) page * size > Integer.MAX_VALUE + || size < 1 || size > MAX_REVIEW_PAGE_SIZE) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } + + private Instant parseUtc(String value) { + return value == null ? null : LocalDateTime.parse(value).toInstant(ZoneOffset.UTC); + } + + private void supersede(Long restaurantId, LocalDateTime now) { + jobs.findActiveForUpdate(restaurantId).forEach(job -> job.supersede(now)); + } + + private String requirePlaceId(RestaurantLocationJob job) { + if (job.getGooglePlaceId() == null) { + throw new BusinessException(RestaurantErrorCode.LOCATION_RETRY_CONFLICT); + } + return job.getGooglePlaceId(); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapPageReader.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapPageReader.java new file mode 100644 index 00000000..1fe27a24 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapPageReader.java @@ -0,0 +1,72 @@ +package org.sopt.hashi.restaurant.service; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.HashMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageResponse.MapCardResponse; +import org.sopt.hashi.restaurant.internal.map.MapQuerySession; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; + +/** 한 페이지의 조건 재검사와 카드 구성을 짧은 DB 읽기로 묶는다. Redis는 호출하지 않는다. */ +@Service +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class RestaurantMapPageReader { + private static final int PAGE_SIZE = 10; + private static final int SCAN_BATCH_SIZE = 32; + private final RestaurantMapService mapService; + private final RestaurantService restaurants; + private final Clock clock; + + public RestaurantMapPageReader(RestaurantMapService mapService, RestaurantService restaurants, + @Qualifier("japanClock") Clock clock) { + this.mapService = mapService; + this.restaurants = restaurants; + this.clock = clock; + } + + public Page read(MapQuerySession session, RestaurantMapSort sort, int start) { + List ordered = session.ordered(sort); + var infos = new HashMap(); + List selected = new ArrayList<>(); + int nextPosition = start; + boolean hasNext = false; + for (int batchStart = start; batchStart < ordered.size() && !hasNext; batchStart += SCAN_BATCH_SIZE) { + int batchEnd = Math.min(batchStart + SCAN_BATCH_SIZE, ordered.size()); + var batchIds = ordered.subList(batchStart, batchEnd).stream() + .map(RestaurantMapCandidate::restaurantId).toList(); + var matching = mapService.findMatchingCandidates(session.criteria(), batchIds).stream() + .map(RestaurantMapCandidate::restaurantId).toList(); + var batchInfos = mapService.findActiveMapInfos(matching).stream() + .filter(info -> info.location() != null && clock.instant().isBefore(info.location().validUntil())) + .collect(Collectors.toMap(RestaurantMapInfo::restaurantId, Function.identity())); + for (int position = batchStart; position < batchEnd; position++) { + var candidate = ordered.get(position); + var info = batchInfos.get(candidate.restaurantId()); + if (info != null && selected.size() == PAGE_SIZE) { + hasNext = true; + // 11번째 유효 후보는 아직 소비하지 않는다. + nextPosition = position; + break; + } + nextPosition = position + 1; + if (info != null) { + selected.add(candidate); + infos.put(candidate.restaurantId(), info); + } + } + } + return new Page(restaurants.findMapCards(selected, infos), hasNext, nextPosition); + } + + public record Page(List content, boolean hasNext, int nextPosition) { + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapPageService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapPageService.java new file mode 100644 index 00000000..26a13dd9 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapPageService.java @@ -0,0 +1,141 @@ +package org.sopt.hashi.restaurant.service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.Semaphore; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageRequest; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageResponse.QueryResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageResponse.SearchResultResponse; +import org.sopt.hashi.restaurant.internal.map.MapCursorCodec; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics.Operation; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics.Reason; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics.Stage; +import org.sopt.hashi.restaurant.internal.map.MapQuerySession; +import org.sopt.hashi.restaurant.internal.map.MapSessionId; +import org.sopt.hashi.restaurant.internal.map.MapSessionLimits; +import org.sopt.hashi.restaurant.internal.map.RedisMapSessionStore; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** DB 단계와 Redis 단계를 분리한다. 상위 transaction이 Redis 대기까지 연결을 점유하지 못하게 한다. */ +@Service +@Transactional(propagation = Propagation.NEVER) +public class RestaurantMapPageService { + private final RestaurantMapService mapService; + private final RestaurantMapPageReader reader; + private final RedisMapSessionStore store; + private final MapCursorCodec cursors; + private final MapSessionLimits limits; + private final MapCapacityMetrics metrics; + private final Semaphore activeRequests; + + public RestaurantMapPageService(RestaurantMapService mapService, RestaurantMapPageReader reader, + RedisMapSessionStore store, MapCursorCodec cursors, + MapSessionLimits limits, MapCapacityMetrics metrics) { + this.mapService = mapService; + this.reader = reader; + this.store = store; + this.cursors = cursors; + this.limits = limits; + this.metrics = metrics; + this.activeRequests = new Semaphore(Math.max(1, Math.min(16, limits.getConcurrentRequests()))); + } + + public RestaurantMapPageResponse getPage(RestaurantMapPageRequest request) { + return getPage(request, "internal"); + } + + public RestaurantMapPageResponse getPage(RestaurantMapPageRequest request, String remoteAddress) { + cursors.requireConfigured(); + limits.validate(); + if (!activeRequests.tryAcquire()) { + metrics.rejected(Reason.CONCURRENT_REQUESTS); + throw new BusinessException(RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + } + try { + return readPage(request, remoteAddress); + } finally { + activeRequests.release(); + } + } + + private RestaurantMapPageResponse readPage(RestaurantMapPageRequest request, String remoteAddress) { + Operation operation = operation(request); + MapSessionId id; + MapQuerySession session; + RestaurantMapSort sort = request.sort(); + int start = 0; + if (request.cursor() != null) { + var cursor = cursors.decode(request.cursor()); + var sessionId = cursor.session(); + id = sessionId; + sort = cursor.sort(); + start = cursor.position(); + metrics.record(operation, Stage.ADMIT, + () -> store.admit(cursors.callerKey(remoteAddress), false)); + session = metrics.record(operation, Stage.LOAD_SESSION, () -> store.find(sessionId)); + } else if (request.querySessionId() != null) { + var sessionId = MapSessionId.parse(request.querySessionId()); + id = sessionId; + metrics.record(operation, Stage.ADMIT, + () -> store.admit(cursors.callerKey(remoteAddress), false)); + session = metrics.record(operation, Stage.LOAD_SESSION, () -> store.find(sessionId)); + } else { + var startedAt = metrics.record(operation, Stage.ADMIT, + () -> store.admit(cursors.callerKey(remoteAddress), true)); + RestaurantMapService.CandidateSnapshot snapshot; + try { + snapshot = metrics.record(operation, Stage.CANDIDATES, + () -> mapService.findCandidates(request.criteria(), limits.candidateCapacity())); + } catch (BusinessException exception) { + if (exception.getErrorCode() == RestaurantErrorCode.MAP_CAPACITY_EXCEEDED) { + metrics.rejected(Reason.CANDIDATE_COUNT); + } + throw exception; + } + var recommendation = new ArrayList<>(snapshot.candidates()); + Collections.shuffle(recommendation); + Instant hardExpiresAt = startedAt.plus(limits.getMaxLifetime()); + if (snapshot.resultExtent() != null && snapshot.resultExtent().earliestValidUntil() != null + && snapshot.resultExtent().earliestValidUntil().isBefore(hardExpiresAt)) { + hardExpiresAt = snapshot.resultExtent().earliestValidUntil(); + } + var createdSession = new MapQuerySession( + MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), request.criteria(), + recommendation, snapshot.resultExtent(), snapshot.rankingAsOf(), hardExpiresAt); + session = createdSession; + id = metrics.record(operation, Stage.SAVE, () -> store.save(createdSession)); + } + if (start > session.candidates().size()) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + MapQuerySession pageSession = session; + MapSessionId pageSessionId = id; + RestaurantMapSort pageSort = sort; + int pageStart = start; + var page = metrics.record(operation, Stage.READ_PAGE, + () -> reader.read(pageSession, pageSort, pageStart)); + var expiresAt = metrics.record(operation, Stage.TOUCH, + () -> store.touch(pageSessionId, pageSession)); + return new RestaurantMapPageResponse(page.content(), + page.hasNext() ? cursors.encode(id, sort, page.nextPosition()) : null, page.hasNext(), + id.value(), expiresAt, session.rankingAsOf(), SearchResultResponse.from(session.searchResult()), + QueryResponse.from(session.criteria(), sort)); + } + + private static Operation operation(RestaurantMapPageRequest request) { + if (request.cursor() != null) { + return Operation.NEXT_PAGE; + } + return request.querySessionId() != null ? Operation.SORT_CHANGE : Operation.NEW_QUERY; + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapService.java new file mode 100644 index 00000000..2a7ff802 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantMapService.java @@ -0,0 +1,184 @@ +package org.sopt.hashi.restaurant.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapRegionSummary; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapQueryRepository; +import org.sopt.hashi.restaurant.dto.RestaurantMapLocationResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapRegionsResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapRegionsResponse.BoundsResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapRegionsResponse.PositionResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapRegionsResponse.QueryLimitsResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapRegionsResponse.RegionResponse; +import org.sopt.hashi.restaurant.internal.map.MapQueryProperties; +import org.sopt.hashi.restaurant.internal.map.MapQueryFailureLogger; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +public class RestaurantMapService { + + private static final int ID_BATCH_SIZE = 500; + + private final RestaurantMapQueryRepository repository; + private final MapQueryProperties properties; + private final Clock clock; + + public RestaurantMapService(RestaurantMapQueryRepository repository, MapQueryProperties properties, + @Qualifier("japanClock") Clock clock) { + this.repository = repository; + this.properties = properties; + this.clock = clock; + } + + public RestaurantMapRegionsResponse getRegions() { + var configuration = properties.requireConfiguration(); + List regions = read(() -> repository.findActiveRegions(clock.instant())); + if (regions.isEmpty()) { + throw new BusinessException(RestaurantErrorCode.MAP_CONFIGURATION_UNAVAILABLE); + } + for (MapRegionSummary region : regions) { + validateRegionConfiguration(region.bounds(), configuration.supportedBounds()); + if (region.outsideBoundsCount() > 0) { + log.warn("Map region mapping outside bounds: regionId={}, count={}", + region.id(), region.outsideBoundsCount()); + } + } + return new RestaurantMapRegionsResponse(BoundsResponse.from(configuration.initialBounds()), + new QueryLimitsResponse(BoundsResponse.from(configuration.supportedBounds()), 1, 1), + regions.stream().map(region -> new RegionResponse(region.id(), region.name(), + region.restaurantCount(), new PositionResponse(region.latitude(), region.longitude()), + BoundsResponse.from(region.bounds()), region.displayOrder())).toList()); + } + + public RestaurantMapLocationResponse getLocation(Long restaurantId) { + List infos = findActiveMapInfos(List.of(restaurantId)); + RestaurantMapInfo info = infos.stream().findFirst() + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND)); + if (info.location() == null) { + throw new BusinessException(RestaurantErrorCode.MAP_LOCATION_UNAVAILABLE); + } + return new RestaurantMapLocationResponse(info.restaurantId(), info.location()); + } + + /** capacity는 후속 세션 담당자가 측정해 전달한다. 초과 후보 목록은 반환하지 않는다. */ + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public CandidateSnapshot findCandidates(MapSearchCriteria criteria, int capacity) { + if (capacity < 1 || capacity == Integer.MAX_VALUE) { + throw new IllegalArgumentException("후보 상한은 1 이상이고 상한 + 1을 조회할 수 있어야 합니다"); + } + validateCriteria(criteria); + Instant rankingAsOf = clock.instant(); + List candidates = read( + () -> repository.findCandidates(criteria, rankingAsOf, capacity + 1)); + if (candidates.size() > capacity) { + throw new BusinessException(RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + } + MapSearchResultExtent resultExtent = criteria.keyword() == null ? null + : read(() -> repository.findResultExtent(criteria, rankingAsOf)); + return new CandidateSnapshot(candidates, rankingAsOf, resultExtent); + } + + /** 현재 조건을 재검사한 ID만 첫 요청 순서로 반환한다. 새 순위 값은 세션의 고정 순위를 대체하지 않는다. */ + public List findMatchingCandidates(MapSearchCriteria criteria, Collection restaurantIds) { + List ids = normalizedIds(restaurantIds); + if (ids.isEmpty()) { + return List.of(); + } + validateCriteria(criteria); + Instant now = clock.instant(); + return readBatches(ids, batch -> repository.findMatchingCandidates(criteria, now, batch), + RestaurantMapCandidate::restaurantId); + } + + /** 지도 설정과 독립적이다. 위치가 없어도 공개 식당을 반환해야 컬렉션 목록을 유지할 수 있다. */ + public List findActiveMapInfos(Collection restaurantIds) { + List ids = normalizedIds(restaurantIds); + Instant now = clock.instant(); + return readBatches(ids, batch -> repository.findActiveMapInfos(batch, now), RestaurantMapInfo::restaurantId); + } + + public record CandidateSnapshot(List candidates, Instant rankingAsOf, + MapSearchResultExtent resultExtent) { + public CandidateSnapshot { + candidates = List.copyOf(candidates); + } + + public CandidateSnapshot(List candidates, Instant rankingAsOf) { + this(candidates, rankingAsOf, null); + } + } + + private void validateCriteria(MapSearchCriteria criteria) { + if (criteria == null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + var configuration = properties.requireConfiguration(); + criteria.bounds().validateQueryWithin(configuration.supportedBounds()); + if (criteria.mapRegionId() != null) { + MapQueryBounds region = read(() -> repository.findActiveRegionBounds(criteria.mapRegionId())) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.MAP_REGION_INVALID)); + validateRegionConfiguration(region, configuration.supportedBounds()); + } + } + + private void validateRegionConfiguration(MapQueryBounds bounds, MapQueryBounds supported) { + try { + bounds.validateQueryWithin(supported); + } catch (BusinessException exception) { + throw new BusinessException(RestaurantErrorCode.MAP_CONFIGURATION_UNAVAILABLE); + } + } + + private static List normalizedIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + if (ids.stream().anyMatch(id -> id == null || id <= 0)) { + throw new IllegalArgumentException("식당 ID는 양수여야 합니다"); + } + return List.copyOf(new LinkedHashSet<>(ids)); + } + + private List readBatches(List ids, Function, List> fetch, Function id) { + List result = new ArrayList<>(); + for (int start = 0; start < ids.size(); start += ID_BATCH_SIZE) { + List batch = ids.subList(start, Math.min(start + ID_BATCH_SIZE, ids.size())); + result.addAll(read(() -> fetch.apply(batch))); + } + Map byId = result.stream().collect(Collectors.toMap(id, Function.identity())); + return ids.stream().map(byId::get).filter(Objects::nonNull).toList(); + } + + private T read(Supplier query) { + try { + return query.get(); + } catch (DataAccessException exception) { + MapQueryFailureLogger.warn(exception); + throw new BusinessException(RestaurantErrorCode.MAP_QUERY_UNAVAILABLE, exception); + } + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantPortImpl.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantPortImpl.java index f094a857..a0e2ba87 100644 --- a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantPortImpl.java +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantPortImpl.java @@ -4,22 +4,32 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; +import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import org.sopt.hashi.media.ImageReference; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantInfo; +import org.sopt.hashi.restaurant.RestaurantLocationPreviewCommand; +import org.sopt.hashi.restaurant.RestaurantPlacesCandidateInfo; import org.sopt.hashi.restaurant.RestaurantCardInfo; import org.sopt.hashi.restaurant.RestaurantDetailInfo; import org.sopt.hashi.restaurant.RestaurantInfo; +import org.sopt.hashi.restaurant.RestaurantMapInfo; import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.restaurant.RestaurantLocationInfo; +import org.sopt.hashi.restaurant.RestaurantLocationReviewPage; +import org.sopt.hashi.restaurant.RestaurantPlacesSearchInfo; import org.sopt.hashi.restaurant.domain.Restaurant; import org.sopt.hashi.restaurant.domain.RestaurantImage; import org.sopt.hashi.restaurant.domain.RestaurantRepository; import org.sopt.hashi.shared.storage.FileStorage; import org.springframework.stereotype.Component; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; /** @@ -33,12 +43,36 @@ class RestaurantPortImpl implements RestaurantPort { private final RestaurantRepository restaurantRepository; private final RestaurantService restaurantService; private final FileStorage fileStorage; + private final RestaurantLocationService locationService; + private final RestaurantMapService restaurantMapService; + private final PlacesLocationService placesLocationService; + + RestaurantPortImpl(RestaurantRepository restaurantRepository, RestaurantService restaurantService, + FileStorage fileStorage, RestaurantLocationService locationService, + RestaurantMapService restaurantMapService) { + this.restaurantRepository = restaurantRepository; + this.restaurantService = restaurantService; + this.fileStorage = fileStorage; + this.locationService = locationService; + this.restaurantMapService = restaurantMapService; + this.placesLocationService = null; + } + @Autowired RestaurantPortImpl(RestaurantRepository restaurantRepository, RestaurantService restaurantService, - FileStorage fileStorage) { + FileStorage fileStorage, RestaurantLocationService locationService, + RestaurantMapService restaurantMapService, + ObjectProvider placesLocationServices) { this.restaurantRepository = restaurantRepository; this.restaurantService = restaurantService; this.fileStorage = fileStorage; + this.locationService = locationService; + this.restaurantMapService = restaurantMapService; + this.placesLocationService = placesLocationServices.getIfAvailable(); + } + + private PlacesLocationService requirePlacesLocationService() { + return Objects.requireNonNull(placesLocationService, "PlacesLocationService is unavailable"); } @Override @@ -114,6 +148,11 @@ public List findActiveCards(Collection restaurantIds) .toList(); } + @Override + public List findActiveMapInfos(Collection restaurantIds) { + return restaurantMapService.findActiveMapInfos(restaurantIds); + } + @Override @Transactional public void increaseReviewStatistics(Long restaurantId, int rating) { @@ -140,23 +179,68 @@ public void updateReviewRatingStatistics(Long restaurantId, int oldRating, int n } @Override - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { return restaurantService.createByAdmin(command); } @Override - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) + public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command, String locationSelectionToken) { + var selection = requirePlacesLocationService().verifyForCreate( + RestaurantLocationPreviewCommand.from(command), locationSelectionToken); + return restaurantService.createByAdmin(command, selection); + } + + @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public List previewLocationByAdmin(RestaurantLocationPreviewCommand command) { + return requirePlacesLocationService().preview(command); + } + + @Override + @Transactional(isolation = Isolation.READ_COMMITTED) public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantCommand command) { return restaurantService.updateByAdmin(restaurantId, command); } @Override - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public void deleteByAdmin(Long restaurantId) { restaurantService.deleteByAdmin(restaurantId); } + @Override + public RestaurantLocationInfo getLocationByAdmin(Long restaurantId) { + return locationService.get(restaurantId); + } + + @Override + @Transactional(isolation = Isolation.READ_COMMITTED) + public RestaurantLocationInfo retryLocationByAdmin(Long restaurantId, long expectedAddressRevision) { + return locationService.retry(restaurantId, expectedAddressRevision); + } + + @Override + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public RestaurantLocationReviewPage findLocationReviewsByAdmin( + String status, String source, int page, int size) { + return locationService.findReviews(status, source, page, size); + } + + @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public RestaurantPlacesSearchInfo searchLocationPlacesByAdmin(Long restaurantId, long expectedAddressRevision) { + return requirePlacesLocationService().search(restaurantId, expectedAddressRevision); + } + + @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public RestaurantLocationInfo selectLocationPlaceByAdmin( + Long restaurantId, long expectedAddressRevision, String selectionToken) { + return requirePlacesLocationService().select(restaurantId, expectedAddressRevision, selectionToken); + } + private RestaurantInfo toInfo(Restaurant restaurant) { return new RestaurantInfo( restaurant.getId(), diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java index ff43882c..b95e498b 100644 --- a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java @@ -33,6 +33,9 @@ import org.sopt.hashi.media.MediaImageSelection; import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.restaurant.AdminRestaurantCommand; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageResponse.MapCardResponse; import org.sopt.hashi.restaurant.AdminRestaurantCommand.BusinessHourCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; @@ -79,6 +82,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -110,17 +114,20 @@ public class RestaurantService { private final FileStorage fileStorage; private final MediaPort mediaPort; private final Clock japanClock; + private final RestaurantLocationService locationService; public RestaurantService( RestaurantRepository restaurantRepository, FileStorage fileStorage, MediaPort mediaPort, - @Qualifier("japanClock") Clock japanClock + @Qualifier("japanClock") Clock japanClock, + RestaurantLocationService locationService ) { this.restaurantRepository = restaurantRepository; this.fileStorage = fileStorage; this.mediaPort = mediaPort; this.japanClock = japanClock; + this.locationService = locationService; } public RestaurantListResponse getRestaurants( @@ -175,6 +182,27 @@ public RestaurantListResponse getRestaurants( ); } + /** 지도 페이지도 기존 카드 변환을 공유하되 순위 값은 최초 세션 값을 사용한다. */ + public List findMapCards(List candidates, + Map mapInfos) { + if (candidates.isEmpty()) { + return List.of(); + } + List restaurants = restaurantRepository.findAllByIdInAndDeletedFalse( + candidates.stream().map(RestaurantMapCandidate::restaurantId).toList()); + var byId = restaurants.stream().collect(Collectors.toMap(Restaurant::getId, Function.identity())); + LocalDate businessDate = LocalDate.now(japanClock); + var hours = findBusinessHours(restaurants, businessDate.getDayOfWeek()); + var media = loadRestaurantProjection(restaurants, MediaImageRole.RESTAURANT_CARD, 3); + return candidates.stream().map(candidate -> { + Restaurant restaurant = byId.get(candidate.restaurantId()); + return MapCardResponse.from(toSummaryResponse(restaurant, businessDate, hours.get(restaurant.getId()), media), + candidate, restaurant.getPlaceType().value(), new PriceRangeResponse( + restaurant.getPriceCurrency().value(), toWholeAmount(restaurant.getMinPrice()), + toWholeAmount(restaurant.getMaxPrice())), mapInfos.get(restaurant.getId()).location()); + }).toList(); + } + public RestaurantSearchSuggestionResponse getSearchSuggestions(String keyword, Integer size) { String normalizedKeyword = normalizeKeyword(keyword); int searchSize = normalizeSearchSize(size); @@ -353,8 +381,14 @@ public List findActiveDetails(Collection restaurantI } /** 어드민 식당 등록 — 필수 값 형식 검증은 admin 요청 DTO가, 도메인 값 해석·저장은 여기가 담당한다. */ - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { + return createByAdmin(command, null); + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command, + PlacesLocationService.CreationSelection selection) { validateRequiredForCreate(command); List images = toImagesForCreate(command); @@ -379,6 +413,7 @@ public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { command.summary(), command.description(), command.address(), + command.geocodingAddress(), command.area(), toGenre(command.genre()), command.foodCategory(), @@ -397,6 +432,11 @@ public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { reconcileMediaBindings(claims, List.of()); Restaurant saved = restaurantRepository.save(restaurant); + if (selection == null) { + locationService.enqueue(saved); + } else { + locationService.enqueueSelectedPlace(saved, selection.placeId(), selection.adminId()); + } restaurantRepository.flush(); // 생성된 id는 응답 body에만 있어 로그로 남겨야 추적 가능하다 (adminId는 MDC) log.info("어드민 식당 등록. restaurantId={}", saved.getId()); @@ -404,7 +444,7 @@ public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { } /** 어드민 식당 수정 — 부분 수정(PATCH). null 필드는 유지하고, 컬렉션은 전체 교체한다. */ - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantCommand command) { validateImageFieldsForUpdate(command); validateNonBlankIfPresent(command.localName()); @@ -417,6 +457,11 @@ public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantComma validateExclusiveImageCollections(command.imageKeys(), command.images()); Restaurant restaurant = findRestaurantForAdminUpdate(restaurantId); + String previousGeocodingInput = restaurant.geocodingAddressForResolution(); + String previousName = restaurant.getName(); + String previousLocalName = restaurant.getLocalName(); + String previousAddress = restaurant.getAddress(); + RestaurantGenre genre = command.genre() == null ? null : toGenre(command.genre()); RestaurantPlaceType placeType = command.placeType() == null ? null : toPlaceType(command.placeType()); PriceCurrency priceCurrency = command.priceCurrency() == null @@ -453,6 +498,7 @@ public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantComma command.summary(), command.description(), command.address(), + command.geocodingAddress(), command.area(), genre, command.foodCategory(), @@ -461,6 +507,14 @@ public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantComma command.minPrice(), command.maxPrice()); + boolean geocodingInputChanged = !Objects.equals( + previousGeocodingInput, restaurant.geocodingAddressForResolution()); + if ((geocodingInputChanged || !Objects.equals(previousName, restaurant.getName()) + || !Objects.equals(previousLocalName, restaurant.getLocalName()) + || !Objects.equals(previousAddress, restaurant.getAddress())) && !restaurant.isDeleted()) { + locationService.enqueue(restaurant); + } + applyImageUpdate(restaurant, imagePlan); applyMenuUpdate(restaurant, menuPlan); if (command.hashtags() != null) { @@ -478,10 +532,11 @@ public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantComma } /** 어드민 식당 삭제 — soft delete(deleted=true). 예약·리뷰가 참조하는 데이터는 보존한다. */ - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public void deleteByAdmin(Long restaurantId) { Restaurant restaurant = findRestaurantForAdminUpdate(restaurantId); restaurant.softDelete(); + locationService.cancel(restaurant); // 노출 종료 전이 — 예약·리뷰가 계속 참조하므로 언제 내려갔는지 기록이 필요하다 (adminId는 MDC) log.info("어드민 식당 삭제(soft). restaurantId={}", restaurantId); } @@ -808,6 +863,10 @@ private void validateRequiredForCreate(AdminRestaurantCommand command) { || command.images() != null || !hasExactlyOneCreateImageSource(command.imageKeys(), command.imageAssetIds()) || command.hashtags() == null || command.hashtags().isEmpty(); + if (command.geocodingAddress() != null + && Restaurant.normalizeGeocodingAddress(command.geocodingAddress()) == null) { + missingRequired = true; + } if (missingRequired) { throw new BusinessException(CommonErrorCode.INVALID_INPUT); } @@ -1311,6 +1370,7 @@ private AdminRestaurantInfo toAdminInfo(Restaurant restaurant) { restaurant.getSummary(), restaurant.getDescription(), restaurant.getAddress(), + restaurant.getGeocodingAddress(), restaurant.getArea(), restaurant.getGenre().value(), restaurant.getFoodCategory(), @@ -1334,7 +1394,9 @@ private AdminRestaurantInfo toAdminInfo(Restaurant restaurant) { .sorted(Comparator.comparing(hour -> hour.getDayOfWeek().getValue())) .map(this::toAdminBusinessHourInfo) .toList(), - restaurant.getCreatedAt()); + restaurant.getCreatedAt(), + restaurant.getLocation() == null ? "UNRESOLVED" : restaurant.getLocation().getStatus().name(), + restaurant.getLocation() == null ? 0 : restaurant.getLocation().getAddressRevision()); } private AdminRestaurantBusinessHourInfo toAdminBusinessHourInfo(RestaurantBusinessHour businessHour) { diff --git a/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapController.java b/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapController.java new file mode 100644 index 00000000..6d2e5c1f --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapController.java @@ -0,0 +1,62 @@ +package org.sopt.hashi.restaurant.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.Positive; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.dto.RestaurantMapLocationResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapRegionsResponse; +import org.sopt.hashi.restaurant.service.RestaurantMapService; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.shared.error.CommonSuccessCode; +import org.sopt.hashi.shared.response.SuccessResponse; +import org.sopt.hashi.shared.swagger.ApiException; +import org.springframework.http.HttpHeaders; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 지도 안내와 현재 위치 조회. 목록 페이지는 후속 조회 세션 API에서 제공한다. */ +@Tag(name = "지도", description = "공개 지도 조회와 조회 세션 API") +@Validated +@RestController +@RequestMapping("/api/v1/restaurants") +public class RestaurantMapController { + + private final RestaurantMapService service; + + public RestaurantMapController(RestaurantMapService service) { + this.service = service; + } + + @ApiException(value = RestaurantErrorCode.class, + codes = {"MAP_CONFIGURATION_UNAVAILABLE", "MAP_QUERY_UNAVAILABLE"}) + @Operation(summary = "지도 관광 지역 조회", description = """ + 최초 카메라 범위, 서버가 허용하는 조회 범위와 크기, 활성 관광 지역의 클러스터 위치와 + 카메라 범위를 반환한다. 식당 수가 0인 활성 지역도 포함하며 응답은 캐시하지 않는다. + """) + @GetMapping("/map/regions") + public SuccessResponse getRegions(HttpServletResponse response) { + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, service.getRegions()); + } + + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiException(value = RestaurantErrorCode.class, + codes = {"NOT_FOUND", "MAP_LOCATION_UNAVAILABLE", "MAP_QUERY_UNAVAILABLE"}) + @Operation(summary = "선택 식당 지도 위치 조회", description = """ + 선택한 공개 식당의 현재 유효한 좌표와 UTC 만료 시각을 반환한다. 삭제·비노출 식당은 404, + 식당은 존재하지만 표시 가능한 좌표가 없거나 만료됐으면 409를 반환한다. + """) + @GetMapping("/{restaurantId}/map-location") + public SuccessResponse getLocation( + @Parameter(description = "조회할 식당 ID", example = "1") + @Positive @PathVariable Long restaurantId, HttpServletResponse response) { + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, service.getLocation(restaurantId)); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapExceptionHandler.java b/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapExceptionHandler.java new file mode 100644 index 00000000..a48fc119 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapExceptionHandler.java @@ -0,0 +1,28 @@ +package org.sopt.hashi.restaurant.web; + +import jakarta.servlet.http.HttpServletRequest; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.internal.map.MapQueryFailureLogger; +import org.sopt.hashi.shared.response.ErrorResponse; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataAccessException; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.TransactionException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** Service 본문 밖의 DB/transaction 실패를 지도 조회 503 응답으로 변환한다. */ +@Order(0) +@RestControllerAdvice(assignableTypes = {RestaurantMapController.class, RestaurantMapPageController.class}) +public class RestaurantMapExceptionHandler { + + @ExceptionHandler({DataAccessException.class, TransactionException.class}) + public ResponseEntity handleQueryFailure(RuntimeException exception, HttpServletRequest request) { + MapQueryFailureLogger.warn(exception); + var code = RestaurantErrorCode.MAP_QUERY_UNAVAILABLE; + return ResponseEntity.status(code.getStatus()).cacheControl(CacheControl.noStore()) + .body(ErrorResponse.of(code, request.getRequestURI())); + } + +} diff --git a/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapPageController.java b/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapPageController.java new file mode 100644 index 00000000..764e1b93 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapPageController.java @@ -0,0 +1,105 @@ +package org.sopt.hashi.restaurant.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageRequest; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageResponse; +import org.sopt.hashi.restaurant.service.RestaurantMapPageService; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.shared.error.CommonSuccessCode; +import org.sopt.hashi.shared.response.SuccessResponse; +import org.sopt.hashi.shared.swagger.ApiException; +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "지도", description = "공개 지도 조회와 조회 세션 API") +@RestController +public class RestaurantMapPageController { + private final RestaurantMapPageService service; + + public RestaurantMapPageController(RestaurantMapPageService service) { + this.service = service; + } + + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiException(value = RestaurantErrorCode.class, codes = {"UNSUPPORTED_GENRE", "UNSUPPORTED_SORT", + "UNSUPPORTED_PLACE_TYPE", "MAP_BOUNDS_INVALID", "MAP_REGION_INVALID", "MAP_SESSION_EXPIRED", + "MAP_SESSION_UNAVAILABLE", "MAP_QUERY_UNAVAILABLE", "MAP_CAPACITY_EXCEEDED", + "MAP_CONFIGURATION_UNAVAILABLE", "MAP_RATE_LIMITED"}) + @Operation(summary = "지도 식당 목록 조회", description = """ + 요청은 세 모드 중 하나만 사용한다. 새 조회는 south/north/west/east와 선택 필터를 보내고, + 정렬 변경은 querySessionId와 sort만, 다음 페이지는 cursor만 보낸다. 전체 음식점 분류는 + placeType을 생략한다. keyword 일반 검색은 공백으로 나눈 토큰 중 하나라도 식당명·메뉴명·해시태그에 + 포함되면 일치하고, #으로 시작하면 식당명·메뉴명을 제외하고 공백 없는 단일 해시태그를 부분 검색한다. + 응답 content는 최대 10개이고 같은 항목이 목록과 핀의 기준이다. 조건에 맞는 식당이 없으면 + 오류 대신 content=[]인 200을 반환한다. keyword 조회의 searchResult는 rankingAsOf에 고정된 전체 + 검색 결과 수와 경계이며, 이후 삭제 등으로 달라져도 각 페이지의 content와 핀은 현재 상태를 재검사한다. + 410이면 마지막 성공 조회 조건으로 새 조회를 시작하고, + 429는 최대 60초 뒤 재시도한다. 503이면 기존 결과를 유지하고, 일시 장애는 같은 요청으로 재시도한다. + 비활성·설정 오류는 서버 설정 조치가 필요하다. 같은 조건에서 수용량 오류(RESTAURANT-016)가 반복되면 + 자동으로 조건을 변경하지 말고 검색 범위나 필터를 조정하도록 안내한다. + 서버 세션은 정상 조회마다 유휴 5분을 갱신하되 최초 admission부터 최대 30분까지만 유지한다. + 화면 상태를 5분 보관하는 클라이언트 정책과 서버 세션 수명은 서로 다른 책임이다. + """) + @Parameters({ + @Parameter(name = "south", in = ParameterIn.QUERY, + description = "새 조회 필수. 현재 viewport의 남쪽 위도", + schema = @Schema(type = "number", example = "35.0")), + @Parameter(name = "north", in = ParameterIn.QUERY, + description = "새 조회 필수. 현재 viewport의 북쪽 위도", + schema = @Schema(type = "number", example = "35.1")), + @Parameter(name = "west", in = ParameterIn.QUERY, + description = "새 조회 필수. 현재 viewport의 서쪽 경도", + schema = @Schema(type = "number", example = "139.0")), + @Parameter(name = "east", in = ParameterIn.QUERY, + description = "새 조회 필수. 현재 viewport의 동쪽 경도", + schema = @Schema(type = "number", example = "139.1")), + @Parameter(name = "mapRegionId", in = ParameterIn.QUERY, + description = "새 조회 선택. 활성 관광 지역 ID", + schema = @Schema(type = "integer", format = "int64", example = "1")), + @Parameter(name = "keyword", in = ParameterIn.QUERY, description = "새 조회 선택, 최대 30자. " + + "일반 검색은 공백 구분 OR 토큰으로 식당명·메뉴명·해시태그를 찾는다. " + + "#단일태그는 해시태그에서만 리터럴 부분 검색하며 # 뒤가 비거나 공백을 포함하면 400", + schema = @Schema(type = "string", example = "sushi")), + @Parameter(name = "genre", in = ParameterIn.QUERY, description = "새 조회 선택. 음식 장르", + schema = @Schema(type = "string", allowableValues = {"sushi", "noodle", "rice-bowl", + "nabe", "fried", "grill", "etc"}, example = "sushi")), + @Parameter(name = "placeType", in = ParameterIn.QUERY, + description = "새 조회 선택. 음식점 분류. 전체(ALL)는 이 파라미터를 생략", + schema = @Schema(type = "string", allowableValues = {"restaurant", "cafe", "bar"}, + example = "restaurant")), + @Parameter(name = "sort", in = ParameterIn.QUERY, + description = "새 조회에서는 선택(기본 recommend). 정렬 변경에서는 " + + "querySessionId와 함께 필수. recommend는 최초 무작위 순서를 세션에 고정한다. " + + "rating은 별점 내림차순, reviews는 리뷰 수 내림차순이며 동점은 최초 추천 순서를 유지한다. " + + "keyword로 검색한 뒤에도 지도 정렬 3가지를 사용한다", + schema = @Schema(type = "string", allowableValues = {"recommend", "rating", "reviews"}, + defaultValue = "recommend")), + @Parameter(name = "querySessionId", in = ParameterIn.QUERY, + description = "정렬 변경 전용. 직전 응답의 조회 세션 ID", + schema = @Schema(type = "string", format = "uuid", + example = "123e4567-e89b-12d3-a456-426614174000")), + @Parameter(name = "cursor", in = ParameterIn.QUERY, + description = "다음 페이지 전용. 직전 응답의 72자 nextCursor를 그대로 전달하며 임의 생성·수정하지 않음", + schema = @Schema(type = "string", + example = "AhI-RWfomxLTpFZCZhQXQAAAAAAACso6YZT4UYYs1U4g1RUyPn-6Z318IrJMCmUuvhgmPhvW")) + }) + @GetMapping("/api/v1/restaurants/map") + public SuccessResponse getPage( + @Parameter(hidden = true) @RequestParam MultiValueMap parameters, + HttpServletResponse response, HttpServletRequest request) { + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + return SuccessResponse.of(CommonSuccessCode.OK, + service.getPage(RestaurantMapPageRequest.from(parameters), request.getRemoteAddr())); + } +} diff --git a/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapWebConfig.java b/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapWebConfig.java new file mode 100644 index 00000000..ee5d9d12 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/web/RestaurantMapWebConfig.java @@ -0,0 +1,32 @@ +package org.sopt.hashi.restaurant.web; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** 지도 목록의 DB 연결을 Redis 대기 전에 반환하고 나머지 경로의 기존 OSIV 동작은 유지한다. */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(name = "spring.jpa.open-in-view", havingValue = "true", matchIfMissing = true) +public class RestaurantMapWebConfig { + + /** 이 표준 interceptor 빈이 있으면 Boot의 기본 OSIV 자동 등록은 물러난다. */ + @Bean + OpenEntityManagerInViewInterceptor openEntityManagerInViewInterceptor() { + return new OpenEntityManagerInViewInterceptor(); + } + + @Bean + WebMvcConfigurer restaurantMapOpenEntityManagerInViewConfigurer(OpenEntityManagerInViewInterceptor interceptor) { + return new WebMvcConfigurer() { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addWebRequestInterceptor(interceptor).excludePathPatterns("/api/v1/restaurants/map"); + } + }; + } +} diff --git a/src/main/java/org/sopt/hashi/user/code/UserErrorCode.java b/src/main/java/org/sopt/hashi/user/code/UserErrorCode.java index fe6e97c4..af620094 100644 --- a/src/main/java/org/sopt/hashi/user/code/UserErrorCode.java +++ b/src/main/java/org/sopt/hashi/user/code/UserErrorCode.java @@ -30,7 +30,8 @@ public enum UserErrorCode implements ErrorCode { RESTAURANT_NOT_SAVED(HttpStatus.NOT_FOUND, "USER-013", "컬렉션에 저장되지 않은 식당입니다"), SAME_COLLECTION_MOVE(HttpStatus.BAD_REQUEST, "USER-014", "같은 컬렉션으로는 이동할 수 없습니다"), RESTAURANT_NOT_FOUND(HttpStatus.NOT_FOUND, "USER-015", "저장할 식당을 찾을 수 없습니다"), - UNSUPPORTED_SAVED_RESTAURANT_SORT(HttpStatus.BAD_REQUEST, "USER-016", "지원하지 않는 정렬 기준입니다"); + UNSUPPORTED_SAVED_RESTAURANT_SORT(HttpStatus.BAD_REQUEST, "USER-016", "지원하지 않는 정렬 기준입니다"), + COLLECTION_MAP_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "USER-017", "컬렉션 지도를 조회할 수 없습니다"); private final HttpStatus status; private final String code; diff --git a/src/main/java/org/sopt/hashi/user/collection/domain/RestaurantCollection.java b/src/main/java/org/sopt/hashi/user/collection/domain/RestaurantCollection.java index e868ccea..9bf90eef 100644 --- a/src/main/java/org/sopt/hashi/user/collection/domain/RestaurantCollection.java +++ b/src/main/java/org/sopt/hashi/user/collection/domain/RestaurantCollection.java @@ -60,6 +60,10 @@ public class RestaurantCollection extends BaseTimeEntity { @Column(name = "visibility", length = 20, nullable = false) private CollectionVisibility visibility; + /** 메타데이터와 저장 관계 변경을 함께 식별한다. 자식 변경은 JPA @Version만으로 감지되지 않는다. */ + @Column(name = "collection_version", nullable = false) + private long collectionVersion; + /** 저장 순서(id 오름차순) — 커버 이미지는 오래된 저장부터, 최신순 목록은 그 역순이다. */ @BatchSize(size = 100) @OrderBy("id ASC") @@ -82,6 +86,7 @@ public static RestaurantCollection create(Long userId, String name, CollectionCo /** 부분 수정(PATCH) — null 필드는 기존 값을 유지한다(식당·매거진과 동일 정책). 설명은 빈 문자열로 비울 수 있다. */ public void update(String name, CollectionColor color, String description, CollectionVisibility visibility) { + collectionVersion++; if (name != null) { this.name = name; } @@ -89,7 +94,7 @@ public void update(String name, CollectionColor color, String description, Colle this.color = color; } if (description != null) { - this.description = description.isBlank() ? null : description; + this.description = description.isEmpty() ? null : description; } if (visibility != null) { this.visibility = visibility; @@ -118,12 +123,14 @@ public Optional findSaved(Long restaurantId) { public SavedRestaurant save(Long restaurantId) { SavedRestaurant saved = SavedRestaurant.create(this, restaurantId); savedRestaurants.add(saved); + collectionVersion++; return saved; } public void remove(Collection restaurantIds) { Set targets = Set.copyOf(restaurantIds); savedRestaurants.removeIf(saved -> targets.contains(saved.getRestaurantId())); + collectionVersion++; } public int savedRestaurantCount() { diff --git a/src/main/java/org/sopt/hashi/user/collection/domain/RestaurantCollectionRepository.java b/src/main/java/org/sopt/hashi/user/collection/domain/RestaurantCollectionRepository.java index 0f0587c2..2732178d 100644 --- a/src/main/java/org/sopt/hashi/user/collection/domain/RestaurantCollectionRepository.java +++ b/src/main/java/org/sopt/hashi/user/collection/domain/RestaurantCollectionRepository.java @@ -1,8 +1,11 @@ package org.sopt.hashi.user.collection.domain; import java.util.List; +import java.util.Optional; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,6 +24,22 @@ public interface RestaurantCollectionRepository extends JpaRepository findForUpdate(@Param("id") Long id); + + /** Native scalar current read는 OSIV/1차 캐시의 기존 Entity 값을 재사용하지 않는다. */ + @Query(value = "select user_id as userId, visibility as visibility, collection_version as collectionVersion " + + "from restaurant_collection where id = :id for update", nativeQuery = true) + Optional findCurrentMapState(@Param("id") Long id); + + interface CurrentMapState { + Long getUserId(); + String getVisibility(); + long getCollectionVersion(); + } + /** 탈퇴 정리 — 회원의 모든 컬렉션에 저장된 식당 매핑을 한 번에 지운다(컬렉션 삭제 전에 호출, 애그리거트 내부 FK). */ @Modifying @Query("delete from SavedRestaurant s where s.collection.id in " diff --git a/src/main/java/org/sopt/hashi/user/collection/domain/SavedRestaurantRepository.java b/src/main/java/org/sopt/hashi/user/collection/domain/SavedRestaurantRepository.java index dbaef7b3..0b5db3aa 100644 --- a/src/main/java/org/sopt/hashi/user/collection/domain/SavedRestaurantRepository.java +++ b/src/main/java/org/sopt/hashi/user/collection/domain/SavedRestaurantRepository.java @@ -3,6 +3,9 @@ import java.util.Collection; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; /** 저장 식당 조회 전용 — 쓰기는 애그리거트 루트({@link RestaurantCollection})를 통해서만 한다. */ public interface SavedRestaurantRepository extends JpaRepository { @@ -12,4 +15,21 @@ public interface SavedRestaurantRepository extends JpaRepository findAllByCollection_IdOrderByIdDesc(Long collectionId); + + /** 공개 범위와 무관하게 같은 소유자의 여러 저장은 한 번만 센다. */ + @Query("select s.restaurantId as restaurantId, count(distinct s.collection.userId) as saveCount " + + "from SavedRestaurant s where s.restaurantId in :ids group by s.restaurantId") + List countOwnersByRestaurantIds(@Param("ids") Collection ids); + + @Query("select distinct s.restaurantId from SavedRestaurant s " + + "where s.collection.userId = :userId and s.restaurantId in :ids") + List findSavedRestaurantIds(@Param("userId") Long userId, @Param("ids") Collection ids); + + @Query("select s.restaurantId from SavedRestaurant s where s.collection.id = :collectionId order by s.restaurantId") + List findMapRestaurantIds(@Param("collectionId") Long collectionId, Pageable pageable); + + interface SaveCount { + Long getRestaurantId(); + long getSaveCount(); + } } diff --git a/src/main/java/org/sopt/hashi/user/collection/dto/CollectionMapMarkersResponse.java b/src/main/java/org/sopt/hashi/user/collection/dto/CollectionMapMarkersResponse.java new file mode 100644 index 00000000..45a49db1 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/collection/dto/CollectionMapMarkersResponse.java @@ -0,0 +1,56 @@ +package org.sopt.hashi.user.collection.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import org.sopt.hashi.restaurant.RestaurantMapInfo.AttributionInfo; + +/** visibleRestaurantCount는 현재 존재하는 식당 수다. 좌표 없는 식당은 이 수에 포함되지만 content에는 없다. */ +@Schema(description = "컬렉션 전체의 현재 유효 지도 핀. viewport·BBOX·목록 페이지·필터는 적용하지 않음") +public record CollectionMapMarkersResponse( + @Schema(description = "컬렉션 ID", example = "42") + Long collectionId, + @Schema(description = "응답을 만든 컬렉션 저장 관계의 변경 번호", example = "7") + long collectionVersion, + @Schema(description = "핀 유효성을 판정하고 응답을 생성한 UTC 시각") + Instant generatedAt, + @Schema(description = "저장 관계 중 현재 존재하고 삭제되지 않은 식당 수. 좌표가 없는 식당도 포함", + example = "12") + int visibleRestaurantCount, + @Schema(description = "현재 존재하지만 좌표가 없거나 generatedAt 기준 만료되어 content에서 빠진 식당 수. 저장 관계는 삭제되지 않음", + example = "2") + int locationUnavailableCount, + @Schema(description = "유효한 좌표가 있는 식당 핀 전체. 식당 ID 오름차순이며 페이지네이션 없음") + List content) { + + public record Marker( + @Schema(description = "식당 ID", example = "1001") + Long restaurantId, + @Schema(description = "식당명", example = "하시 스시") + String name, + @Schema(description = "음식점 분류", example = "restaurant") + String placeType, + @Schema(description = "장르", example = "sushi") + String genre, + @Schema(description = "현재 유효한 지도 위치") + Position location) { } + + public record Position( + @Schema(description = "위도", example = "35.681236") + BigDecimal latitude, + @Schema(description = "경도", example = "139.767125") + BigDecimal longitude, + @Schema(description = "좌표 유효 기한(UTC). generatedAt보다 늦은 핀만 응답") + Instant validUntil, + @Schema(description = "좌표와 함께 표시할 제3자 출처. 각 이름과 링크를 표시하며 좌표 만료 시 함께 제거. 빈 배열이면 추가 출처 없음") + List attributions) { + public Position { + attributions = List.copyOf(attributions); + } + + public Position(BigDecimal latitude, BigDecimal longitude, Instant validUntil) { + this(latitude, longitude, validUntil, List.of()); + } + } +} diff --git a/src/main/java/org/sopt/hashi/user/collection/dto/CreateRestaurantCollectionRequest.java b/src/main/java/org/sopt/hashi/user/collection/dto/CreateRestaurantCollectionRequest.java index c8a5f16f..6eccd2ca 100644 --- a/src/main/java/org/sopt/hashi/user/collection/dto/CreateRestaurantCollectionRequest.java +++ b/src/main/java/org/sopt/hashi/user/collection/dto/CreateRestaurantCollectionRequest.java @@ -13,13 +13,17 @@ * 컬렉션명의 공백 판정은 수정 요청과 같이 유니코드 공백(전각 공백 등)까지 보고, 줄바꿈이 섞여 있어도 판정한다. */ public record CreateRestaurantCollectionRequest( - @Schema(description = "컬렉션명", example = "2026 도쿄 여름 여행") + @Schema(description = "컬렉션명. 입력값은 공백을 포함해 최대 20자이며, 저장 전에 앞뒤 공백을 제거합니다. " + + "중간 공백은 유지하고 공백만 있는 이름은 거부합니다. 앞뒤 공백을 제거한 이름으로 중복을 확인합니다.", + example = "2026 도쿄 여름 여행") @NotBlank(message = "컬렉션명은 필수입니다") @Pattern(regexp = "(?sU).*\\S.*", message = "컬렉션명은 공백일 수 없습니다") @Size(max = RestaurantCollection.NAME_MAX_LENGTH, message = "컬렉션명은 20자 이내입니다") String name, @Schema(description = "커버 색상(red·orange·yellow·green·blue·purple)", example = "red") @NotBlank(message = "색상은 필수입니다") String color, - @Schema(description = "설명(선택)", example = "여행 가기 전에 저장해둔 맛집들 중에서 꼭 가보고 싶은 곳") + @Schema(description = "설명(선택). 입력값은 공백을 포함해 최대 100자이며 앞뒤 공백은 제거하고 중간 공백은 유지합니다. " + + "생략·null·빈 문자열·공백만 입력하면 설명 없이 저장합니다.", + example = "여행 가기 전에 저장해둔 맛집들 중에서 꼭 가보고 싶은 곳") @Size(max = RestaurantCollection.DESCRIPTION_MAX_LENGTH, message = "설명은 100자 이내입니다") String description, @Schema(description = "공개 범위(public·private)", example = "public") @NotBlank(message = "공개 범위는 필수입니다") String visibility) { diff --git a/src/main/java/org/sopt/hashi/user/collection/dto/MyRestaurantSavesResponse.java b/src/main/java/org/sopt/hashi/user/collection/dto/MyRestaurantSavesResponse.java new file mode 100644 index 00000000..4dd96c91 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/collection/dto/MyRestaurantSavesResponse.java @@ -0,0 +1,17 @@ +package org.sopt.hashi.user.collection.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "요청 식당별 현재 USER의 저장 여부. 삭제·미존재 식당은 제외") +public record MyRestaurantSavesResponse( + @Schema(description = "요청 순서의 식당별 저장 여부. 카드와 restaurantId로 결합") + List restaurants) { + + public record MyRestaurantSave( + @Schema(description = "식당 ID", example = "1001") + Long restaurantId, + @Schema(description = "현재 USER의 공개·비공개 컬렉션 중 하나 이상에 저장됐는지 여부", + example = "true") + boolean saved) { } +} diff --git a/src/main/java/org/sopt/hashi/user/collection/dto/RestaurantSaveCountsResponse.java b/src/main/java/org/sopt/hashi/user/collection/dto/RestaurantSaveCountsResponse.java new file mode 100644 index 00000000..924da226 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/collection/dto/RestaurantSaveCountsResponse.java @@ -0,0 +1,18 @@ +package org.sopt.hashi.user.collection.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** 현재 존재하는 식당의 저장 사용자 수. 같은 사용자의 여러 컬렉션은 한 번만 센다. */ +@Schema(description = "요청 식당별 저장 회원 수. 삭제·미존재 식당은 제외") +public record RestaurantSaveCountsResponse( + @Schema(description = "요청 순서의 식당별 저장 수. 카드와 restaurantId로 결합") + List restaurants) { + + public record RestaurantSaveCount( + @Schema(description = "식당 ID", example = "1001") + Long restaurantId, + @Schema(description = "식당을 하나 이상의 컬렉션에 저장한 고유 회원 수. 한 회원의 여러 저장은 1명으로 계산", + example = "27") + long saveCount) { } +} diff --git a/src/main/java/org/sopt/hashi/user/collection/dto/UpdateRestaurantCollectionRequest.java b/src/main/java/org/sopt/hashi/user/collection/dto/UpdateRestaurantCollectionRequest.java index c9e7ee82..deffdb5c 100644 --- a/src/main/java/org/sopt/hashi/user/collection/dto/UpdateRestaurantCollectionRequest.java +++ b/src/main/java/org/sopt/hashi/user/collection/dto/UpdateRestaurantCollectionRequest.java @@ -7,15 +7,19 @@ /** * 컬렉션 부분 수정(PATCH) 요청(SAVED-007) — null 필드는 변경하지 않는다. - * 설명은 빈 문자열을 보내면 지운다. 공유 전 "공개 후 공유"도 visibility 변경으로 처리한다. + * 이름·설명은 앞뒤 공백을 제거하고, 설명은 빈 문자열이나 공백만 보내면 지운다. + * 공유 전 "공개 후 공유"도 visibility 변경으로 처리한다. */ public record UpdateRestaurantCollectionRequest( - @Schema(description = "컬렉션명(선택, 공백 불가)", example = "2026 도쿄 겨울 여행") + @Schema(description = "컬렉션명(선택). 생략·null이면 유지합니다. 입력값은 공백을 포함해 최대 20자이며, " + + "앞뒤 공백을 제거한 이름으로 저장하고 중복을 확인합니다. 중간 공백은 유지하며 공백만 있는 이름은 거부합니다.", + example = "2026 도쿄 겨울 여행") @Pattern(regexp = "(?sU).*\\S.*", message = "컬렉션명은 공백일 수 없습니다") @Size(max = RestaurantCollection.NAME_MAX_LENGTH, message = "컬렉션명은 20자 이내입니다") String name, @Schema(description = "커버 색상(red·orange·yellow·green·blue·purple, 선택)", example = "blue") @Pattern(regexp = "(?sU).*\\S.*", message = "색상은 공백일 수 없습니다") String color, - @Schema(description = "설명(선택) — 빈 문자열이면 설명을 지운다", example = "도쿄여행 야호") + @Schema(description = "설명(선택). 생략·null이면 유지하고 빈 문자열·공백만 입력하면 삭제합니다. " + + "입력값은 공백을 포함해 최대 100자이며 앞뒤 공백은 제거하고 중간 공백은 유지합니다.", example = "도쿄여행 야호") @Size(max = RestaurantCollection.DESCRIPTION_MAX_LENGTH, message = "설명은 100자 이내입니다") String description, @Schema(description = "공개 범위(public·private, 선택)", example = "private") @Pattern(regexp = "(?sU).*\\S.*", message = "공개 범위는 공백일 수 없습니다") String visibility) { diff --git a/src/main/java/org/sopt/hashi/user/collection/service/CollectionMapQueryService.java b/src/main/java/org/sopt/hashi/user/collection/service/CollectionMapQueryService.java new file mode 100644 index 00000000..647cb1e8 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/collection/service/CollectionMapQueryService.java @@ -0,0 +1,80 @@ +package org.sopt.hashi.user.collection.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.user.code.UserErrorCode; +import org.sopt.hashi.user.collection.dto.CollectionMapMarkersResponse; +import org.sopt.hashi.user.collection.dto.CollectionMapMarkersResponse.Marker; +import org.sopt.hashi.user.collection.dto.CollectionMapMarkersResponse.Position; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.dao.DataAccessException; +import org.springframework.transaction.TransactionException; + +/** DB snapshot와 최종 검사 사이의 Port 호출에는 user transaction/부모 잠금을 유지하지 않는다. */ +@Slf4j +@Service +public class CollectionMapQueryService { + private final CollectionMapSnapshotStore snapshots; + private final RestaurantPort restaurants; + private final CurrentUserProvider currentUserProvider; + private final Clock clock; + + public CollectionMapQueryService(CollectionMapSnapshotStore snapshots, RestaurantPort restaurants, + CurrentUserProvider currentUserProvider, @Qualifier("japanClock") Clock clock) { + this.snapshots = snapshots; + this.restaurants = restaurants; + this.currentUserProvider = currentUserProvider; + this.clock = clock; + } + + public CollectionMapMarkersResponse getMarkers(Long collectionId) { + if (collectionId == null || collectionId <= 0) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + try { + return readMarkers(collectionId); + } catch (DataAccessException | TransactionException exception) { + log.warn("Collection map failed. operation=collection-map-snapshot exceptionType={}", + exception.getClass().getSimpleName()); + throw new BusinessException(UserErrorCode.COLLECTION_MAP_UNAVAILABLE, exception); + } + } + + private CollectionMapMarkersResponse readMarkers(Long collectionId) { + Long viewerId = currentUserProvider.isAuthenticatedUser() ? currentUserProvider.currentUserId() : null; + var snapshot = snapshots.read(collectionId, viewerId); + List infos = snapshot.restaurantIds().isEmpty() ? List.of() + : findMapInfos(snapshot.restaurantIds()); + // 변경됐거나 접근할 수 없으면 응답을 만들지 않는다. 일부 batch 성공도 반환하지 않는다. + snapshots.validate(snapshot, viewerId); + Instant now = clock.instant(); + List markers = infos.stream() + .filter(info -> info.location() != null && now.isBefore(info.location().validUntil())) + .sorted(Comparator.comparing(RestaurantMapInfo::restaurantId)) + .map(info -> new Marker(info.restaurantId(), info.name(), info.placeType(), info.genre(), + new Position(info.location().latitude(), info.location().longitude(), info.location().validUntil(), + info.location().attributions()))) + .toList(); + return new CollectionMapMarkersResponse(collectionId, snapshot.version(), now, infos.size(), + infos.size() - markers.size(), markers); + } + + private List findMapInfos(List ids) { + try { + return restaurants.findActiveMapInfos(ids); + } catch (RuntimeException exception) { + log.warn("Collection map failed. operation=collection-map-port exceptionType={}", + exception.getClass().getSimpleName()); + throw new BusinessException(UserErrorCode.COLLECTION_MAP_UNAVAILABLE, exception); + } + } +} diff --git a/src/main/java/org/sopt/hashi/user/collection/service/CollectionMapSnapshotStore.java b/src/main/java/org/sopt/hashi/user/collection/service/CollectionMapSnapshotStore.java new file mode 100644 index 00000000..4fe20876 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/collection/service/CollectionMapSnapshotStore.java @@ -0,0 +1,65 @@ +package org.sopt.hashi.user.collection.service; + +import java.util.List; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.user.code.UserErrorCode; +import org.sopt.hashi.user.collection.domain.RestaurantCollection; +import org.sopt.hashi.user.collection.domain.RestaurantCollectionRepository; +import org.sopt.hashi.user.collection.domain.SavedRestaurantRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 지도 외부 enrich 동안 DB 잠금을 유지하지 않는다. 두 메서드는 별도 proxy/transaction 경계다. */ +@Service +public class CollectionMapSnapshotStore { + private final RestaurantCollectionRepository collections; + private final SavedRestaurantRepository saved; + + public CollectionMapSnapshotStore(RestaurantCollectionRepository collections, SavedRestaurantRepository saved) { + this.collections = collections; + this.saved = saved; + } + + /** 부모 변경번호와 자식 목록은 같은 RR snapshot에서 읽는다. 상한+1만 읽어 과량도 bounded다. */ + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) + public Snapshot read(Long id, Long viewerId) { + RestaurantCollection collection = collections.findById(id) + .orElseThrow(() -> new BusinessException(UserErrorCode.COLLECTION_NOT_FOUND)); + requireVisible(collection, viewerId); + List ids = saved.findMapRestaurantIds(id, + PageRequest.of(0, RestaurantCollectionService.MAX_SAVED_RESTAURANTS_PER_COLLECTION + 1)); + if (ids.size() > RestaurantCollectionService.MAX_SAVED_RESTAURANTS_PER_COLLECTION) { + throw new BusinessException(UserErrorCode.COLLECTION_MAP_UNAVAILABLE); + } + return new Snapshot(id, collection.getCollectionVersion(), List.copyOf(ids)); + } + + /** + * 새 RC transaction의 current locking read로 접근권한·변경번호를 최종 검사한다. + * 검사 중에는 쓰기와 동일한 부모 잠금을 잡는다. 반환 이후 미래 변경까지 차단하지는 않는다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public void validate(Snapshot snapshot, Long viewerId) { + var current = collections.findCurrentMapState(snapshot.collectionId()) + .orElseThrow(() -> new BusinessException(UserErrorCode.COLLECTION_NOT_FOUND)); + if (!"PUBLIC".equals(current.getVisibility()) + && (viewerId == null || !current.getUserId().equals(viewerId))) { + throw new BusinessException(UserErrorCode.COLLECTION_NOT_FOUND); + } + if (current.getCollectionVersion() != snapshot.version()) { + throw new BusinessException(CommonErrorCode.CONFLICT); + } + } + + private void requireVisible(RestaurantCollection collection, Long viewerId) { + if (!collection.isPublic() && (viewerId == null || !collection.isOwnedBy(viewerId))) { + throw new BusinessException(UserErrorCode.COLLECTION_NOT_FOUND); + } + } + + public record Snapshot(Long collectionId, long version, List restaurantIds) { } +} diff --git a/src/main/java/org/sopt/hashi/user/collection/service/RestaurantCollectionFinder.java b/src/main/java/org/sopt/hashi/user/collection/service/RestaurantCollectionFinder.java index fe83b954..b0fa65b6 100644 --- a/src/main/java/org/sopt/hashi/user/collection/service/RestaurantCollectionFinder.java +++ b/src/main/java/org/sopt/hashi/user/collection/service/RestaurantCollectionFinder.java @@ -32,6 +32,13 @@ RestaurantCollection findOwned(Long collectionId) { .orElseThrow(() -> new BusinessException(UserErrorCode.COLLECTION_NOT_FOUND)); } + RestaurantCollection findOwnedForUpdate(Long collectionId) { + Long userId = currentUserProvider.currentUserId(); + return restaurantCollectionRepository.findForUpdate(collectionId) + .filter(collection -> collection.isOwnedBy(userId)) + .orElseThrow(() -> new BusinessException(UserErrorCode.COLLECTION_NOT_FOUND)); + } + /** 열람 가능한 컬렉션 — 공개 컬렉션은 비로그인 포함 누구나, 비공개 컬렉션은 소유자만. 볼 수 없으면 없는 것과 같게 응답한다. */ RestaurantCollection findVisible(Long collectionId) { return restaurantCollectionRepository.findById(collectionId) diff --git a/src/main/java/org/sopt/hashi/user/collection/service/RestaurantCollectionService.java b/src/main/java/org/sopt/hashi/user/collection/service/RestaurantCollectionService.java index 09ac056a..5c6c0369 100644 --- a/src/main/java/org/sopt/hashi/user/collection/service/RestaurantCollectionService.java +++ b/src/main/java/org/sopt/hashi/user/collection/service/RestaurantCollectionService.java @@ -94,8 +94,10 @@ public RestaurantCollectionResponse create(CreateRestaurantCollectionRequest req if (restaurantCollectionRepository.existsByUserIdAndName(userId, name)) { throw new BusinessException(UserErrorCode.DUPLICATE_COLLECTION_NAME); } + String description = normalizeDescription(request.description()); RestaurantCollection collection = saveNewOrConflict(RestaurantCollection.create( - userId, name, toColor(request.color()), normalizeDescription(request.description()), + userId, name, toColor(request.color()), + description == null || description.isEmpty() ? null : description, toVisibility(request.visibility()))); log.info("컬렉션 생성. collectionId={}, userId={}", collection.getId(), userId); return new RestaurantCollectionResponse( @@ -175,7 +177,7 @@ public RestaurantCollectionResponse getCollection(Long collectionId) { /** 컬렉션 수정(SAVED-007) — 부분 수정. 이름을 바꾸면 다른 컬렉션과 중복될 수 없다. */ @Transactional public RestaurantCollectionResponse update(Long collectionId, UpdateRestaurantCollectionRequest request) { - RestaurantCollection collection = collectionFinder.findOwned(collectionId); + RestaurantCollection collection = collectionFinder.findOwnedForUpdate(collectionId); String name = request.name() == null ? null : normalizeName(request.name()); boolean isRenamingToTakenName = name != null && restaurantCollectionRepository.existsByUserIdAndNameAndIdNot( @@ -186,7 +188,7 @@ public RestaurantCollectionResponse update(Long collectionId, UpdateRestaurantCo collection.update( name, request.color() == null ? null : toColor(request.color()), - request.description(), + normalizeDescription(request.description()), request.visibility() == null ? null : toVisibility(request.visibility())); flushOrConflict(UserErrorCode.DUPLICATE_COLLECTION_NAME); return toResponse(collection, true); @@ -195,7 +197,7 @@ public RestaurantCollectionResponse update(Long collectionId, UpdateRestaurantCo /** 컬렉션 삭제(SAVED-007) — 컬렉션과 저장 관계만 지우고 식당 원본은 건드리지 않는다. */ @Transactional public void delete(Long collectionId) { - RestaurantCollection collection = collectionFinder.findOwned(collectionId); + RestaurantCollection collection = collectionFinder.findOwnedForUpdate(collectionId); restaurantCollectionRepository.delete(collection); log.info("컬렉션 삭제. collectionId={}, userId={}", collection.getId(), collection.getUserId()); } @@ -206,7 +208,7 @@ public void delete(Long collectionId) { */ @Transactional public RestaurantCollectionResponse saveRestaurant(Long collectionId, SaveRestaurantRequest request) { - RestaurantCollection collection = collectionFinder.findOwned(collectionId); + RestaurantCollection collection = collectionFinder.findOwnedForUpdate(collectionId); Long restaurantId = request.restaurantId(); if (!restaurantPort.existsActiveById(restaurantId)) { throw new BusinessException(UserErrorCode.RESTAURANT_NOT_FOUND); @@ -224,7 +226,7 @@ public RestaurantCollectionResponse saveRestaurant(Long collectionId, SaveRestau @Transactional public RestaurantCollectionResponse removeRestaurants(Long collectionId, List restaurantIds) { Set targets = distinctIds(restaurantIds); - RestaurantCollection collection = collectionFinder.findOwned(collectionId); + RestaurantCollection collection = collectionFinder.findOwnedForUpdate(collectionId); requireAllSaved(collection, targets); collection.remove(targets); return toResponse(collection, true); @@ -240,8 +242,13 @@ public MoveSavedRestaurantsResponse moveRestaurants(Long collectionId, MoveSaved throw new BusinessException(UserErrorCode.SAME_COLLECTION_MOVE); } Set targets = distinctIds(request.restaurantIds()); - RestaurantCollection source = collectionFinder.findOwned(collectionId); - RestaurantCollection target = collectionFinder.findOwned(request.targetCollectionId()); + // 반대 방향의 이동도 같은 순서로 두 부모를 잠근 후 자식 목록을 읽는다. + Long firstId = Math.min(collectionId, request.targetCollectionId()); + Long secondId = Math.max(collectionId, request.targetCollectionId()); + RestaurantCollection first = collectionFinder.findOwnedForUpdate(firstId); + RestaurantCollection second = collectionFinder.findOwnedForUpdate(secondId); + RestaurantCollection source = collectionId.equals(firstId) ? first : second; + RestaurantCollection target = collectionId.equals(firstId) ? second : first; requireAllSaved(source, targets); boolean isAnyAlreadyInTarget = targets.stream().anyMatch(target::contains); if (isAnyAlreadyInTarget) { @@ -351,32 +358,44 @@ private Set distinctIds(List restaurantIds) { return ids; } - /** - * 컬렉션명 앞뒤 공백 제거 — strip()(Character.isWhitespace)은 NBSP 같은 줄바꿈 금지 공백을 남기고, DTO의 유니코드 공백 검사는 - * 정보 구분 제어문자를 공백으로 보지 않는다. 두 정의를 합쳐 앞뒤를 잘라야 공백만으로 된 이름이 저장되지 않고, - * '도쿄'와 '도쿄 '(끝에 NBSP)가 다른 이름으로 중복 금지를 우회하지 않는다. 빈 이름을 막는 최종 방어선이다. - */ + /** 입력 길이 제한은 DTO와 같게 적용하고, 앞뒤 공백을 제거한 이름으로 저장·중복 검사한다. */ private String normalizeName(String name) { + if (name == null || name.length() > RestaurantCollection.NAME_MAX_LENGTH) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + String normalized = stripBoundaryWhitespace(name); + if (normalized.isEmpty()) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + return normalized; + } + + /** NBSP·전각 공백도 제거하며 이름·설명 안쪽의 공백은 그대로 둔다. */ + private String stripBoundaryWhitespace(String value) { int start = 0; - int end = name.length(); - while (start < end && isBlankChar(name.charAt(start))) { + int end = value.length(); + while (start < end && isBlankChar(value.charAt(start))) { start++; } - while (end > start && isBlankChar(name.charAt(end - 1))) { + while (end > start && isBlankChar(value.charAt(end - 1))) { end--; } - if (start == end) { - throw new BusinessException(CommonErrorCode.INVALID_INPUT); - } - return name.substring(start, end); + return value.substring(start, end); } private static boolean isBlankChar(char character) { - return Character.isWhitespace(character) || Character.isSpaceChar(character); + return Character.isWhitespace(character) || Character.isSpaceChar(character) || character == '\u0085'; } private String normalizeDescription(String description) { - return description == null || description.isBlank() ? null : description; + if (description == null) { + return null; + } + if (description.length() > RestaurantCollection.DESCRIPTION_MAX_LENGTH) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + // PATCH는 null이면 유지, 빈 문자열이면 삭제하므로 두 값을 구분한다. + return stripBoundaryWhitespace(description); } private CollectionColor toColor(String value) { diff --git a/src/main/java/org/sopt/hashi/user/collection/service/RestaurantSaveSummaryService.java b/src/main/java/org/sopt/hashi/user/collection/service/RestaurantSaveSummaryService.java new file mode 100644 index 00000000..9ebb800d --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/collection/service/RestaurantSaveSummaryService.java @@ -0,0 +1,71 @@ +package org.sopt.hashi.user.collection.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.restaurant.RestaurantCardInfo; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.user.collection.domain.SavedRestaurantRepository; +import org.sopt.hashi.user.collection.domain.SavedRestaurantRepository.SaveCount; +import org.sopt.hashi.user.collection.dto.MyRestaurantSavesResponse; +import org.sopt.hashi.user.collection.dto.MyRestaurantSavesResponse.MyRestaurantSave; +import org.sopt.hashi.user.collection.dto.RestaurantSaveCountsResponse; +import org.sopt.hashi.user.collection.dto.RestaurantSaveCountsResponse.RestaurantSaveCount; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED) +public class RestaurantSaveSummaryService { + private final SavedRestaurantRepository savedRepository; + private final RestaurantPort restaurantPort; + private final CurrentUserProvider currentUserProvider; + + public RestaurantSaveSummaryService(SavedRestaurantRepository savedRepository, + RestaurantPort restaurantPort, CurrentUserProvider currentUserProvider) { + this.savedRepository = savedRepository; + this.restaurantPort = restaurantPort; + this.currentUserProvider = currentUserProvider; + } + + public RestaurantSaveCountsResponse getSaveCounts(List restaurantIds) { + List ids = activeIds(restaurantIds); + if (ids.isEmpty()) { + return new RestaurantSaveCountsResponse(List.of()); + } + Map counts = savedRepository.countOwnersByRestaurantIds(ids).stream() + .collect(Collectors.toMap(SaveCount::getRestaurantId, SaveCount::getSaveCount)); + return new RestaurantSaveCountsResponse(ids.stream() + .map(id -> new RestaurantSaveCount(id, counts.getOrDefault(id, 0L))).toList()); + } + + public MyRestaurantSavesResponse getMySaves(List restaurantIds) { + if (!currentUserProvider.isAuthenticatedUser()) { + throw new BusinessException(CommonErrorCode.FORBIDDEN); + } + Long userId = currentUserProvider.currentUserId(); + List ids = activeIds(restaurantIds); + Set saved = ids.isEmpty() ? Set.of() + : new HashSet<>(savedRepository.findSavedRestaurantIds(userId, ids)); + return new MyRestaurantSavesResponse(ids.stream() + .map(id -> new MyRestaurantSave(id, saved.contains(id))).toList()); + } + + private List activeIds(List ids) { + if (ids == null || ids.isEmpty() || ids.size() > 100 + || ids.stream().anyMatch(id -> id == null || id <= 0) + || new HashSet<>(ids).size() != ids.size()) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + // Port 실패는 그대로 실패한다. 삭제/미존재와 provider 장애를 0 또는 false로 혼동하지 않는다. + Set active = restaurantPort.findActiveCards(ids).stream() + .map(RestaurantCardInfo::id).collect(Collectors.toSet()); + return ids.stream().filter(active::contains).toList(); + } +} diff --git a/src/main/java/org/sopt/hashi/user/collection/web/CollectionMapController.java b/src/main/java/org/sopt/hashi/user/collection/web/CollectionMapController.java new file mode 100644 index 00000000..16e83168 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/collection/web/CollectionMapController.java @@ -0,0 +1,49 @@ +package org.sopt.hashi.user.collection.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.constraints.Positive; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.shared.error.CommonSuccessCode; +import org.sopt.hashi.shared.response.SuccessResponse; +import org.sopt.hashi.shared.swagger.ApiException; +import org.sopt.hashi.shared.swagger.ApiSuccess; +import org.sopt.hashi.user.code.UserErrorCode; +import org.sopt.hashi.user.collection.dto.CollectionMapMarkersResponse; +import org.sopt.hashi.user.collection.service.CollectionMapQueryService; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +public class CollectionMapController { + private final CollectionMapQueryService service; + + public CollectionMapController(CollectionMapQueryService service) { + this.service = service; + } + + /** 공개는 누구나, 비공개는 소유 USER만. ID순 전체 유효 핀을 반환하며 목록 페이지/필터는 적용하지 않는다. */ + @Operation(summary = "컬렉션의 전체 지도 핀 조회", description = """ + 공개 컬렉션은 로그인 없이 조회할 수 있고, 비공개 컬렉션은 소유 USER만 조회할 수 있습니다. + 익명 사용자·다른 USER·ADMIN이 비공개 컬렉션을 요청하면 존재를 숨기기 위해 USER-006(404)을 반환합니다. + viewport, BBOX, 목록 페이지, 정렬·필터와 무관하게 컬렉션 전체에서 현재 유효한 위치 핀을 식당 ID순으로 반환합니다. + 현재 컬렉션 저장 상한은 1,000개이며 응답도 페이지네이션 없이 전체를 반환합니다. + 좌표가 없거나 만료된 식당은 content에서만 제외되며 저장 관계는 유지되고 locationUnavailableCount에 포함됩니다. + 삭제·미존재 식당은 visibleRestaurantCount와 content에서 제외됩니다. 응답은 no-store입니다. + """) + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "CONFLICT"}) + @ApiException(value = UserErrorCode.class, codes = {"COLLECTION_NOT_FOUND", "COLLECTION_MAP_UNAVAILABLE"}) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @GetMapping("/api/v1/collections/{collectionId}/map-markers") + public ResponseEntity> getMarkers( + @Parameter(description = "조회할 컬렉션 ID", example = "42") + @Positive @PathVariable Long collectionId) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(SuccessResponse.of(CommonSuccessCode.OK, service.getMarkers(collectionId))); + } +} diff --git a/src/main/java/org/sopt/hashi/user/collection/web/RestaurantCollectionController.java b/src/main/java/org/sopt/hashi/user/collection/web/RestaurantCollectionController.java index d805532c..89a1b693 100644 --- a/src/main/java/org/sopt/hashi/user/collection/web/RestaurantCollectionController.java +++ b/src/main/java/org/sopt/hashi/user/collection/web/RestaurantCollectionController.java @@ -53,7 +53,11 @@ public RestaurantCollectionController(RestaurantCollectionService restaurantColl this.savedRestaurantQueryService = savedRestaurantQueryService; } - /** 새 컬렉션 만들기 — 공개 범위(public·private)는 필수다. 같은 이름의 컬렉션이 있으면 USER-007. */ + /** + * 새 컬렉션 만들기 — 공개 범위(public·private)는 필수다. + * 이름·설명의 앞뒤 공백은 제거하고 중간 공백은 유지한다. 예: " 도쿄 맛집 " → "도쿄 맛집". + * 같은 사용자의 컬렉션 중 공백 제거 후 이름이 중복되면 USER-007(409)이다. + */ @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) @ApiException(value = UserErrorCode.class, codes = {"DUPLICATE_COLLECTION_NAME", "UNSUPPORTED_COLLECTION_COLOR", "UNSUPPORTED_COLLECTION_VISIBILITY", "COLLECTION_LIMIT_EXCEEDED"}) @@ -88,7 +92,12 @@ public SuccessResponse getCollection( return SuccessResponse.of(CommonSuccessCode.OK, restaurantCollectionService.getCollection(collectionId)); } - /** 컬렉션 수정(PATCH) — 보낸 필드만 바꾼다. 공유 전 공개 전환도 visibility로 처리한다. */ + /** + * 컬렉션 수정(PATCH) — 생략·null 필드는 유지한다. 공유 전 공개 전환도 visibility로 처리한다. + * 이름·설명의 앞뒤 공백은 제거하고 중간 공백은 유지한다. 공백만 있는 이름은 거부한다. + * 설명에 빈 문자열이나 공백만 보내면 삭제한다. 같은 이름으로 다시 수정하는 것은 허용하며, + * 다른 내 컬렉션과 이름이 중복되면 USER-007(409)이다. + */ @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) @ApiException(value = UserErrorCode.class, codes = {"COLLECTION_NOT_FOUND", "DUPLICATE_COLLECTION_NAME", "UNSUPPORTED_COLLECTION_COLOR", "UNSUPPORTED_COLLECTION_VISIBILITY"}) diff --git a/src/main/java/org/sopt/hashi/user/collection/web/RestaurantSaveSummaryController.java b/src/main/java/org/sopt/hashi/user/collection/web/RestaurantSaveSummaryController.java new file mode 100644 index 00000000..9a01959f --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/collection/web/RestaurantSaveSummaryController.java @@ -0,0 +1,69 @@ +package org.sopt.hashi.user.collection.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import java.util.List; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.shared.error.CommonSuccessCode; +import org.sopt.hashi.shared.response.SuccessResponse; +import org.sopt.hashi.shared.swagger.ApiException; +import org.sopt.hashi.shared.swagger.ApiSuccess; +import org.sopt.hashi.user.collection.dto.MyRestaurantSavesResponse; +import org.sopt.hashi.user.collection.dto.RestaurantSaveCountsResponse; +import org.sopt.hashi.user.collection.service.RestaurantSaveSummaryService; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class RestaurantSaveSummaryController { + private final RestaurantSaveSummaryService service; + + public RestaurantSaveSummaryController(RestaurantSaveSummaryService service) { + this.service = service; + } + + /** 중복 없는 양의 식당 ID 1~100개. 삭제·미존재 식당은 제외하고 요청 순서를 유지한다. */ + @Operation(summary = "지도 카드용 식당 저장 수 조회", description = """ + 로그인 없이 지도 카드 여러 개의 저장 수를 한 번에 조회합니다. + 이 API는 카드 표시용 읽기 API이며 저장을 만들지 않습니다. USER가 카드에서 식당을 저장할 때는 + POST /api/v1/collections/{collectionId}/restaurants에 해당 restaurantId를 보내세요. + restaurantIds는 중복 없는 양의 ID 1~100개이며, 삭제·미존재 식당은 응답에서 제외됩니다. + 같은 회원이 여러 공개·비공개 컬렉션에 저장해도 saveCount는 한 명으로 계산합니다. + 응답은 요청한 식당 순서를 유지하지만 일부 ID가 빠질 수 있으므로 배열 위치가 아닌 restaurantId로 카드와 결합하세요. + 응답은 no-store입니다. + """) + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @GetMapping("/api/v1/restaurants/save-counts") + public ResponseEntity> getSaveCounts( + @Parameter(description = "중복 없는 양의 식당 ID 1~100개. 쉼표로 구분", + example = "1001,1002,1003") + @RequestParam List restaurantIds) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(SuccessResponse.of(CommonSuccessCode.OK, service.getSaveCounts(restaurantIds))); + } + + /** 현재 로그인 회원의 모든 컬렉션을 기준으로 저장 여부를 조회한다. */ + @Operation(summary = "지도 카드용 내 저장 여부 조회", description = """ + USER 토큰으로 지도 카드 여러 개가 내 컬렉션 중 하나 이상에 저장됐는지 한 번에 조회합니다. + 이 API는 저장 여부만 읽습니다. 카드의 저장 동작은 POST /api/v1/collections/{collectionId}/restaurants를 사용합니다. + 비로그인은 COMMON-401, ADMIN·ONBOARDING 토큰은 COMMON-403을 반환합니다. + restaurantIds는 중복 없는 양의 ID 1~100개이며, 삭제·미존재 식당은 응답에서 제외됩니다. + 공개·비공개 컬렉션을 모두 확인하며 하나라도 저장돼 있으면 saved=true입니다. + 응답은 요청 순서를 유지하지만 일부 ID가 빠질 수 있으므로 배열 위치가 아닌 restaurantId로 카드와 결합하세요. + 응답은 no-store입니다. + """) + @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) + @ApiSuccess(value = CommonSuccessCode.class, codes = {"OK"}) + @GetMapping("/api/v1/users/me/restaurant-saves") + public ResponseEntity> getMySaves( + @Parameter(description = "중복 없는 양의 식당 ID 1~100개. 쉼표로 구분", + example = "1001,1002,1003") + @RequestParam List restaurantIds) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(SuccessResponse.of(CommonSuccessCode.OK, service.getMySaves(restaurantIds))); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index df14aeda..e467ad41 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,6 +45,18 @@ kakao: client-secret: ${KAKAO_CLIENT_SECRET:} # 카카오 콘솔에서 활성화한 경우만 hashi: + map: + google-places: + enabled: ${HASHI_MAP_GOOGLE_PLACES_ENABLED:false} + api-key: ${HASHI_MAP_GOOGLE_PLACES_API_KEY:} + connect-timeout: ${HASHI_MAP_GOOGLE_PLACES_CONNECT_TIMEOUT:2s} + response-timeout: ${HASHI_MAP_GOOGLE_PLACES_RESPONSE_TIMEOUT:5s} + max-response-bytes: ${HASHI_MAP_GOOGLE_PLACES_MAX_RESPONSE_BYTES:65536} + places-selection: + # 후보 선택은 provider와 별도로 opt-in하며 Base64 디코딩 기준 32바이트 이상의 전용 키가 필요하다. + enabled: ${HASHI_MAP_PLACES_SELECTION_ENABLED:false} + signing-key: ${HASHI_MAP_PLACES_SELECTION_SIGNING_KEY:} + token-ttl: ${HASHI_MAP_PLACES_SELECTION_TOKEN_TTL:10m} cors: # 콤마 구분 목록. 운영은 env CORS_ALLOWED_ORIGINS로 프론트 도메인을 주입한다. allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173} diff --git a/src/main/resources/db/migration/V38__add_restaurant_map_location.sql b/src/main/resources/db/migration/V38__add_restaurant_map_location.sql new file mode 100644 index 00000000..a69f8ef5 --- /dev/null +++ b/src/main/resources/db/migration/V38__add_restaurant_map_location.sql @@ -0,0 +1,81 @@ +-- No coordinates, tourist regions or geocoding jobs are seeded here. +CREATE TABLE restaurant_location ( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + latitude DECIMAL(9, 6), + longitude DECIMAL(10, 6), + status VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + source VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin, + address_revision BIGINT NOT NULL, + request_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + obtained_at DATETIME(6), + valid_until DATETIME(6), + next_attempt_at DATETIME(6), + lock_version BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + CONSTRAINT chk_location_revision CHECK (address_revision > 0 AND lock_version >= 0), + CONSTRAINT chk_location_status CHECK ( + status IN ('PENDING', 'READY', 'RETRY_WAIT', 'REVIEW_REQUIRED', 'FAILED') + AND CHAR_LENGTH(status) = CHAR_LENGTH(TRIM(status)) + ), + CONSTRAINT chk_location_coordinates CHECK ( + (latitude IS NULL AND longitude IS NULL) + OR (latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180) + ), + CONSTRAINT chk_location_payload CHECK ( + (latitude IS NOT NULL AND longitude IS NOT NULL + AND source IS NOT NULL AND source IN ('GOOGLE_GEOCODING', 'ADMIN') + AND CHAR_LENGTH(source) = CHAR_LENGTH(TRIM(source)) + AND obtained_at IS NOT NULL AND valid_until IS NOT NULL AND obtained_at < valid_until) + OR (latitude IS NULL AND longitude IS NULL + AND source IS NULL AND obtained_at IS NULL AND valid_until IS NULL) + ), + CONSTRAINT chk_location_ready CHECK (status <> 'READY' OR latitude IS NOT NULL), + CONSTRAINT chk_location_retry CHECK ( + (status = 'RETRY_WAIT' AND next_attempt_at IS NOT NULL) + OR (status <> 'RETRY_WAIT' AND next_attempt_at IS NULL) + ), + INDEX idx_location_status_coordinates (status, latitude, longitude), + INDEX idx_location_status_expiry (status, valid_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE map_region ( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + code VARCHAR(40) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + name VARCHAR(100) NOT NULL, + cluster_latitude DECIMAL(9, 6) NOT NULL, + cluster_longitude DECIMAL(10, 6) NOT NULL, + south DECIMAL(9, 6) NOT NULL, + north DECIMAL(9, 6) NOT NULL, + west DECIMAL(10, 6) NOT NULL, + east DECIMAL(10, 6) NOT NULL, + display_order INT NOT NULL, + active BIT(1) NOT NULL DEFAULT b'0', + PRIMARY KEY (id), + CONSTRAINT uq_map_region_code UNIQUE (code), + CONSTRAINT chk_map_region_code CHECK (REGEXP_LIKE(code, '^[A-Z][A-Z0-9_]{0,39}$', 'c')), + -- Unicode whitespace plus Java's U+001C..U+001F separators, matching MapRegion.BLANK_NAME. + CONSTRAINT chk_map_region_name CHECK (REGEXP_LIKE(name, '[^[:space:]\\x{001C}-\\x{001F}]')), + CONSTRAINT chk_map_region_order CHECK (display_order >= 0), + CONSTRAINT chk_map_region_bounds CHECK ( + south >= -90 AND north <= 90 AND south < north + AND west >= -180 AND east <= 180 AND west < east + ), + CONSTRAINT chk_map_region_position CHECK ( + cluster_latitude BETWEEN south AND north AND cluster_longitude BETWEEN west AND east + ), + INDEX idx_map_region_active_order (active, display_order, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- location is an owned child of Restaurant. MapRegion is a separate aggregate referenced only by ID. +ALTER TABLE restaurant + ADD COLUMN location_id BIGINT NULL, + ADD COLUMN map_region_id BIGINT NULL, + ADD CONSTRAINT uq_restaurant_location UNIQUE (location_id), + ADD CONSTRAINT fk_restaurant_location FOREIGN KEY (location_id) REFERENCES restaurant_location (id), + ADD CONSTRAINT chk_restaurant_map_region CHECK (map_region_id IS NULL OR map_region_id > 0), + ADD INDEX idx_restaurant_map_region (map_region_id, deleted, id); diff --git a/src/main/resources/db/migration/V39_1__add_restaurant_geocoding_address.sql b/src/main/resources/db/migration/V39_1__add_restaurant_geocoding_address.sql new file mode 100644 index 00000000..7fa93816 --- /dev/null +++ b/src/main/resources/db/migration/V39_1__add_restaurant_geocoding_address.sql @@ -0,0 +1,3 @@ +-- Admin-entered Japanese base address for provider requests. Existing rows keep display-address fallback. +ALTER TABLE restaurant + ADD COLUMN geocoding_address VARCHAR(255) NULL AFTER address; diff --git a/src/main/resources/db/migration/V39_2__add_restaurant_places_review.sql b/src/main/resources/db/migration/V39_2__add_restaurant_places_review.sql new file mode 100644 index 00000000..65a20f35 --- /dev/null +++ b/src/main/resources/db/migration/V39_2__add_restaurant_places_review.sql @@ -0,0 +1,54 @@ +ALTER TABLE restaurant_location + ADD COLUMN google_place_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER source, + ADD COLUMN places_attributions JSON NULL AFTER google_place_id, + DROP CHECK chk_location_payload, + ADD CONSTRAINT chk_location_payload CHECK ( + (latitude IS NOT NULL AND longitude IS NOT NULL + AND source IS NOT NULL AND source IN ('GOOGLE_GEOCODING', 'GOOGLE_PLACES', 'ADMIN') + AND CHAR_LENGTH(source) = CHAR_LENGTH(TRIM(source)) + AND obtained_at IS NOT NULL AND valid_until IS NOT NULL AND obtained_at < valid_until + AND ((source = 'GOOGLE_PLACES' AND google_place_id IS NOT NULL + AND CHAR_LENGTH(google_place_id) BETWEEN 1 AND 255 + AND CHAR_LENGTH(google_place_id) = CHAR_LENGTH(TRIM(google_place_id)) + AND places_attributions IS NOT NULL AND JSON_TYPE(places_attributions) = 'ARRAY') + OR (source <> 'GOOGLE_PLACES' AND google_place_id IS NULL AND places_attributions IS NULL))) + OR (latitude IS NULL AND longitude IS NULL AND source IS NULL AND google_place_id IS NULL + AND places_attributions IS NULL + AND obtained_at IS NULL AND valid_until IS NULL) + ); + +ALTER TABLE restaurant_location_job + ADD COLUMN operation VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'GEOCODING' + AFTER request_id, + ADD COLUMN google_place_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER operation, + ADD CONSTRAINT chk_location_job_operation CHECK ( + (operation = 'GEOCODING' AND google_place_id IS NULL) + OR (operation = 'PLACE_DETAILS' AND google_place_id IS NOT NULL + AND CHAR_LENGTH(google_place_id) BETWEEN 1 AND 255 + AND CHAR_LENGTH(google_place_id) = CHAR_LENGTH(TRIM(google_place_id))) + ), + ADD INDEX idx_location_job_operation_due (operation, state, next_attempt_at, id); + +CREATE TABLE restaurant_places_budget ( + operation VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + enabled BIT(1) NOT NULL, + daily_limit INT NOT NULL, + minute_limit INT NOT NULL, + budget_day DATE NULL, + daily_used INT NOT NULL, + minute_window_start DATETIME(6) NULL, + minute_used INT NOT NULL, + blocked_until DATETIME(6) NULL, + PRIMARY KEY (operation), + CONSTRAINT chk_places_budget_operation CHECK (operation IN ('SEARCH', 'DETAILS')), + CONSTRAINT chk_places_budget_limits CHECK ( + daily_limit BETWEEN 0 AND 100000 AND minute_limit BETWEEN 0 AND 10000 + AND daily_used >= 0 AND minute_used >= 0 + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Places is opt-in. Enabling provider configuration never changes these durable controls. +INSERT INTO restaurant_places_budget + (operation, enabled, daily_limit, minute_limit, daily_used, minute_used) +VALUES ('SEARCH', b'0', 100, 10, 0, 0), + ('DETAILS', b'0', 100, 10, 0, 0); diff --git a/src/main/resources/db/migration/V39_3__add_location_confirmation_audit.sql b/src/main/resources/db/migration/V39_3__add_location_confirmation_audit.sql new file mode 100644 index 00000000..85353ac1 --- /dev/null +++ b/src/main/resources/db/migration/V39_3__add_location_confirmation_audit.sql @@ -0,0 +1,26 @@ +-- Keep provider provenance separate from the administrator's candidate selection. +-- Historical selections have no known administrator or confirmation time; do not fabricate them. +ALTER TABLE restaurant_location + ADD COLUMN confirmation_method VARCHAR(30) CHARACTER SET ascii COLLATE ascii_bin NULL, + ADD COLUMN confirmed_by_admin_id BIGINT NULL, + ADD COLUMN confirmed_at DATETIME(6) NULL, + ADD COLUMN confirmed_revision BIGINT NULL, + ADD CONSTRAINT chk_location_confirmation CHECK ( + (confirmation_method IS NULL AND confirmed_by_admin_id IS NULL + AND confirmed_at IS NULL AND confirmed_revision IS NULL) + OR (confirmation_method IS NOT NULL AND confirmation_method = 'AUTOMATIC' + AND CHAR_LENGTH(confirmation_method) = CHAR_LENGTH(TRIM(confirmation_method)) + AND confirmed_by_admin_id IS NULL + AND ((confirmed_at IS NULL AND confirmed_revision IS NULL) + OR (confirmed_at IS NOT NULL AND confirmed_revision IS NOT NULL + AND confirmed_revision = address_revision))) + OR (confirmation_method IS NOT NULL AND confirmation_method = 'ADMIN_PLACE_SELECTION' + AND CHAR_LENGTH(confirmation_method) = CHAR_LENGTH(TRIM(confirmation_method)) + AND confirmed_by_admin_id IS NOT NULL + AND confirmed_by_admin_id > 0 AND confirmed_at IS NOT NULL + AND confirmed_revision IS NOT NULL AND confirmed_revision = address_revision) + ); + +UPDATE restaurant_location +SET confirmation_method = 'AUTOMATIC' +WHERE source = 'GOOGLE_GEOCODING'; diff --git a/src/main/resources/db/migration/V39__add_restaurant_location_jobs.sql b/src/main/resources/db/migration/V39__add_restaurant_location_jobs.sql new file mode 100644 index 00000000..578b5283 --- /dev/null +++ b/src/main/resources/db/migration/V39__add_restaurant_location_jobs.sql @@ -0,0 +1,52 @@ +-- Minimal job history: no address, provider JSON, coordinates or request URI. +CREATE TABLE restaurant_location_job ( + id BIGINT NOT NULL AUTO_INCREMENT, + restaurant_id BIGINT NOT NULL, + address_revision BIGINT NOT NULL, + request_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + state VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + attempt INT NOT NULL, + next_attempt_at DATETIME(6) NOT NULL, + lease_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + lease_until DATETIME(6) NULL, + reserved_until DATETIME(6) NULL, + failure_code VARCHAR(40) CHARACTER SET ascii COLLATE ascii_bin NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uq_location_job_request UNIQUE (request_id), + CONSTRAINT fk_location_job_restaurant FOREIGN KEY (restaurant_id) REFERENCES restaurant (id), + CONSTRAINT chk_location_job_attempt CHECK (address_revision > 0 AND attempt >= 0), + CONSTRAINT chk_location_job_state CHECK ( + state IN ('PENDING', 'LEASED', 'RETRY_WAIT', 'SUCCEEDED', 'REVIEW_REQUIRED', 'FAILED', 'SUPERSEDED') + AND CHAR_LENGTH(state) = CHAR_LENGTH(TRIM(state)) + ), + CONSTRAINT chk_location_job_lease CHECK ( + (state = 'LEASED' AND lease_token IS NOT NULL AND lease_until IS NOT NULL + AND reserved_until IS NOT NULL AND reserved_until = lease_until AND attempt > 0) + OR (state <> 'LEASED' AND lease_token IS NULL AND lease_until IS NULL) + ), + INDEX idx_location_job_due (state, next_attempt_at, id), + INDEX idx_location_job_lease (state, lease_until, id), + INDEX idx_location_job_restaurant (restaurant_id, state, id), + INDEX idx_location_job_reservation (reserved_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE restaurant_geocoding_budget ( + id BIGINT NOT NULL, + enabled BIT(1) NOT NULL, + daily_limit INT NOT NULL, + max_concurrent INT NOT NULL, + budget_day DATE NULL, + reserved_calls INT NOT NULL, + blocked_until DATETIME(6) NULL, + PRIMARY KEY (id), + CONSTRAINT chk_geocoding_budget_singleton CHECK (id = 1), + CONSTRAINT chk_geocoding_budget_limits CHECK ( + daily_limit BETWEEN 0 AND 100000 AND max_concurrent BETWEEN 0 AND 4 AND reserved_calls >= 0 + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Environment-independent fail-closed control state; see ADR 0003. Never reset on startup. +INSERT INTO restaurant_geocoding_budget (id, enabled, daily_limit, max_concurrent, reserved_calls) +VALUES (1, b'0', 0, 0, 0); diff --git a/src/main/resources/db/migration/V40__add_location_maintenance_runs.sql b/src/main/resources/db/migration/V40__add_location_maintenance_runs.sql new file mode 100644 index 00000000..bf1d7265 --- /dev/null +++ b/src/main/resources/db/migration/V40__add_location_maintenance_runs.sql @@ -0,0 +1,35 @@ +-- Only control data and job IDs; never copy addresses or Google results into run history. +CREATE TABLE restaurant_location_maintenance_run ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + mode VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + state VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + after_id BIGINT NOT NULL, + upper_id BIGINT NOT NULL, + cursor_id BIGINT NOT NULL, + as_of DATETIME(6) NOT NULL, + refresh_before DATETIME(6) NOT NULL, + max_registrations INT NOT NULL, + max_calls INT NOT NULL, + scanned BIGINT NOT NULL DEFAULT 0, + enqueued INT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_location_run_mode CHECK (mode IN ('BACKFILL', 'REFRESH') + AND CHAR_LENGTH(mode) = CHAR_LENGTH(TRIM(mode))), + CONSTRAINT chk_location_run_state CHECK (state IN ('ACTIVE', 'STOPPED', 'SCANNED', 'LIMIT_REACHED') + AND CHAR_LENGTH(state) = CHAR_LENGTH(TRIM(state))), + CONSTRAINT chk_location_run_range CHECK (after_id >= 0 AND upper_id >= after_id + AND cursor_id BETWEEN after_id AND upper_id AND refresh_before > as_of), + CONSTRAINT chk_location_run_limits CHECK (max_registrations BETWEEN 1 AND 10000 + AND max_calls BETWEEN 0 AND 80000 AND scanned >= 0 AND enqueued >= 0 + AND enqueued <= max_registrations AND enqueued * 8 <= max_calls AND scanned >= enqueued) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE restaurant_location_maintenance_job ( + run_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + job_id BIGINT NOT NULL, + PRIMARY KEY (run_id, job_id), + CONSTRAINT uq_location_maintenance_job UNIQUE (job_id), + CONSTRAINT fk_location_maintenance_run FOREIGN KEY (run_id) + REFERENCES restaurant_location_maintenance_run (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V41__add_collection_version.sql b/src/main/resources/db/migration/V41__add_collection_version.sql new file mode 100644 index 00000000..a725a6ea --- /dev/null +++ b/src/main/resources/db/migration/V41__add_collection_version.sql @@ -0,0 +1,3 @@ +-- 지도 조회 전후 metadata/membership 변경 감지. 자식 저장/제거/이동도 부모 버전을 같은 transaction에서 갱신한다. +ALTER TABLE restaurant_collection + ADD COLUMN collection_version BIGINT NOT NULL DEFAULT 0; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 73fc88a0..f0309627 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,5 +1,7 @@ + + diff --git a/src/main/resources/map/admit.lua b/src/main/resources/map/admit.lua new file mode 100644 index 00000000..654d8a6d --- /dev/null +++ b/src/main/resources/map/admit.lua @@ -0,0 +1,42 @@ +local memory = redis.call('INFO', 'memory') +local used = tonumber(string.match(memory, 'used_memory:(%d+)')) +local max = tonumber(string.match(memory, 'maxmemory:(%d+)')) +local policy = string.match(memory, 'maxmemory_policy:([^%s]+)') +local ceiling = tonumber(ARGV[1]) +local headroom = tonumber(ARGV[2]) +local shared = ARGV[9] == '1' +if not used or not max or (shared and (policy ~= 'volatile-lru' or max <= 0)) + or (not shared and policy ~= 'noeviction') then return -5 end +if max > 0 then ceiling = math.min(ceiling, max) end +if used + headroom >= ceiling then return -5 end +local time = redis.call('TIME') +local now = tonumber(time[1]) +local nowMillis = now * 1000 + math.floor(tonumber(time[2]) / 1000) +if shared then + local state = sharedState(KEYS[1], KEYS[2], nowMillis) + if state ~= 0 then return state end +end +local window = math.floor(now / 60) +local previous = tonumber(redis.call('HGET', KEYS[1], 'window')) +if shared and (not previous or previous > window or redis.call('HLEN', KEYS[1]) > 4099) then return -7 end +if previous ~= window then + local readyAt = redis.call('HGET', KEYS[1], '__readyAt') + redis.call('DEL', KEYS[1]) + redis.call('HSET', KEYS[1], 'window', window, 'total', 0) + if shared then redis.call('HSET', KEYS[1], '__readyAt', readyAt) + else redis.call('EXPIRE', KEYS[1], 120) end +end +local total = tonumber(redis.call('HGET', KEYS[1], 'total')) +local caller = redis.call('HGET', KEYS[1], ARGV[3]) +local metadataFields = shared and 3 or 2 +if not total or total < 0 then return -7 end +if not caller and redis.call('HLEN', KEYS[1]) - metadataFields >= tonumber(ARGV[4]) then return -1 end +local calls, queries = 0, 0 +if caller then calls, queries = string.match(caller, '^(%d+):(%d+)$') end +calls, queries = tonumber(calls), tonumber(queries) +if not calls or not queries or queries > calls or calls > total then return -7 end +local isNew = tonumber(ARGV[8]) +if total >= tonumber(ARGV[5]) then return -4 end +if calls >= tonumber(ARGV[6]) or (isNew == 1 and queries >= tonumber(ARGV[7])) then return -3 end +redis.call('HSET', KEYS[1], 'total', total + 1, ARGV[3], (calls + 1) .. ':' .. (queries + isNew)) +return nowMillis diff --git a/src/main/resources/map/create.lua b/src/main/resources/map/create.lua new file mode 100644 index 00000000..2b89a0f1 --- /dev/null +++ b/src/main/resources/map/create.lua @@ -0,0 +1,55 @@ +local memory = redis.call('INFO', 'memory') +local used = tonumber(string.match(memory, 'used_memory:(%d+)')) +local max = tonumber(string.match(memory, 'maxmemory:(%d+)')) +local policy = string.match(memory, 'maxmemory_policy:([^%s]+)') +local ceiling = tonumber(ARGV[1]) +local headroom = tonumber(ARGV[2]) +local shared = ARGV[11] == '1' +if not used or not max or (shared and (policy ~= 'volatile-lru' or max <= 0)) + or (not shared and policy ~= 'noeviction') then return -5 end +if max > 0 then ceiling = math.min(ceiling, max) end +if used + headroom >= ceiling then return -5 end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +if shared then + local state = sharedState(KEYS[3], KEYS[2], now) + if state ~= 0 then return state end +end +local absolute = tonumber(ARGV[4]) +local idle = tonumber(ARGV[5]) +local bytes = #ARGV[3] +if bytes > tonumber(ARGV[6]) then return -1 end +if not absolute or absolute <= now or absolute > now + tonumber(ARGV[10]) then return -2 end +if redis.call('HLEN', KEYS[2]) > (shared and 4097 or 4096) then return shared and -7 or -2 end +local entries = redis.call('HGETALL', KEYS[2]) +local total, count = 0, 0 +for index = 1, #entries, 2 do + if not shared or entries[index] ~= '__readyAt' then + local expiry, reserved = string.match(entries[index + 1], '^(%d+):(%d+)$') + if not expiry or not reserved or tonumber(reserved) < 1024 then return shared and -7 or -2 end + if tonumber(expiry) <= now then + redis.call('HDEL', KEYS[2], entries[index]) + else + total = total + tonumber(reserved) + count = count + 1 + end + end +end +-- Reserve twice the serialized bytes plus per-key/ledger overhead; INFO is a second, global guard. +local reservation = bytes * 2 + 1024 +-- Account for the impending allocation too, rather than only the memory before SET. +if used + reservation + headroom >= ceiling then return -5 end +if total + reservation > tonumber(ARGV[7]) then return -3 end +if count >= tonumber(ARGV[8]) then return -4 end +if redis.call('EXISTS', KEYS[1]) == 1 then return -2 end +local expiry = math.min(absolute, now + idle) +local ledgerTtl = redis.call('PTTL', KEYS[2]) +redis.call('HSET', KEYS[2], ARGV[9], expiry .. ':' .. reservation) +-- A shorter lifetime on a rolling instance must not expire another instance's reservations. +-- Preserve an existing persistent ledger; a missing ledger receives the new lifetime. +if not shared and ledgerTtl ~= -1 then + redis.call('PEXPIRE', KEYS[2], math.max(ledgerTtl, tonumber(ARGV[10]) + 1000)) +end +-- Reserve first. If SET fails, the reservation expires conservatively instead of leaving unaccounted data. +redis.call('SET', KEYS[1], ARGV[3], 'PXAT', expiry) +return expiry diff --git a/src/main/resources/map/prepare-shared.lua b/src/main/resources/map/prepare-shared.lua new file mode 100644 index 00000000..47c0f1fc --- /dev/null +++ b/src/main/resources/map/prepare-shared.lua @@ -0,0 +1,18 @@ +-- Operator preflight while the public map feature is disabled. Concatenate shared.lua before this file. +-- Only the two known map metadata keys are allowed; no snapshot or authentication key is touched. +if #KEYS ~= 2 or KEYS[1] ~= 'hashi:restaurant:map:{sessions-shared-v3}:requests' + or KEYS[2] ~= 'hashi:restaurant:map:{sessions-shared-v3}:admission' then return {-7, 0} end +local memory = redis.call('INFO', 'memory') +local used = tonumber(string.match(memory, 'used_memory:(%d+)')) +local max = tonumber(string.match(memory, 'maxmemory:(%d+)')) +local ceiling, headroom = tonumber(ARGV[1]), tonumber(ARGV[2]) +if not used or not max or max <= 0 or not ceiling or not headroom or headroom <= 0 + or ceiling <= headroom or string.match(memory, 'maxmemory_policy:([^%s]+)') ~= 'volatile-lru' + or used + headroom >= math.min(ceiling, max) then return {-5, 0} end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local state = sharedState(KEYS[1], KEYS[2], now) +if state == -6 then + return {state, tonumber(redis.call('HGET', KEYS[1], '__readyAt')) - now} +end +return {state, 0} diff --git a/src/main/resources/map/shared.lua b/src/main/resources/map/shared.lua new file mode 100644 index 00000000..1a3a1e97 --- /dev/null +++ b/src/main/resources/map/shared.lua @@ -0,0 +1,23 @@ +-- Shared volatile-lru can evict snapshots, but must not evict their reservation/rate metadata. +-- If metadata disappears, stop all writers/touches for the hard 30-minute lifetime. This lets +-- unaccounted snapshots expire without scanning, deleting, or interpreting any authentication key. +local function sharedState(requests, ledger, now) + local requestsType = redis.call('TYPE', requests).ok + local ledgerType = redis.call('TYPE', ledger).ok + if (requestsType ~= 'none' and requestsType ~= 'hash') + or (ledgerType ~= 'none' and ledgerType ~= 'hash') then return -7 end + if requestsType == 'none' or ledgerType == 'none' then + local readyAt = now + 1800000 + redis.call('DEL', requests, ledger) + redis.call('HSET', requests, '__readyAt', readyAt, 'window', -1, 'total', 0) + redis.call('HSET', ledger, '__readyAt', readyAt) + return -6 + end + -- A TTL or malformed marker must never silently reset accounting. + if redis.call('PTTL', requests) ~= -1 or redis.call('PTTL', ledger) ~= -1 then return -7 end + local requestsReady = tonumber(redis.call('HGET', requests, '__readyAt')) + local ledgerReady = tonumber(redis.call('HGET', ledger, '__readyAt')) + if not requestsReady or not ledgerReady or requestsReady < 0 or requestsReady ~= ledgerReady then return -7 end + if now < requestsReady then return -6 end + return 0 +end diff --git a/src/main/resources/map/touch.lua b/src/main/resources/map/touch.lua new file mode 100644 index 00000000..f7db573c --- /dev/null +++ b/src/main/resources/map/touch.lua @@ -0,0 +1,21 @@ +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +if ARGV[4] == '1' then + local memory = redis.call('INFO', 'memory') + local max = tonumber(string.match(memory, 'maxmemory:(%d+)')) + if string.match(memory, 'maxmemory_policy:([^%s]+)') ~= 'volatile-lru' + or not max or max <= 0 then return -5 end + local state = sharedState(KEYS[3], KEYS[2], now) + if state ~= 0 then return state end +end +local ttl = redis.call('PTTL', KEYS[1]) +local entry = redis.call('HGET', KEYS[2], ARGV[1]) +if ttl <= 0 or not entry then return -1 end +local expiry, reserved = string.match(entry, '^(%d+):(%d+)$') +if not expiry or not reserved or tonumber(reserved) < 1024 or tonumber(expiry) <= now then return -1 end +local absolute = tonumber(ARGV[3]) +if absolute <= now then return -1 end +local nextExpiry = math.min(absolute, math.max(now + ttl, now + tonumber(ARGV[2]))) +redis.call('PEXPIREAT', KEYS[1], nextExpiry) +redis.call('HSET', KEYS[2], ARGV[1], nextExpiry .. ':' .. reserved) +return nextExpiry diff --git a/src/test/java/org/sopt/hashi/admin/dto/RestaurantRequestValidationTest.java b/src/test/java/org/sopt/hashi/admin/dto/RestaurantRequestValidationTest.java index 46c0bf64..6ffb3ba9 100644 --- a/src/test/java/org/sopt/hashi/admin/dto/RestaurantRequestValidationTest.java +++ b/src/test/java/org/sopt/hashi/admin/dto/RestaurantRequestValidationTest.java @@ -13,6 +13,8 @@ import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class RestaurantRequestValidationTest { @@ -249,6 +251,25 @@ static void setUpValidator() { .containsExactly("placeType"); } + @ParameterizedTest + @ValueSource(strings = {" \u3000 ", "\u00A0", "\u2007", "\u202F", "\u001C"}) + void 식당_등록의_위치확인주소는_선택이지만_보내면_Unicode공백일_수_없다(String blank) { + CreateRestaurantRequest request = createRequestWithGeocodingAddress(blank); + + assertThat(validator.validate(request)) + .extracting(violation -> violation.getPropertyPath().toString()) + .containsExactly("geocodingAddress"); + } + + @Test + void 식당_수정의_공백_위치확인주소는_override_삭제_요청으로_허용한다() { + UpdateRestaurantRequest request = new UpdateRestaurantRequest( + null, null, null, null, null, "\u00A0\u2007\u202F", null, null, null, null, + null, null, null, null, null, null, null, null, null, null); + + assertThat(validator.validate(request)).isEmpty(); + } + private CreateRestaurantRequest createRequest( List imageKeys, List imageAssetIds, @@ -257,6 +278,16 @@ private CreateRestaurantRequest createRequest( return createRequest("restaurant", imageKeys, imageAssetIds, menus); } + private CreateRestaurantRequest createRequestWithGeocodingAddress(String geocodingAddress) { + return new CreateRestaurantRequest( + "하시 스시", "Hashi Sushi", "한 줄 소개", "상세 설명", + "東京都渋谷区恵比寿2-37-8 架空ビル1F", geocodingAddress, + "도쿄", "sushi", "sushi", "restaurant", "JPY", + BigDecimal.valueOf(1_000), BigDecimal.valueOf(3_000), + List.of("restaurants/test.jpg"), null, null, List.of(), List.of("스시"), List.of(), + createBusinessHours()); + } + private CreateRestaurantRequest createRequest( String placeType, List imageKeys, diff --git a/src/test/java/org/sopt/hashi/admin/service/AdminRestaurantServiceTest.java b/src/test/java/org/sopt/hashi/admin/service/AdminRestaurantServiceTest.java index 8d23ad36..53733696 100644 --- a/src/test/java/org/sopt/hashi/admin/service/AdminRestaurantServiceTest.java +++ b/src/test/java/org/sopt/hashi/admin/service/AdminRestaurantServiceTest.java @@ -5,10 +5,15 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -16,9 +21,15 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.hashi.admin.dto.UpdateRestaurantRequest; +import org.sopt.hashi.admin.dto.RetryRestaurantLocationRequest; +import org.sopt.hashi.admin.dto.SelectRestaurantPlaceRequest; +import org.sopt.hashi.auth.AdminAccountPort; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantInfo; import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.restaurant.RestaurantLocationInfo; +import org.sopt.hashi.restaurant.RestaurantLocationReviewInfo; +import org.sopt.hashi.restaurant.RestaurantLocationReviewPage; @ExtendWith(MockitoExtension.class) class AdminRestaurantServiceTest { @@ -26,9 +37,89 @@ class AdminRestaurantServiceTest { @Mock private RestaurantPort restaurantPort; + @Mock + private AdminAccountPort adminAccountPort; + @InjectMocks private AdminRestaurantService adminRestaurantService; + @Test + void 상태와_재시도와_후보선택은_저장된_확인자의_현재_로그인ID를_반환한다() { + var info = location(7L); + given(restaurantPort.getLocationByAdmin(1L)).willReturn(info); + given(restaurantPort.retryLocationByAdmin(1L, 2)).willReturn(info); + given(restaurantPort.selectLocationPlaceByAdmin(1L, 2, "selection-token")).willReturn(info); + given(adminAccountPort.findLoginIds(Set.of(7L))).willReturn(Map.of(7L, "hashi_admin")); + + var responses = List.of( + adminRestaurantService.getLocation(1L), + adminRestaurantService.retryLocation(1L, new RetryRestaurantLocationRequest(2L)), + adminRestaurantService.selectLocationPlace(1L, new SelectRestaurantPlaceRequest(2L, "selection-token"))); + + assertThat(responses).allSatisfy(response -> { + assertThat(response.confirmedByAdminId()).isEqualTo(7L); + assertThat(response.confirmedByAdminLoginId()).isEqualTo("hashi_admin"); + }); + verify(adminAccountPort, times(3)).findLoginIds(Set.of(7L)); + } + + @Test + void 확인_계정이_삭제되면_내부ID는_유지하고_로그인ID만_null이다() { + given(restaurantPort.getLocationByAdmin(1L)).willReturn(location(7L)); + given(adminAccountPort.findLoginIds(Set.of(7L))).willReturn(Map.of()); + + var response = adminRestaurantService.getLocation(1L); + + assertThat(response.confirmedByAdminId()).isEqualTo(7L); + assertThat(response.confirmedByAdminLoginId()).isNull(); + } + + @Test + void 확인자가_없는_위치는_계정을_조회하지_않는다() { + given(restaurantPort.getLocationByAdmin(1L)).willReturn(location(null)); + + assertThat(adminRestaurantService.getLocation(1L).confirmedByAdminLoginId()).isNull(); + + verifyNoInteractions(adminAccountPort); + } + + @Test + void 검토_목록은_중복_확인자를_한번에_조회하고_없는_계정은_null로_표시한다() { + given(restaurantPort.findLocationReviewsByAdmin("REVIEW_REQUIRED", null, 0, 20)) + .willReturn(new RestaurantLocationReviewPage(List.of( + review(1L, 7L), review(2L, null), review(3L, 7L), review(4L, 8L)), 0, 20, 4, 1)); + given(adminAccountPort.findLoginIds(Set.of(7L, 8L))).willReturn(Map.of(7L, "hashi_admin")); + + var response = adminRestaurantService.findLocationReviews("REVIEW_REQUIRED", null, 0, 20); + + assertThat(response.restaurants()).extracting("confirmedByAdminLoginId") + .containsExactly("hashi_admin", null, "hashi_admin", null); + assertThat(response.restaurants()).extracting("confirmedByAdminId").containsExactly(7L, null, 7L, 8L); + verify(adminAccountPort).findLoginIds(Set.of(7L, 8L)); + verifyNoMoreInteractions(adminAccountPort); + } + + @Test + void 확인자가_없는_목록은_계정을_조회하지_않는다() { + given(restaurantPort.findLocationReviewsByAdmin("REVIEW_REQUIRED", null, 0, 20)) + .willReturn(new RestaurantLocationReviewPage(List.of(review(1L, null)), 0, 20, 1, 1)); + + assertThat(adminRestaurantService.findLocationReviews("REVIEW_REQUIRED", null, 0, 20) + .restaurants().getFirst().confirmedByAdminLoginId()).isNull(); + + verifyNoInteractions(adminAccountPort); + } + + private RestaurantLocationInfo location(Long adminId) { + return new RestaurantLocationInfo(1L, "PENDING", 2, null, "PLACE_DETAILS", null, + 0, null, null, false, "ADMIN_PLACE_SELECTION", adminId, null, 2L); + } + + private RestaurantLocationReviewInfo review(Long restaurantId, Long adminId) { + return new RestaurantLocationReviewInfo(restaurantId, "검토 식당", "東京都", null, + "REVIEW_REQUIRED", null, "PLACE_DETAILS", 2, null, 1, null, "NO_RESULTS", true, adminId); + } + @Test void 식당_수정은_기존_메뉴_ID를_restaurant_포트에_전달한다() { UpdateRestaurantRequest request = updateRequest(List.of( @@ -45,7 +136,11 @@ class AdminRestaurantServiceTest { given(restaurantPort.updateByAdmin(eq(1L), any(AdminRestaurantCommand.class))) .willReturn(adminRestaurantInfo()); - adminRestaurantService.update(1L, request); + var response = adminRestaurantService.update(1L, request); + assertThat(response.restaurantId()).isEqualTo(1L); + assertThat(response.geocodingAddress()).isEqualTo("東京都千代田区丸の内1-9-1"); + assertThat(response.locationStatus()).isEqualTo("PENDING"); + assertThat(response.addressRevision()).isEqualTo(1); ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(AdminRestaurantCommand.class); @@ -58,6 +153,23 @@ class AdminRestaurantServiceTest { }); } + @Test + void 식당_수정은_명시한_위치확인주소를_restaurant_포트에_전달한다() { + UpdateRestaurantRequest request = addressAndGeocodingAddressRequest( + "東京都千代田区丸の内1-9-1 建物2F", " 東京都千代田区丸の内1-9-1 "); + given(restaurantPort.updateByAdmin(eq(1L), any(AdminRestaurantCommand.class))) + .willReturn(adminRestaurantInfo()); + + adminRestaurantService.update(1L, request); + + ArgumentCaptor commandCaptor = + ArgumentCaptor.forClass(AdminRestaurantCommand.class); + verify(restaurantPort).updateByAdmin(eq(1L), commandCaptor.capture()); + assertThat(commandCaptor.getValue().address()).isEqualTo("東京都千代田区丸の内1-9-1 建物2F"); + assertThat(commandCaptor.getValue().geocodingAddress()) + .isEqualTo(" 東京都千代田区丸の内1-9-1 "); + } + private UpdateRestaurantRequest updateRequest(List menus) { return new UpdateRestaurantRequest( null, @@ -82,6 +194,12 @@ private UpdateRestaurantRequest updateRequest(List request = + ArgumentCaptor.forClass(UpdateRestaurantRequest.class); + verify(adminRestaurantService).update(eq(1L), request.capture()); + assertThat(request.getValue().geocodingAddress()).isEqualTo("\u00A0\u2007\u202F"); + } + + @Test + void 표시주소와_같은_위치확인주소를_함께보낸_PATCH를_둘다_서비스에_전달한다() throws Exception { + mockMvc.perform(patch("/api/v1/admin/restaurants/{restaurantId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"address":"東京都中央区月島3-16-9 建物2F", + "geocodingAddress":"東京都中央区月島3-16-9"} + """)) + .andExpect(status().isOk()); + + ArgumentCaptor request = + ArgumentCaptor.forClass(UpdateRestaurantRequest.class); + verify(adminRestaurantService).update(eq(1L), request.capture()); + assertThat(request.getValue().address()).isEqualTo("東京都中央区月島3-16-9 建物2F"); + assertThat(request.getValue().geocodingAddress()).isEqualTo("東京都中央区月島3-16-9"); + } } diff --git a/src/test/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortImplTest.java b/src/test/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortImplTest.java new file mode 100644 index 00000000..4244555a --- /dev/null +++ b/src/test/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortImplTest.java @@ -0,0 +1,88 @@ +package org.sopt.hashi.auth.internal.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.CurrentActor; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; + +class AdminAccountPortImplTest { + + private final AdminRepository admins = mock(AdminRepository.class); + private final CurrentActorProvider actors = mock(CurrentActorProvider.class); + private final AdminAccountPortImpl port = new AdminAccountPortImpl(admins, actors); + + @Test + void 관리자에게_존재하는_계정의_현재_로그인ID만_일괄_반환한다() { + given(actors.currentActor()).willReturn(new CurrentActor(ActorType.ADMIN, 1L)); + given(admins.findLoginIds(Set.of(7L, 8L, 9L))) + .willReturn(List.of(projection(7L, "first_admin"), projection(9L, "renamed_admin"))); + + var result = port.findLoginIds(Set.of(7L, 8L, 9L)); + + assertThat(result).containsEntry(7L, "first_admin").containsEntry(9L, "renamed_admin") + .doesNotContainKey(8L).hasSize(2); + verify(admins).findLoginIds(Set.of(7L, 8L, 9L)); + verifyNoMoreInteractions(admins); + } + + @Test + void 빈_목록도_관리자_권한을_확인하고_DB를_조회하지_않는다() { + given(actors.currentActor()).willReturn(new CurrentActor(ActorType.ADMIN, 1L)); + + assertThat(port.findLoginIds(Set.of())).isEmpty(); + + verify(actors).currentActor(); + verifyNoInteractions(admins); + } + + @ParameterizedTest + @EnumSource(value = ActorType.class, names = {"USER", "ONBOARDING"}) + void 다른_모듈에서_직접_호출해도_일반회원과_온보딩은_거부한다(ActorType actorType) { + given(actors.currentActor()).willReturn(new CurrentActor(actorType, 1L)); + + assertThatThrownBy(() -> port.findLoginIds(Set.of(7L))) + .isInstanceOfSatisfying(BusinessException.class, + error -> assertThat(error.getErrorCode()).isEqualTo(CommonErrorCode.FORBIDDEN)); + + verifyNoInteractions(admins); + } + + @Test + void 인증_정보가_없으면_로그인ID를_조회하지_않는다() { + given(actors.currentActor()).willThrow(new BusinessException(CommonErrorCode.UNAUTHORIZED)); + + assertThatThrownBy(() -> port.findLoginIds(Set.of(7L))) + .isInstanceOfSatisfying(BusinessException.class, + error -> assertThat(error.getErrorCode()).isEqualTo(CommonErrorCode.UNAUTHORIZED)); + + verifyNoInteractions(admins); + } + + private AdminRepository.AdminLoginIdProjection projection(Long id, String loginId) { + return new AdminRepository.AdminLoginIdProjection() { + @Override + public Long getId() { + return id; + } + + @Override + public String getLoginId() { + return loginId; + } + }; + } +} diff --git a/src/test/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortMySqlTest.java b/src/test/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortMySqlTest.java new file mode 100644 index 00000000..7fb37302 --- /dev/null +++ b/src/test/java/org/sopt/hashi/auth/internal/admin/AdminAccountPortMySqlTest.java @@ -0,0 +1,58 @@ +package org.sopt.hashi.auth.internal.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.AdminAccountPort; +import org.sopt.hashi.auth.CurrentActor; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(AdminAccountPortImpl.class) +@TestPropertySource(properties = {"spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true"}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AdminAccountPortMySqlTest { + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("admin_display").withUsername("hashi").withPassword("hashi") + .withCommand("--log-bin-trust-function-creators=1"); + + @Autowired AdminAccountPort port; + @Autowired JdbcTemplate jdbc; + @MockitoBean CurrentActorProvider actors; + + @Test + void 실제_쿼리는_현재_로그인ID를_반환하고_삭제된_계정은_제외한다() { + given(actors.currentActor()).willReturn(new CurrentActor(ActorType.ADMIN, 1L)); + jdbc.update("INSERT INTO admin(login_id, password, created_at) VALUES (?, ?, NOW())", + "display_test_admin", "test-only-password-hash"); + Long id = jdbc.queryForObject("SELECT id FROM admin WHERE login_id=?", Long.class, "display_test_admin"); + + assertThat(port.findLoginIds(Set.of(id))).containsExactlyEntriesOf(Map.of(id, "display_test_admin")); + + jdbc.update("UPDATE admin SET login_id=? WHERE id=?", "renamed_test_admin", id); + assertThat(port.findLoginIds(Set.of(id))).containsExactlyEntriesOf(Map.of(id, "renamed_test_admin")); + + jdbc.update("DELETE FROM admin WHERE id=?", id); + assertThat(port.findLoginIds(Set.of(id))).isEmpty(); + } +} diff --git a/src/test/java/org/sopt/hashi/auth/internal/security/AdminLocationAuthorizationTest.java b/src/test/java/org/sopt/hashi/auth/internal/security/AdminLocationAuthorizationTest.java new file mode 100644 index 00000000..ec14099b --- /dev/null +++ b/src/test/java/org/sopt/hashi/auth/internal/security/AdminLocationAuthorizationTest.java @@ -0,0 +1,314 @@ +package org.sopt.hashi.auth.internal.security; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.admin.service.AdminRestaurantService; +import org.sopt.hashi.admin.web.AdminRestaurantController; +import org.sopt.hashi.auth.AdminAccountPort; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.auth.internal.onboarding.OnboardingJwtIssuer; +import org.sopt.hashi.auth.internal.token.OnboardingTokenStore; +import org.sopt.hashi.auth.internal.token.TokenBlacklist; +import org.sopt.hashi.restaurant.RestaurantLocationInfo; +import org.sopt.hashi.restaurant.RestaurantLocationPreviewCommand; +import org.sopt.hashi.restaurant.RestaurantLocationReviewInfo; +import org.sopt.hashi.restaurant.RestaurantLocationReviewPage; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.restaurant.RestaurantPlacesSearchInfo; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminRestaurantController.class, + excludeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = OnboardingJwtIssuer.class)) +@Import({AdminRestaurantService.class, SecurityConfig.class, JwtAuthenticationFilter.class, JwtProvider.class, + CookieUtil.class, OriginValidator.class, JwtAuthenticationEntryPoint.class, JwtAccessDeniedHandler.class}) +@TestPropertySource(properties = { + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", "jwt.access-token-ttl=30m", + "jwt.refresh-token-ttl=14d", "jwt.onboarding-token-ttl=30m", "kakao.client-id=test-client-id", + "kakao.redirect-uri=https://app.hashi.test/callback", "hashi.cors.allowed-origins=https://app.hashi.test", + "springdoc.api-docs.enabled=false", "springdoc.swagger-ui.enabled=false" +}) +class AdminLocationAuthorizationTest { + private static final String PATH = "/api/v1/admin/restaurants/1/location"; + private static final String LIST_PATH = "/api/v1/admin/restaurants/locations"; + @Autowired MockMvc mvc; + @Autowired JwtProvider tokens; + @MockitoBean RestaurantPort restaurants; + @MockitoBean AdminAccountPort admins; + @MockitoBean OnboardingTokenStore onboardingTokenStore; + @MockitoBean TokenBlacklist tokenBlacklist; + + @Test + void 관리자_상태_응답에는_서버가_조회한_확인자_로그인ID를_표시한다() throws Exception { + given(restaurants.getLocationByAdmin(1L)).willReturn(new RestaurantLocationInfo( + 1L, "PENDING", 2, null, "PLACE_DETAILS", null, 0, null, null, false, + "ADMIN_PLACE_SELECTION", 7L, null, 2L)); + given(admins.findLoginIds(Set.of(7L))).willReturn(Map.of(7L, "hashi_admin")); + + mvc.perform(get(PATH).header("Authorization", admin())) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.confirmedByAdminId").value(7)) + .andExpect(jsonPath("$.data.confirmedByAdminLoginId").value("hashi_admin")) + .andExpect(jsonPath("$.data.password").doesNotExist()); + } + + @ParameterizedTest + @ValueSource(longs = {0, 3}) + void ADMIN은_실제_controller_service_port로_상태와_재처리_계약을_사용한다(long revision) throws Exception { + var state = new RestaurantLocationInfo(1L, "PENDING", revision, null, "GEOCODING", + null, 0, null, null, false); + given(restaurants.getLocationByAdmin(1L)).willReturn(state); + given(restaurants.retryLocationByAdmin(1L, revision)).willReturn(state); + mvc.perform(get(PATH).header("Authorization", admin())) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("COMMON-200")) + .andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.addressRevision").value((int) revision)) + .andExpect(jsonPath("$.data.leaseToken").doesNotExist()) + .andExpect(jsonPath("$.data.address").doesNotExist()); + mvc.perform(post(PATH + "/retry").header("Authorization", admin()).contentType(MediaType.APPLICATION_JSON) + .content("{\"expectedAddressRevision\":" + revision + "}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("COMMON-200")); + verify(restaurants).retryLocationByAdmin(1L, revision); + } + + @ParameterizedTest + @ValueSource(strings = {"USER", "ONBOARDING", "ANONYMOUS"}) + void 일반회원과_온보딩과_익명은_조회와_재처리_모두_차단한다(String role) throws Exception { + var read = get(PATH); + var retry = post(PATH + "/retry").contentType(MediaType.APPLICATION_JSON) + .content("{\"expectedAddressRevision\":1}"); + if (!role.equals("ANONYMOUS")) { + String token = role.equals("ONBOARDING") ? tokens.createOnboardingToken(1L) + : tokens.createAccessToken(1L, "ROLE_USER"); + read.header("Authorization", "Bearer " + token); + retry.header("Authorization", "Bearer " + token); + } + int expected = role.equals("ANONYMOUS") ? 401 : 403; + mvc.perform(read).andExpect(status().is(expected)); + mvc.perform(retry).andExpect(status().is(expected)); + verifyNoInteractions(restaurants, admins); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', textBlock = """ + {} | 주소 버전은 필수입니다 + {"expectedAddressRevision":null} | 주소 버전은 필수입니다 + {"expectedAddressRevision":-1} | 주소 버전은 0 이상입니다 + """) + void 누락과_null과_음수_revision은_400과_한국어_필드_메시지로_거부한다(String body, String reason) throws Exception { + mvc.perform(post(PATH + "/retry").header("Authorization", admin()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("COMMON-400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다")) + .andExpect(jsonPath("$.path").value(PATH + "/retry")) + .andExpect(jsonPath("$.errors.length()").value(1)) + .andExpect(jsonPath("$.errors[0].field").value("expectedAddressRevision")) + .andExpect(jsonPath("$.errors[0].reason").value(reason)); + verifyNoInteractions(restaurants); + } + + @Test + void 숫자가_아닌_revision은_기존_400_봉투로_거부한다() throws Exception { + mvc.perform(post(PATH + "/retry").header("Authorization", admin()).contentType(MediaType.APPLICATION_JSON) + .content("{\"expectedAddressRevision\":\"bad\"}")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("COMMON-400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다")) + .andExpect(jsonPath("$.errors").doesNotExist()); + verifyNoInteractions(restaurants); + } + + @Test + void revision과_READY_충돌은_RESTAURANT019를_유지한다() throws Exception { + given(restaurants.retryLocationByAdmin(1L, 1)) + .willThrow(new BusinessException(RestaurantErrorCode.LOCATION_RETRY_CONFLICT)); + mvc.perform(post(PATH + "/retry").header("Authorization", admin()).contentType(MediaType.APPLICATION_JSON) + .content("{\"expectedAddressRevision\":1}")) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("RESTAURANT-019")); + } + + @Test + void ADMIN은_기본_REVIEW_REQUIRED_필터와_offset_응답을_사용한다() throws Exception { + var item = new RestaurantLocationReviewInfo( + 10L, "검토 식당", "東京都豊島区1-1", null, "REVIEW_REQUIRED", null, + "GEOCODING", 2, null, 1, null, "NO_RESULTS", true, 7L); + given(admins.findLoginIds(Set.of(7L))).willReturn(Map.of(7L, "hashi_admin")); + given(restaurants.findLocationReviewsByAdmin("REVIEW_REQUIRED", null, 0, 20)) + .willReturn(new RestaurantLocationReviewPage(List.of(item), 0, 20, 1, 1)); + + mvc.perform(get(LIST_PATH).header("Authorization", admin())) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("COMMON-200")) + .andExpect(jsonPath("$.data.restaurants[0].restaurantId").value(10)) + .andExpect(jsonPath("$.data.restaurants[0].locationStatus").value("REVIEW_REQUIRED")) + .andExpect(jsonPath("$.data.restaurants[0].failureCode").value("NO_RESULTS")) + .andExpect(jsonPath("$.data.restaurants[0].confirmedByAdminId").value(7)) + .andExpect(jsonPath("$.data.restaurants[0].confirmedByAdminLoginId").value("hashi_admin")) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.totalPages").value(1)); + verify(restaurants).findLocationReviewsByAdmin("REVIEW_REQUIRED", null, 0, 20); + } + + @Test + void ADMIN은_page_size_status_source를_port에_전달한다() throws Exception { + given(restaurants.findLocationReviewsByAdmin("READY", "GOOGLE_PLACES", 1, 10)) + .willReturn(new RestaurantLocationReviewPage(List.of(), 1, 10, 15, 2)); + + mvc.perform(get(LIST_PATH + "?status=READY&source=GOOGLE_PLACES&page=1&size=10") + .header("Authorization", admin())) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.restaurants.length()").value(0)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(10)) + .andExpect(jsonPath("$.data.totalCount").value(15)) + .andExpect(jsonPath("$.data.totalPages").value(2)); + verify(restaurants).findLocationReviewsByAdmin("READY", "GOOGLE_PLACES", 1, 10); + } + + @ParameterizedTest + @ValueSource(strings = {"USER", "ONBOARDING", "ANONYMOUS"}) + void 일반회원과_온보딩과_익명은_위치_검토_목록도_차단한다(String role) throws Exception { + var request = get(LIST_PATH); + if (!role.equals("ANONYMOUS")) { + String token = role.equals("ONBOARDING") ? tokens.createOnboardingToken(1L) + : tokens.createAccessToken(1L, "ROLE_USER"); + request.header("Authorization", "Bearer " + token); + } + mvc.perform(request).andExpect(status().is(role.equals("ANONYMOUS") ? 401 : 403)); + verifyNoInteractions(restaurants); + } + + @ParameterizedTest + @ValueSource(strings = { + "?status=UNKNOWN", + "?source=PLACES", + "?page=-1", + "?page=2147483647", + "?page=2147483648", + "?size=0", + "?size=101" + }) + void 위치_검토_목록의_지원하지_않는_필터와_범위는_400으로_거부한다(String query) throws Exception { + mvc.perform(get(LIST_PATH + query).header("Authorization", admin())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-400")); + verifyNoInteractions(restaurants); + } + + @Test + void ADMIN은_POST로_Places_빈후보를_조회하고_서명후보를_선택한다() throws Exception { + given(restaurants.searchLocationPlacesByAdmin(1L, 2)) + .willReturn(new RestaurantPlacesSearchInfo(1L, 2, List.of())); + var pending = new RestaurantLocationInfo(1L, "PENDING", 2, null, "PLACE_DETAILS", + null, 0, null, null, false); + given(restaurants.selectLocationPlaceByAdmin(1L, 2, "signed-token")).willReturn(pending); + + mvc.perform(post(PATH + "/place-candidates").header("Authorization", admin()) + .contentType(MediaType.APPLICATION_JSON).content("{\"expectedAddressRevision\":2}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.restaurantId").value(1)) + .andExpect(jsonPath("$.data.addressRevision").value(2)) + .andExpect(jsonPath("$.data.candidates.length()").value(0)); + + mvc.perform(post(PATH + "/place-selection").header("Authorization", admin()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"expectedAddressRevision\":2,\"selectionToken\":\"signed-token\"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.verificationMode").value("PLACE_DETAILS")); + verify(restaurants).searchLocationPlacesByAdmin(1L, 2); + verify(restaurants).selectLocationPlaceByAdmin(1L, 2, "signed-token"); + } + + @ParameterizedTest + @ValueSource(strings = {"/place-candidates", "/place-selection"}) + void 일반회원은_Places_관리자_API를_사용할수없다(String suffix) throws Exception { + String body = suffix.equals("/place-selection") + ? "{\"expectedAddressRevision\":2,\"selectionToken\":\"signed-token\"}" + : "{\"expectedAddressRevision\":2}"; + mvc.perform(post(PATH + suffix) + .header("Authorization", "Bearer " + tokens.createAccessToken(1L, "ROLE_USER")) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isForbidden()); + verifyNoInteractions(restaurants); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', textBlock = """ + /place-candidates | {"expectedAddressRevision":0} + /place-selection | {"expectedAddressRevision":2,"selectionToken":" "} + /place-selection | {"selectionToken":"signed-token"} + """) + void Places_요청의_누락과_잘못된값은_provider호출전_400이다(String suffix, String body) throws Exception { + mvc.perform(post(PATH + suffix).header("Authorization", admin()) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("COMMON-400")); + verifyNoInteractions(restaurants); + } + + @Test + void 등록전_미리보기는_ADMIN만_검색하고_빈후보도_성공응답으로_구분한다() throws Exception { + var input = new RestaurantLocationPreviewCommand("식당", "現地名", "住所", null); + given(restaurants.previewLocationByAdmin(input)).willReturn(List.of()); + mvc.perform(post("/api/v1/admin/restaurants/location-preview").header("Authorization", admin()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"식당\",\"localName\":\"現地名\",\"address\":\"住所\"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.candidates.length()").value(0)); + verify(restaurants).previewLocationByAdmin(input); + } + + @ParameterizedTest + @ValueSource(strings = {"USER", "ONBOARDING", "ANONYMOUS"}) + void 등록전_미리보기는_일반회원과_익명의_유료호출을_차단한다(String role) throws Exception { + var request = post("/api/v1/admin/restaurants/location-preview").contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"식당\",\"localName\":\"現地名\",\"address\":\"住所\"}"); + if (!role.equals("ANONYMOUS")) { + String token = role.equals("ONBOARDING") ? tokens.createOnboardingToken(1L) + : tokens.createAccessToken(1L, "ROLE_USER"); + request.header("Authorization", "Bearer " + token); + } + mvc.perform(request).andExpect(role.equals("ANONYMOUS") ? status().isUnauthorized() : status().isForbidden()); + verifyNoInteractions(restaurants); + } + + @Test + void 등록전_미리보기의_공백기본주소는_유료호출전에_거부한다() throws Exception { + mvc.perform(post("/api/v1/admin/restaurants/location-preview").header("Authorization", admin()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"식당\",\"localName\":\"現地名\",\"address\":\"住所\",\"geocodingAddress\":\" \"}")) + .andExpect(status().isBadRequest()); + verifyNoInteractions(restaurants); + } + + private String admin() { + return "Bearer " + tokens.createAccessToken(1L, "ROLE_ADMIN"); + } +} diff --git a/src/test/java/org/sopt/hashi/auth/internal/security/CollectionMapHttpMySqlTest.java b/src/test/java/org/sopt/hashi/auth/internal/security/CollectionMapHttpMySqlTest.java new file mode 100644 index 00000000..227b5847 --- /dev/null +++ b/src/test/java/org/sopt/hashi/auth/internal/security/CollectionMapHttpMySqlTest.java @@ -0,0 +1,234 @@ +package org.sopt.hashi.auth.internal.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.internal.jwt.AuthRoles; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.auth.internal.jwt.MemberPrincipal; +import org.sopt.hashi.auth.internal.onboarding.OnboardingJwtIssuer; +import org.sopt.hashi.auth.internal.token.OnboardingTokenStore; +import org.sopt.hashi.auth.internal.token.TokenBlacklist; +import org.sopt.hashi.config.JpaAuditingConfig; +import org.sopt.hashi.config.TimeConfig; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.user.collection.domain.CollectionColor; +import org.sopt.hashi.user.collection.domain.CollectionVisibility; +import org.sopt.hashi.user.collection.domain.RestaurantCollection; +import org.sopt.hashi.user.collection.domain.RestaurantCollectionRepository; +import org.sopt.hashi.user.collection.domain.SavedRestaurantRepository; +import org.sopt.hashi.user.collection.dto.MoveSavedRestaurantsRequest; +import org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest; +import org.sopt.hashi.user.collection.service.CollectionMapQueryService; +import org.sopt.hashi.user.collection.service.CollectionMapSnapshotStore; +import org.sopt.hashi.user.collection.service.RestaurantCollectionService; +import org.sopt.hashi.user.collection.web.CollectionMapController; +import org.sopt.hashi.user.domain.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.http.HttpHeaders; +import org.springframework.orm.jpa.EntityManagerHolder; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** 실제 HTTP/OSIV/컬렉션 JPA만 로딩한다. 다른 도메인 구현은 Port로 격리한다. */ +@Testcontainers(disabledWithoutDocker = true) +@WebMvcTest(controllers = CollectionMapController.class, + excludeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = OnboardingJwtIssuer.class)) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtProvider.class, CookieUtil.class, OriginValidator.class, + JwtAuthenticationEntryPoint.class, JwtAccessDeniedHandler.class, CurrentUserProviderImpl.class, + TimeConfig.class, JpaAuditingConfig.class, + CollectionMapHttpMySqlTest.Persistence.class}) +@ImportAutoConfiguration({DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class, + TransactionAutoConfiguration.class, FlywayAutoConfiguration.class}) +@TestPropertySource(properties = { + "spring.jpa.open-in-view=true", "spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", "jwt.access-token-ttl=30m", + "jwt.refresh-token-ttl=14d", "jwt.onboarding-token-ttl=30m", "kakao.client-id=test-client-id", + "kakao.redirect-uri=https://app.hashi.com/callback", "hashi.cors.allowed-origins=https://app.hashi.com", + "springdoc.api-docs.enabled=false", "springdoc.swagger-ui.enabled=false" +}) +class CollectionMapHttpMySqlTest { + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi").withUsername("hashi").withPassword("hashi"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + } + + @TestConfiguration + @EntityScan(basePackageClasses = RestaurantCollection.class) + @EnableJpaRepositories(basePackageClasses = RestaurantCollectionRepository.class) + @ComponentScan(basePackageClasses = RestaurantCollectionService.class) + static class Persistence { } + + @Autowired MockMvc mvc; + @Autowired JwtProvider jwt; + @Autowired RestaurantCollectionRepository collections; + @Autowired SavedRestaurantRepository saved; + @Autowired RestaurantCollectionService writes; + @Autowired EntityManagerFactory entityManagerFactory; + @MockitoBean OnboardingTokenStore onboardingTokenStore; + @MockitoBean TokenBlacklist tokenBlacklist; + @MockitoBean RestaurantPort restaurants; + @MockitoBean MediaPort media; + @MockitoBean UserRepository userRepository; + @MockitoBean(name = "japanClock") Clock clock; + + @BeforeEach + void setUp() { + given(clock.instant()).willReturn(Instant.parse("2026-10-01T00:00:00Z")); + given(restaurants.findActiveMapInfos(anyCollection())).willAnswer(invocation -> { + Collection ids = invocation.getArgument(0); + return ids.stream().map(this::info).toList(); + }); + } + + @AfterEach + void clean() { + SecurityContextHolder.clearContext(); + saved.deleteAllInBatch(); + collections.deleteAllInBatch(); + } + + @Test + void 공개_익명_전체핀과_비공개_소유USER만_허용하며_쓰기는_계속_보호한다() throws Exception { + Long publicId = seed("공개", CollectionVisibility.PUBLIC, 1L, 2L); + Long privateId = seed("비공개", CollectionVisibility.PRIVATE, 1L); + mvc.perform(get(path(publicId))).andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.data.visibleRestaurantCount").value(2)) + .andExpect(jsonPath("$.data.content.length()").value(2)) + .andExpect(jsonPath("$.data.generatedAt").value("2026-10-01T00:00:00Z")); + mvc.perform(get(path(privateId))).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("USER-006")); + mvc.perform(get(path(privateId)).header(HttpHeaders.AUTHORIZATION, + "Bearer " + jwt.createAccessToken(1L, AuthRoles.ADMIN))) + .andExpect(status().isNotFound()); + mvc.perform(get(path(privateId)).header(HttpHeaders.AUTHORIZATION, + "Bearer " + jwt.createAccessToken(1L, AuthRoles.USER))) + .andExpect(status().isOk()); + mvc.perform(get(path(0L))).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("COMMON-400")); + mvc.perform(get(path(Long.MAX_VALUE))).andExpect(status().isNotFound()); + mvc.perform(post("/api/v1/collections/" + publicId + "/restaurants")) + .andExpect(status().isUnauthorized()); + } + + @Test + void OSIV_HTTP_조회중_비공개_전환은_stale_Entity가_있어도_404다() throws Exception { + Long id = seed("OSIV", CollectionVisibility.PUBLIC, 1L); + AtomicBoolean staleEntity = new AtomicBoolean(); + var result = race(id, false, () -> writes.update(id, new UpdateRestaurantCollectionRequest(null, null, null, "private")), + staleEntity); + status().isNotFound().match(result); + jsonPath("$.code").value("USER-006").match(result); + assertThat(staleEntity).isTrue(); + } + + @Test + void HTTP_조회중_삭제는_404이고_이동은_409다() throws Exception { + Long deleted = seed("삭제", CollectionVisibility.PUBLIC, 1L); + status().isNotFound().match(race(deleted, false, () -> writes.delete(deleted), new AtomicBoolean())); + Long source = seed("원본", CollectionVisibility.PUBLIC, 1L); + Long target = seed("대상", CollectionVisibility.PUBLIC); + var moved = race(source, true, + () -> writes.moveRestaurants(source, new MoveSavedRestaurantsRequest(target, List.of(1L))), new AtomicBoolean()); + status().isConflict().match(moved); + jsonPath("$.code").value("COMMON-409").match(moved); + } + + private org.springframework.test.web.servlet.MvcResult race(Long id, boolean owner, Runnable mutation, + AtomicBoolean staleEntity) throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + org.mockito.Mockito.doAnswer(invocation -> { + assertThat(TransactionSynchronizationManager.hasResource(entityManagerFactory)).isTrue(); + entered.countDown(); + assertThat(release.await(30, TimeUnit.SECONDS)).isTrue(); + var holder = (EntityManagerHolder) TransactionSynchronizationManager.getResource(entityManagerFactory); + staleEntity.set(holder.getEntityManager().find(RestaurantCollection.class, id).isPublic()); + return List.of(info(1L)); + }).when(restaurants).findActiveMapInfos(anyCollection()); + try (var executor = Executors.newSingleThreadExecutor()) { + var request = get(path(id)); + if (owner) { + request.header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt.createAccessToken(1L, AuthRoles.USER)); + } + var future = executor.submit(() -> mvc.perform(request).andReturn()); + try { + assertThat(entered.await(30, TimeUnit.SECONDS)).isTrue(); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new MemberPrincipal(1L), null, List.of(new SimpleGrantedAuthority(AuthRoles.USER)))); + mutation.run(); + } finally { + SecurityContextHolder.clearContext(); + release.countDown(); + } + return future.get(30, TimeUnit.SECONDS); + } + } + + private Long seed(String name, CollectionVisibility visibility, Long... ids) { + var collection = RestaurantCollection.create(1L, name, CollectionColor.RED, null, visibility); + for (Long id : ids) { + collection.save(id); + } + return collections.saveAndFlush(collection).getId(); + } + + private String path(Long id) { + return "/api/v1/collections/" + id + "/map-markers"; + } + + private RestaurantMapInfo info(Long id) { + return new RestaurantMapInfo(id, "식당", "restaurant", "japanese", + new RestaurantMapInfo.LocationInfo(new BigDecimal("35.6"), new BigDecimal("139.7"), + Instant.parse("2026-10-02T00:00:00Z"))); + } +} diff --git a/src/test/java/org/sopt/hashi/auth/internal/security/RestaurantSaveSummaryHttpTest.java b/src/test/java/org/sopt/hashi/auth/internal/security/RestaurantSaveSummaryHttpTest.java new file mode 100644 index 00000000..920f665f --- /dev/null +++ b/src/test/java/org/sopt/hashi/auth/internal/security/RestaurantSaveSummaryHttpTest.java @@ -0,0 +1,91 @@ +package org.sopt.hashi.auth.internal.security; + +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.internal.jwt.AuthRoles; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.auth.internal.onboarding.OnboardingJwtIssuer; +import org.sopt.hashi.auth.internal.token.OnboardingTokenStore; +import org.sopt.hashi.auth.internal.token.TokenBlacklist; +import org.sopt.hashi.restaurant.RestaurantCardInfo; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.user.collection.domain.SavedRestaurantRepository; +import org.sopt.hashi.user.collection.service.RestaurantSaveSummaryService; +import org.sopt.hashi.user.collection.web.RestaurantSaveSummaryController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +/** 실제 Controller/Service/USER 판정과 SecurityFilterChain을 통과한다. DB/restaurant 구현만 모킹한다. */ +@WebMvcTest(controllers = RestaurantSaveSummaryController.class, + excludeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = OnboardingJwtIssuer.class)) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtProvider.class, + CookieUtil.class, OriginValidator.class, JwtAuthenticationEntryPoint.class, JwtAccessDeniedHandler.class, + CurrentUserProviderImpl.class, RestaurantSaveSummaryService.class}) +@TestPropertySource(properties = { + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", "jwt.access-token-ttl=30m", + "jwt.refresh-token-ttl=14d", "jwt.onboarding-token-ttl=30m", "kakao.client-id=test-client-id", + "kakao.redirect-uri=https://app.hashi.com/callback", "hashi.cors.allowed-origins=https://app.hashi.com", + "springdoc.api-docs.enabled=false", "springdoc.swagger-ui.enabled=false" +}) +class RestaurantSaveSummaryHttpTest { + @Autowired MockMvc mvc; + @Autowired JwtProvider jwt; + @MockitoBean OnboardingTokenStore onboardingTokenStore; + @MockitoBean TokenBlacklist tokenBlacklist; + @MockitoBean RestaurantPort restaurants; + @MockitoBean SavedRestaurantRepository saved; + + @BeforeEach + void setUp() { + given(restaurants.findActiveCards(anyCollection())).willReturn(List.of( + new RestaurantCardInfo(1L, "식당", "도쿄", "sushi", "restaurant", BigDecimal.ZERO, 0, null))); + } + + @Test + void 익명_집계는_공개이고_개인_조회는_인증이_필요하다() throws Exception { + mvc.perform(get("/api/v1/restaurants/save-counts").param("restaurantIds", "1")) + .andExpect(status().isOk()).andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.data.restaurants[0].saveCount").value(0)); + mvc.perform(get("/api/v1/users/me/restaurant-saves").param("restaurantIds", "1")) + .andExpect(status().isUnauthorized()).andExpect(jsonPath("$.code").value("COMMON-401")); + } + + @Test + void USER만_내_저장_조회가_가능하고_ADMIN과_ONBOARDING은_403이다() throws Exception { + mvc.perform(get("/api/v1/users/me/restaurant-saves").param("restaurantIds", "1") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt.createAccessToken(7L, AuthRoles.USER))) + .andExpect(status().isOk()).andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.data.restaurants[0].saved").value(false)); + for (String token : List.of(jwt.createAccessToken(7L, AuthRoles.ADMIN), jwt.createOnboardingToken(7L))) { + mvc.perform(get("/api/v1/users/me/restaurant-saves").param("restaurantIds", "1") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isForbidden()); + } + } + + @Test + void 잘못된_ID는_공통_400으로_응답한다() throws Exception { + for (String value : List.of("", "1,1", "0", "-1", "abc", "1,,2")) { + mvc.perform(get("/api/v1/restaurants/save-counts").param("restaurantIds", value)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("COMMON-400")); + } + mvc.perform(get("/api/v1/restaurants/save-counts")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("COMMON-400")); + } +} diff --git a/src/test/java/org/sopt/hashi/dev/DevLocationJobMySqlTest.java b/src/test/java/org/sopt/hashi/dev/DevLocationJobMySqlTest.java new file mode 100644 index 00000000..e26fa17d --- /dev/null +++ b/src/test/java/org/sopt/hashi/dev/DevLocationJobMySqlTest.java @@ -0,0 +1,128 @@ +package org.sopt.hashi.dev; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mockingDetails; + +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.dev.DevTokenResponse; +import org.sopt.hashi.auth.dev.DevTokenRole; +import org.sopt.hashi.auth.dev.DevTokenService; +import org.sopt.hashi.config.TimeConfig; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.reservation.dev.DevReservationDataGenerator; +import org.sopt.hashi.restaurant.dev.DevRestaurantDataGenerator; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.service.RestaurantLocationService; +import org.sopt.hashi.restaurant.service.RestaurantService; +import org.sopt.hashi.review.dev.DevReviewDataGenerator; +import org.sopt.hashi.shared.storage.FileStorage; +import org.sopt.hashi.user.dev.DevUserDataGenerator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({RestaurantService.class, RestaurantLocationService.class, TimeConfig.class, + DevLocationJobMySqlTest.Fixtures.class}) +@TestPropertySource(properties = {"spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true"}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class DevLocationJobMySqlTest { + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("dev_location_jobs").withUsername("hashi").withPassword("hashi"); + + @Autowired DevDataService scenarios; + @Autowired RestaurantRepository restaurants; + @Autowired JdbcTemplate jdbc; + @MockitoSpyBean RestaurantLocationJobRepository jobs; + @MockitoBean MediaPort mediaPort; + @MockitoBean FileStorage fileStorage; + @MockitoBean DevUserDataGenerator users; + @MockitoBean DevReservationDataGenerator reservations; + @MockitoBean DevReviewDataGenerator reviews; + @MockitoBean DevTokenService tokens; + + @BeforeEach + void reset() { + jdbc.update("DELETE FROM restaurant_location_job"); + // 위치 저장 밖의 개발용 회원/예약/리뷰/토큰 생성만 합성 응답으로 대체한다. + given(users.createUsers(10)).willReturn(List.of(901L)); + given(reviews.createReviews(anyLong(), anyList())).willReturn(List.of(801L)); + given(tokens.issue(DevTokenRole.USER, 901L)) + .willReturn(new DevTokenResponse("synthetic-token", "USER", 901L)); + } + + @Test + void 두_개발용_시나리오의_외부_transaction에서도_위치_작업이_함께_저장된다() throws Exception { + CyclicBarrier selected = new CyclicBarrier(2); + var realRepository = mockingDetails(jobs).getMockCreationSettings().getDefaultAnswer(); + doAnswer(invocation -> { + Object result = realRepository.answer(invocation); + selected.await(10, TimeUnit.SECONDS); + return result; + }).when(jobs).findActiveForUpdate(anyLong()); + long before = restaurants.count(); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(scenarios::createScenario); + var second = executor.submit(scenarios::createScenario); + assertThat(first.get(15, TimeUnit.SECONDS).restaurantId()).isNotNull(); + assertThat(second.get(15, TimeUnit.SECONDS).restaurantId()).isNotNull(); + } + assertThat(restaurants.count()).isEqualTo(before + 2); + assertThat(jobs.count()).isEqualTo(2); + } + + @Test + void 뒤의_시나리오_생성이_실패하면_식당과_위치_작업도_함께_rollback한다() { + long before = restaurants.count(); + given(users.createUsers(10)).willThrow(new IllegalStateException("synthetic scenario failure")); + assertThatThrownBy(scenarios::createScenario).isInstanceOf(IllegalStateException.class); + assertThat(restaurants.count()).isEqualTo(before); + assertThat(jobs.count()).isZero(); + } + + @TestConfiguration(proxyBeanMethods = false) + static class Fixtures { + // local/dev 프로필 전체를 활성화하지 않고 실제 두 transaction proxy만 등록한다. + @Bean + DevRestaurantDataGenerator restaurantGenerator(RestaurantService restaurants) { + return new DevRestaurantDataGenerator(restaurants); + } + + @Bean + DevDataService scenarioService(DevRestaurantDataGenerator restaurants, DevUserDataGenerator users, + DevReservationDataGenerator reservations, DevReviewDataGenerator reviews, + DevTokenService tokens) { + return new DevDataService(restaurants, users, reservations, reviews, tokens); + } + } +} diff --git a/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java index 1b789255..e775f7c9 100644 --- a/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java @@ -27,6 +27,7 @@ import org.sopt.hashi.restaurant.domain.RestaurantImage; import org.sopt.hashi.restaurant.domain.RestaurantRepository; import org.sopt.hashi.restaurant.service.RestaurantService; +import org.sopt.hashi.restaurant.service.RestaurantLocationService; import org.sopt.hashi.shared.storage.FileStorage; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @@ -42,6 +43,7 @@ MediaPortImpl.class, MediaPurposeAccessPolicy.class, RestaurantService.class, + RestaurantLocationService.class, TimeConfig.class }) @TestPropertySource(properties = { diff --git a/src/test/java/org/sopt/hashi/migration/FlywayMigrationTest.java b/src/test/java/org/sopt/hashi/migration/FlywayMigrationTest.java index de514d54..e288c076 100644 --- a/src/test/java/org/sopt/hashi/migration/FlywayMigrationTest.java +++ b/src/test/java/org/sopt/hashi/migration/FlywayMigrationTest.java @@ -7,6 +7,8 @@ import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import org.flywaydb.core.Flyway; import org.flywaydb.core.api.FlywayException; @@ -36,6 +38,70 @@ class FlywayMigrationTest { assertThat(flyway.validateWithResult().validationSuccessful).isTrue(); } + @Test + void V37의_식당과_컬렉션을_보존하고_지도_외부호출은_비활성으로_추가한다() throws SQLException { + Flyway throughV37 = Flyway.configure() + .dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("37")) + .cleanDisabled(false).load(); + throughV37.clean(); + throughV37.migrate(); + execute(""" + INSERT INTO restaurant (id, name, local_name, address, area, genre, food_category, + place_type, summary, description, price_currency, price_min, price_max, + rating, rating_sum, review_count, deleted) + VALUES (101, '보존 식당', '保存食堂', '東京都中央区月島3-16-9 1F', + 'GINZA', 'JAPANESE', 'JAPANESE', 'RESTAURANT', '요약', '설명', + 'JPY', 1000, 3000, 4.5, 9, 2, FALSE), + (102, '삭제 식당', '削除食堂', '1 Chome-9-1 Marunouchi, Tokyo', + 'GINZA', 'JAPANESE', 'JAPANESE', 'RESTAURANT', '요약', '설명', + 'JPY', 1000, 2000, 0, 0, 0, TRUE) + """); + execute(""" + INSERT INTO restaurant_menu (id, restaurant_id, name, description, + price_currency, price_amount, is_main) + VALUES (201, 101, '정식', '메뉴 설명', 'JPY', 1500, TRUE) + """); + execute("INSERT INTO restaurant_hashtag VALUES (101, '혼밥')"); + execute(""" + INSERT INTO restaurant_collection (id, user_id, name, color, visibility) + VALUES (301, 901, ' 여행 식당 ', 'BLUE', 'PRIVATE') + """); + execute("INSERT INTO saved_restaurant (id, collection_id, restaurant_id) VALUES (401, 301, 101)"); + List preservedQueries = List.of( + "SELECT id, name, local_name, address, rating, rating_sum, review_count, deleted FROM restaurant ORDER BY id", + "SELECT * FROM restaurant_menu ORDER BY id", + "SELECT * FROM restaurant_hashtag ORDER BY restaurant_id, hashtag", + "SELECT id, user_id, name, color, description, visibility, created_at, updated_at FROM restaurant_collection ORDER BY id", + "SELECT * FROM saved_restaurant ORDER BY id"); + List>> before = new ArrayList<>(); + for (String query : preservedQueries) { + before.add(rows(query)); + } + + Flyway upgraded = flyway(); + upgraded.migrate(); + + for (int index = 0; index < preservedQueries.size(); index++) { + assertThat(rows(preservedQueries.get(index))).isEqualTo(before.get(index)); + } + assertThat(rows("SELECT COUNT(*) FROM restaurant WHERE location_id IS NOT NULL OR map_region_id IS NOT NULL OR geocoding_address IS NOT NULL")) + .isEqualTo(List.of(List.of("0"))); + assertThat(rows("SELECT collection_version FROM restaurant_collection WHERE id = 301")) + .isEqualTo(List.of(List.of("0"))); + for (String table : List.of("restaurant_location", "restaurant_location_job", "map_region", + "restaurant_location_maintenance_run")) { + assertThat(rows("SELECT COUNT(*) FROM " + table)).isEqualTo(List.of(List.of("0"))); + } + assertThat(rows("SELECT COUNT(*) FROM restaurant_geocoding_budget WHERE enabled = TRUE")) + .isEqualTo(List.of(List.of("0"))); + assertThat(rows("SELECT COUNT(*) FROM restaurant_places_budget WHERE enabled = TRUE")) + .isEqualTo(List.of(List.of("0"))); + assertThat(upgraded.info().pending()).isEmpty(); + assertThat(upgraded.validateWithResult().validationSuccessful).isTrue(); + } + @Test void V17은_예상하지_않은_PROCESSING_row가_있으면_부분_DDL_없이_실패한다() throws SQLException { @@ -177,6 +243,22 @@ private void execute(String sql) throws SQLException { } } + private List> rows(String sql) throws SQLException { + try (Connection connection = connection(); + var statement = connection.createStatement(); + ResultSet result = statement.executeQuery(sql)) { + List> rows = new ArrayList<>(); + while (result.next()) { + List row = new ArrayList<>(); + for (int column = 1; column <= result.getMetaData().getColumnCount(); column++) { + row.add(result.getString(column)); + } + rows.add(row); + } + return rows; + } + } + private Connection connection() throws SQLException { return DriverManager.getConnection( MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/LocationRetentionPolicyTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/LocationRetentionPolicyTest.java new file mode 100644 index 00000000..ea2b658c --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/LocationRetentionPolicyTest.java @@ -0,0 +1,91 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.Test; + +class LocationRetentionPolicyTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + private static final LocalDateTime NOW = LocalDateTime.ofInstant(CLOCK.instant(), ZoneOffset.UTC); + + @Test + void 동일revision이어도_새요청결과는_오래된정리작업으로_제거하지않는다() { + var location = ready(RestaurantLocationSource.GOOGLE_GEOCODING); + var request = location.getRequestId(); + var obtained = location.getObtainedAt(); + var until = location.getValidUntil(); + location.beginRefresh(); + complete(location, RestaurantLocationSource.GOOGLE_GEOCODING); + assertThat(location.purgeGoogle(1, request, obtained, until, NOW.plusHours(2))).isFalse(); + assertThat(location.isUsable(CLOCK)).isTrue(); + } + + @Test + void 수명snapshot과_기한전경계를_함께확인하고_운영자좌표는_유지한다() { + var location = ready(RestaurantLocationSource.GOOGLE_GEOCODING); + var request = location.getRequestId(); + assertThat(location.purgeGoogle(1, request, NOW.minusHours(2), NOW.plusHours(1), NOW.plusHours(2))).isFalse(); + assertThat(location.purgeGoogle(1, request, NOW.minusHours(1), NOW.plusHours(1), NOW)).isFalse(); + assertThat(location.purgeGoogle(1, request, NOW.minusHours(1), NOW.plusHours(1), NOW.plusHours(1))).isTrue(); + assertThat(location.getStatus()).isEqualTo(RestaurantLocationStatus.REVIEW_REQUIRED); + assertThat(location.getCoordinates()).isNull(); + assertThat(location.getSource()).isNull(); + assertThat(location.getObtainedAt()).isNull(); + assertThat(location.getValidUntil()).isNull(); + assertThat(location.getRequestId()).isEqualTo(request); + var admin = ready(RestaurantLocationSource.ADMIN); + assertThat(admin.purgeGoogle(1, admin.getRequestId(), NOW.minusHours(1), NOW.plusHours(1), + NOW.plusHours(2))).isFalse(); + } + + @Test + void 갱신중_만료좌표만_지우고_진행중인_요청과_재시도시각은_보존한다() { + var location = ready(RestaurantLocationSource.GOOGLE_GEOCODING); + location.beginRefresh(); + var request = location.getRequestId(); + var next = NOW.plusHours(2); + assertThat(location.defer(1, request, next, CLOCK)).isTrue(); + assertThat(location.purgeGoogle(1, request, NOW.minusHours(1), NOW.plusHours(1), NOW.plusHours(1))).isTrue(); + assertThat(location.getCoordinates()).isNull(); + assertThat(location.getStatus()).isEqualTo(RestaurantLocationStatus.RETRY_WAIT); + assertThat(location.getRequestId()).isEqualTo(request); + assertThat(location.getNextAttemptAt()).isEqualTo(next); + } + + @Test + void Places_위치정리는_좌표와_Place_ID와_attribution을_함께지운다() { + var location = RestaurantLocation.pending(); + var attribution = new RestaurantLocationAttribution("Google Maps", "https://maps.google.com/"); + assertThat(location.complete(1, location.getRequestId(), + MapCoordinates.of(BigDecimal.TEN, BigDecimal.TEN), RestaurantLocationSource.GOOGLE_PLACES, + "place-retention", List.of(attribution), NOW.minusHours(1), NOW.plusHours(1), CLOCK)).isTrue(); + var request = location.getRequestId(); + + assertThat(location.purgeGoogle(1, request, NOW.minusHours(1), NOW.plusHours(1), + NOW.plusHours(1))).isTrue(); + + assertThat(location.getStatus()).isEqualTo(RestaurantLocationStatus.REVIEW_REQUIRED); + assertThat(location.getCoordinates()).isNull(); + assertThat(location.getSource()).isNull(); + assertThat(location.getGooglePlaceId()).isNull(); + assertThat(location.getPlacesAttributions()).isNull(); + assertThat(location.getRequestId()).isEqualTo(request); + } + + private RestaurantLocation ready(RestaurantLocationSource source) { + var location = RestaurantLocation.pending(); + complete(location, source); + return location; + } + + private void complete(RestaurantLocation location, RestaurantLocationSource source) { + assertThat(location.complete(1, location.getRequestId(), MapCoordinates.of(BigDecimal.TEN, BigDecimal.TEN), + source, NOW.minusHours(1), NOW.plusHours(1), CLOCK)).isTrue(); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/MapBoundsTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/MapBoundsTest.java new file mode 100644 index 00000000..5c66e412 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/MapBoundsTest.java @@ -0,0 +1,34 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class MapBoundsTest { + + @ParameterizedTest + @CsvSource({"10,20,true", "11,21,true", "10.5,20.5,true", "9.999999,20,false", + "11.000001,20,false", "10,19.999999,false", "10,21.000001,false"}) + void 네_경계를_포함하고_바깥_좌표는_제외한다(String latitude, String longitude, boolean expected) { + MapBounds bounds = bounds("10", "11", "20", "21"); + assertThat(bounds.contains(MapCoordinates.of(new BigDecimal(latitude), new BigDecimal(longitude)))) + .isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({"11,10,20,21", "10,10,20,21", "10,11,21,20", "10,11,20,20", + "10,11,179,-179", "-91,0,0,1", "0,91,0,1", "0,1,-181,0", "0,1,0,181", + "0.0000001,1,0,1", ",1,0,1"}) + void 역전과_빈_면적과_날짜변경선과_잘못된_좌표를_거절한다( + String south, String north, String west, String east) { + assertThatThrownBy(() -> bounds(south, north, west, east)).isInstanceOf(IllegalArgumentException.class); + } + + private MapBounds bounds(String south, String north, String west, String east) { + return MapBounds.of(south == null ? null : new BigDecimal(south), new BigDecimal(north), + new BigDecimal(west), new BigDecimal(east)); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/MapCoordinatesTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/MapCoordinatesTest.java new file mode 100644 index 00000000..e118e3c1 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/MapCoordinatesTest.java @@ -0,0 +1,47 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MapCoordinatesTest { + + @ParameterizedTest + @CsvSource({"-90,-180", "90,180", "0,0", "35.1234560,139.6543210"}) + void 세계_경계와_영점은_유효하고_불필요한_후행_영은_제거한다(String latitude, String longitude) { + MapCoordinates point = MapCoordinates.of(new BigDecimal(latitude), new BigDecimal(longitude)); + assertThat(point.getLatitude().scale()).isEqualTo(6); + assertThat(point.getLongitude().scale()).isEqualTo(6); + assertThat(point.getLatitude()).isEqualByComparingTo(latitude); + assertThat(point.getLongitude()).isEqualByComparingTo(longitude); + } + + @ParameterizedTest + @CsvSource({"90.000001,0", "-90.000001,0", "0,180.000001", "0,-180.000001", + "35.1234567,139", "35,139.1234567", "139,35"}) + void 범위_초과와_정밀도_손실과_뒤집힌_도쿄_좌표를_거절한다(String latitude, String longitude) { + assertThatThrownBy(() -> MapCoordinates.of(new BigDecimal(latitude), new BigDecimal(longitude))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 한쪽만_있거나_모두_없는_좌표_객체를_만들_수_없다() { + assertThatThrownBy(() -> MapCoordinates.of(null, BigDecimal.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> MapCoordinates.of(BigDecimal.ZERO, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> MapCoordinates.of(null, null)).isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"NaN", "Infinity", "-Infinity"}) + void 비유한_문자열은_BigDecimal_좌표로_변환되지_않는다(String value) { + assertThatThrownBy(() -> MapCoordinates.of(new BigDecimal(value), BigDecimal.ZERO)) + .isInstanceOf(NumberFormatException.class); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/MapRegionTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/MapRegionTest.java new file mode 100644 index 00000000..f95ce2f9 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/MapRegionTest.java @@ -0,0 +1,64 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class MapRegionTest { + + @Test + void 지역은_합성_대표_위치와_범위를_갖고_명시적으로_활성화한다() { + MapRegion region = region("FIXTURE_AREA", "합성 지역", 0); + assertThat(region.isActive()).isFalse(); + region.activate(); + assertThat(region.isActive()).isTrue(); + region.deactivate(); + assertThat(region.isActive()).isFalse(); + assertThat(region.getCameraBounds().contains(region.getClusterPosition())).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"lower_case", "1AREA", "AREA-1", "AREA ", "한글"}) + void 불안정한_지역_코드를_거절한다(String code) { + assertThatThrownBy(() -> region(code, "합성 지역", 0)).isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n", "\r\n", "\t \n", "\u001C", "\u0085", + "\u00A0", "\u2007", "\u202F", "\u3000", "\u001C\u00A0"}) + void 공백_문자로만_된_지역명을_거절한다(String name) { + assertThatThrownBy(() -> region("AREA", name, 0)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 범위_밖_대표_위치와_빈_이름과_초과_길이와_음수_순서를_거절한다() { + assertThatThrownBy(() -> region("A".repeat(41), "지역", 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> region("AREA", " ", 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> region("AREA", "가".repeat(101), 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> region("AREA", "지역", -1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> MapRegion.create("AREA", "지역", point("12", "20"), bounds(), 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + private MapRegion region(String code, String name, int order) { + return MapRegion.create(code, name, point("10", "20"), bounds(), order); + } + + private MapCoordinates point(String latitude, String longitude) { + return MapCoordinates.of(new BigDecimal(latitude), new BigDecimal(longitude)); + } + + private MapBounds bounds() { + return MapBounds.of(new BigDecimal("10"), new BigDecimal("11"), + new BigDecimal("20"), new BigDecimal("21")); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/MapSearchCriteriaTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/MapSearchCriteriaTest.java new file mode 100644 index 00000000..af8e79f7 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/MapSearchCriteriaTest.java @@ -0,0 +1,112 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; + +class MapSearchCriteriaTest { + + @Test + void SDK의_긴_소수점_경계는_축소하거나_반올림하지_않는다() { + MapQueryBounds bounds = MapQueryBounds.parse("0.000000000001", "0.999999999999", + "0.000000000002", "0.999999999998"); + bounds.validateQueryWithin(MapQueryBounds.parse("0", "1", "0", "1")); + assertThat(bounds.south()).isEqualByComparingTo("0.000000000001"); + assertThat(bounds.east()).isEqualByComparingTo("0.999999999998"); + } + + @ParameterizedTest + @CsvSource({"NaN,1,0,1", "0,Infinity,0,1", "-91,0,0,1", "0,91,0,1", "0,1,-181,0", + "0,1,0,181", "1,0,0,1", "0,0,0,1", "0,1,180,-180", "0,1,1,1"}) + void 유한하지_않거나_범위_역전_날짜변경선_경계는_거절한다(String south, String north, String west, String east) { + assertThatThrownBy(() -> MapQueryBounds.parse(south, north, west, east)) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_BOUNDS_INVALID)); + } + + @ParameterizedTest + @CsvSource({"0,1.00000001,0,1", "0,1,0,1.00000001", "-0.000001,0.5,0,1"}) + void 일도_초과와_지원영역_밖을_거절한다(String south, String north, String west, String east) { + assertThatThrownBy(() -> MapQueryBounds.parse(south, north, west, east) + .validateQueryWithin(MapQueryBounds.parse("0", "1", "0", "1"))) + .isInstanceOf(BusinessException.class); + } + + @Test + void Unicode_앞뒤공백을_제거하고_30자_코드포인트와_OR_검색어를_보존한다() { + var criteria = criteria(" \u00a0\u3000 A\u00a0\u3000B %_!\\ \u00a0\u3000 "); + assertThat(criteria.keyword()).isEqualTo("A\u00a0\u3000B %_!\\"); + assertThat(criteria.keywordPatterns()).containsExactly("%a%", "%b%", "%!%!_!!\\%"); + assertThat(criteria("가".repeat(30)).keyword()).hasSize(30); + assertThat(criteria("😀".repeat(30)).keyword().codePointCount(0, 60)).isEqualTo(30); + assertThatThrownBy(() -> criteria("가".repeat(31))).isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> criteria("😀".repeat(31))).isInstanceOf(BusinessException.class); + } + + @Test + void 샵_접두어는_공백없는_단일_해시태그_부분검색으로_해석한다() { + var criteria = criteria(" \u3000#NiGhT%_! \u00a0"); + assertThat(criteria.keyword()).isEqualTo("#NiGhT%_!"); + assertThat(criteria.hashtagOnly()).isTrue(); + assertThat(criteria.keywordPatterns()).containsExactly("%night!%!_!!%"); + } + + @ParameterizedTest + @ValueSource(strings = {"#", "# ", "# tag", "#tag name", "#tag\u00a0name", "#tag\u3000name"}) + void 비어있거나_공백이_섞인_해시태그_검색어는_거절한다(String keyword) { + assertThatThrownBy(() -> criteria(keyword)).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\u00a0\u3000"}) + void 명시적인_빈_검색어는_기존_지도_계약대로_거절한다(String keyword) { + assertThatThrownBy(() -> criteria(keyword)).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); + } + + @ParameterizedTest + @ValueSource(strings = {"a\nb", "a\tb", "a\rb", "a\u0000b", "a\u007fb", "a\u2028b", "a\u200bb"}) + void 제어문자를_거절한다(String keyword) { + assertThatThrownBy(() -> criteria(keyword)).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); + } + + @ParameterizedTest + @ValueSource(strings = {"", "all", "SUSHI", "foodCategory"}) + void 장르는_고정된_wire_값만_허용한다(String genre) { + assertThatThrownBy(() -> MapSearchCriteria.of(bounds(), null, genre, null, null)) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.UNSUPPORTED_GENRE)); + } + + @ParameterizedTest + @ValueSource(strings = {"", "all", "CAFE", "sushi"}) + void 음식점_분류는_고정된_wire_값만_허용한다(String placeType) { + assertThatThrownBy(() -> MapSearchCriteria.of(bounds(), null, null, placeType, null)) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.UNSUPPORTED_PLACE_TYPE)); + } + + @Test + void 지역_ID는_양수이며_필터_생략은_null로_유지한다() { + assertThatThrownBy(() -> MapSearchCriteria.of(bounds(), 0L, null, null, null)) + .isInstanceOf(BusinessException.class); + assertThat(criteria(null).keywordPatterns()).isEmpty(); + } + + private MapSearchCriteria criteria(String keyword) { + return MapSearchCriteria.of(bounds(), null, null, null, keyword); + } + + private MapQueryBounds bounds() { + return MapQueryBounds.parse("0", "1", "0", "1"); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantLocationTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantLocationTest.java new file mode 100644 index 00000000..7d2a843e --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantLocationTest.java @@ -0,0 +1,438 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class RestaurantLocationTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + private static final LocalDateTime NOW = LocalDateTime.ofInstant(CLOCK.instant(), ZoneOffset.UTC); + private static final MapCoordinates POINT = MapCoordinates.of(new BigDecimal("10.123456"), + new BigDecimal("20.654321")); + + @Test + void 기존_식당은_위치와_관광_지역이_없어도_유효하다() { + Restaurant restaurant = restaurant(); + assertThat(restaurant.getLocation()).isNull(); + assertThat(restaurant.getMapRegionId()).isNull(); + assertThat(restaurant.hasUsableMapLocation(CLOCK)).isFalse(); + assertThat(restaurant.getArea()).isEqualTo("표시 지역"); + } + + @Test + void 미분류_READY는_사용_가능하고_만료_순간부터_제외한다() { + Restaurant restaurant = pending(); + assertThat(complete(restaurant, 1, request(restaurant))).isTrue(); + assertThat(restaurant.hasUsableMapLocation(CLOCK.withZone(ZoneId.of("Asia/Tokyo")))).isTrue(); + assertThat(restaurant.getMapRegionId()).isNull(); + assertThat(restaurant.hasUsableMapLocation(Clock.offset(CLOCK, java.time.Duration.ofDays(1)))).isFalse(); + assertThat(complete(restaurant, 1, request(restaurant))).isFalse(); + } + + @Test + void 주소_변경은_좌표를_제거하고_지역을_보존하며_이전_결과를_막는다() { + Restaurant restaurant = pending(); + restaurant.assignMapRegion(7L); + UUID previousRequest = request(restaurant); + complete(restaurant, 1, previousRequest); + + updateAddress(restaurant, "새 합성 주소"); + + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(2); + assertThat(request(restaurant)).isNotEqualTo(previousRequest); + assertPendingWithoutCoordinates(restaurant); + assertThat(restaurant.getMapRegionId()).isEqualTo(7L); + assertThat(complete(restaurant, 1, previousRequest)).isFalse(); + assertThat(complete(restaurant, 1, request(restaurant))).isFalse(); + assertThat(complete(restaurant, 2, request(restaurant))).isTrue(); + } + + @Test + void 동일_주소와_null_PATCH는_revision과_READY와_통계를_보존한다() { + Restaurant restaurant = pending(); + complete(restaurant, 1, request(restaurant)); + UUID request = request(restaurant); + updateAddress(restaurant, restaurant.getAddress()); + updateAddress(restaurant, null); + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(1); + assertThat(request(restaurant)).isEqualTo(request); + assertThat(restaurant.hasUsableMapLocation(CLOCK)).isTrue(); + assertThat(restaurant.getReviewCount()).isZero(); + assertThat(restaurant.getRating()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + void 위치확인주소_변경과_삭제는_revision을_올리고_이전_결과를_막는다() { + Restaurant restaurant = restaurantWithGeocodingAddress("\u00A0東京都試験区架空町1丁目2番3号\u202F"); + restaurant.requestLocationResolution(); + UUID firstRequest = request(restaurant); + assertThat(restaurant.geocodingAddressForResolution()).isEqualTo("東京都試験区架空町1丁目2番3号"); + + updateGeocodingAddress(restaurant, "\u2007東京都試験区架空町1丁目2番4号\u00A0"); + + assertThat(restaurant.getGeocodingAddress()).isEqualTo("東京都試験区架空町1丁目2番4号"); + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(2); + assertThat(complete(restaurant, 1, firstRequest)).isFalse(); + UUID secondRequest = request(restaurant); + + updateGeocodingAddress(restaurant, "\u00A0\u2007\u202F"); + + assertThat(restaurant.getGeocodingAddress()).isNull(); + assertThat(restaurant.geocodingAddressForResolution()).isEqualTo(restaurant.getAddress()); + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(3); + assertThat(complete(restaurant, 2, secondRequest)).isFalse(); + } + + @Test + void 표시주소를_바꾸며_위치확인주소를_생략하면_과거_override를_지운다() { + Restaurant restaurant = restaurantWithGeocodingAddress("東京都試験区架空町1丁目2番3号"); + restaurant.requestLocationResolution(); + + updateAddress(restaurant, "東京都試験区別町4丁目5番6号 새 건물 2F"); + + assertThat(restaurant.getGeocodingAddress()).isNull(); + assertThat(restaurant.geocodingAddressForResolution()).isEqualTo("東京都試験区別町4丁目5番6号 새 건물 2F"); + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(2); + } + + @Test + void 같은_위치확인주소라도_표시주소가_바뀌면_진행중요청을_무효화한다() { + String base = "東京都試験区架空町1丁目2番3号"; + Restaurant restaurant = restaurantWithGeocodingAddress(base); + restaurant.requestLocationResolution(); + UUID currentRequest = request(restaurant); + + restaurant.updateBasicInfo(null, null, null, null, "표시용 전체 주소 架空ビル2F", base, + null, null, null, null, null, null, null); + + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(2); + assertThat(request(restaurant)).isNotEqualTo(currentRequest); + assertThat(restaurant.geocodingAddressForResolution()).isEqualTo(base); + } + + @Test + void 식당명과_현지명_변경은_관리자확인을_지우고_같은값_PATCH는_유지한다() { + Restaurant restaurant = pending(); + restaurant.confirmPlaceSelection(7L, NOW); + restaurant.completePlacesLocation(1, request(restaurant), POINT, "selected-place", + java.util.List.of(), NOW, NOW.plusDays(1), CLOCK); + UUID oldRequest = request(restaurant); + restaurant.updateBasicInfo(restaurant.getName(), restaurant.getLocalName(), null, null, + restaurant.getAddress(), null, null, null, null, null, null, null); + assertThat(request(restaurant)).isEqualTo(oldRequest); + assertThat(restaurant.getLocation().getConfirmedByAdminId()).isEqualTo(7L); + + restaurant.updateBasicInfo("다른 지점", "別支店", null, null, null, + null, null, null, null, null, null, null); + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(2); + assertThat(restaurant.getLocation().getConfirmedByAdminId()).isNull(); + assertThat(restaurant.getLocation().getConfirmedAt()).isNull(); + assertThat(restaurant.getLocation().getConfirmationMethod()).isNull(); + assertThat(restaurant.hasUsableMapLocation(CLOCK)).isFalse(); + assertThat(complete(restaurant, 1, oldRequest)).isFalse(); + } + + @ParameterizedTest + @EnumSource(value = RestaurantLocationStatus.class, names = {"REVIEW_REQUIRED", "FAILED"}) + void 실패_후_같은_주소의_재요청도_이전_요청을_구별한다(RestaurantLocationStatus outcome) { + Restaurant restaurant = pending(); + UUID oldRequest = request(restaurant); + restaurant.requestLocationResolution(); + assertThat(request(restaurant)).isEqualTo(oldRequest); + assertThat(restaurant.rejectLocation(1, oldRequest, outcome)).isTrue(); + assertThat(restaurant.retryLocationWhenDue(CLOCK)).isFalse(); + restaurant.requestLocationResolution(); + assertThat(request(restaurant)).isNotEqualTo(oldRequest); + assertThat(complete(restaurant, 1, oldRequest)).isFalse(); + assertThat(restaurant.rejectLocation(1, oldRequest, outcome)).isFalse(); + assertThat(complete(restaurant, 1, request(restaurant))).isTrue(); + } + + @Test + void 재시도는_예정시각부터_새_요청으로_시작하고_주소_변경은_대기를_없앤다() { + Restaurant restaurant = pending(); + UUID oldRequest = request(restaurant); + assertThat(restaurant.deferLocation(1, oldRequest, NOW.plusMinutes(1), CLOCK)).isTrue(); + assertThat(restaurant.retryLocationWhenDue(CLOCK)).isFalse(); + assertThat(complete(restaurant, 1, oldRequest)).isFalse(); + Clock due = Clock.offset(CLOCK, java.time.Duration.ofMinutes(1)); + assertThat(restaurant.retryLocationWhenDue(due)).isTrue(); + assertPendingWithoutCoordinates(restaurant); + assertThat(restaurant.deferLocation(1, oldRequest, NOW.plusMinutes(2), due)).isFalse(); + restaurant.deferLocation(1, request(restaurant), NOW.plusMinutes(2), due); + updateAddress(restaurant, "다른 합성 주소"); + assertPendingWithoutCoordinates(restaurant); + } + + @Test + void READY_재시도는_거절하고_같은_주소의_갱신은_기존_좌표를_만료까지만_유지한다() { + Restaurant restaurant = pending(); + complete(restaurant, 1, request(restaurant)); + UUID previous = request(restaurant); + assertThatThrownBy(restaurant::requestLocationResolution).isInstanceOf(IllegalStateException.class); + restaurant.refreshLocation(); + assertThat(restaurant.getLocation().getStatus()).isEqualTo(RestaurantLocationStatus.PENDING); + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(1); + assertAcceptedLocationUnchanged(restaurant); + assertThat(restaurant.getLocation().getNextAttemptAt()).isNull(); + assertThat(restaurant.hasUsableMapLocation(CLOCK)).isTrue(); + assertThat(restaurant.hasUsableMapLocation(clockAt(NOW.plusDays(1).minusNanos(1000)))).isTrue(); + assertThat(restaurant.hasUsableMapLocation(clockAt(NOW.plusDays(1)))).isFalse(); + assertThat(request(restaurant)).isNotEqualTo(previous); + assertThat(complete(restaurant, 1, previous)).isFalse(); + assertThatThrownBy(restaurant::refreshLocation).isInstanceOf(IllegalStateException.class); + } + + @Test + void 반복_재시도는_기존_좌표와_만료를_유지하고_지난_요청의_결과를_거절한다() { + Restaurant restaurant = readyForRefresh(); + for (int attempt = 1; attempt <= 3; attempt++) { + UUID previousRequest = request(restaurant); + Clock attemptedAt = clockAt(NOW.plusMinutes(attempt - 1)); + LocalDateTime nextAttemptAt = NOW.plusMinutes(attempt); + assertThat(restaurant.deferLocation(1, previousRequest, nextAttemptAt, attemptedAt)).isTrue(); + assertThat(restaurant.getLocation().getStatus()).isEqualTo(RestaurantLocationStatus.RETRY_WAIT); + assertAcceptedLocationUnchanged(restaurant); + assertThat(restaurant.hasUsableMapLocation(attemptedAt)).isTrue(); + assertThat(restaurant.retryLocationWhenDue(attemptedAt)).isFalse(); + assertThat(restaurant.retryLocationWhenDue(clockAt(nextAttemptAt))).isTrue(); + assertThat(request(restaurant)).isNotEqualTo(previousRequest); + assertThat(restaurant.getLocation().getNextAttemptAt()).isNull(); + assertThat(complete(restaurant, 1, previousRequest)).isFalse(); + assertThat(restaurant.deferLocation(1, previousRequest, NOW.plusHours(1), CLOCK)).isFalse(); + assertThat(restaurant.rejectLocation(1, previousRequest, RestaurantLocationStatus.FAILED)).isFalse(); + assertAcceptedLocationUnchanged(restaurant); + } + } + + @ParameterizedTest + @EnumSource(value = RestaurantLocationStatus.class, names = {"REVIEW_REQUIRED", "FAILED"}) + void 갱신_실패와_명시적_재요청은_기존_좌표의_수명을_연장하지_않는다(RestaurantLocationStatus outcome) { + Restaurant restaurant = readyForRefresh(); + UUID failedRequest = request(restaurant); + assertThat(restaurant.rejectLocation(1, failedRequest, outcome)).isTrue(); + assertThat(restaurant.getLocation().getStatus()).isEqualTo(outcome); + assertThat(restaurant.retryLocationWhenDue(CLOCK)).isFalse(); + assertThat(restaurant.hasUsableMapLocation(CLOCK)).isTrue(); + assertThat(restaurant.hasUsableMapLocation(clockAt(NOW.plusDays(1)))).isFalse(); + assertAcceptedLocationUnchanged(restaurant); + + restaurant.requestLocationResolution(); + assertThat(request(restaurant)).isNotEqualTo(failedRequest); + assertThat(complete(restaurant, 1, failedRequest)).isFalse(); + assertThat(restaurant.getLocation().getStatus()).isEqualTo(RestaurantLocationStatus.PENDING); + assertAcceptedLocationUnchanged(restaurant); + assertThat(restaurant.hasUsableMapLocation(clockAt(NOW.plusDays(1)))).isFalse(); + } + + @Test + void 검증을_통과한_갱신_결과만_좌표와_출처와_수명을_함께_교체한다() { + Restaurant restaurant = readyForRefresh(); + MapCoordinates updated = MapCoordinates.of(BigDecimal.ONE, BigDecimal.TEN); + Clock completedAt = Clock.offset(CLOCK, Duration.ofHours(1)); + + assertThat(restaurant.completeLocation(1, request(restaurant), updated, RestaurantLocationSource.ADMIN, + NOW.plusHours(1), NOW.plusDays(2), completedAt)).isTrue(); + + assertThat(restaurant.getLocation().getStatus()).isEqualTo(RestaurantLocationStatus.READY); + assertThat(restaurant.getLocation().getCoordinates()).isEqualTo(updated); + assertThat(restaurant.getLocation().getSource()).isEqualTo(RestaurantLocationSource.ADMIN); + assertThat(restaurant.getLocation().getObtainedAt()).isEqualTo(NOW.plusHours(1)); + assertThat(restaurant.getLocation().getValidUntil()).isEqualTo(NOW.plusDays(2)); + assertThat(restaurant.hasUsableMapLocation(clockAt(NOW.plusDays(1)))).isTrue(); + } + + @Test + void 잘못된_갱신_결과는_기존_좌표나_작업_상태를_부분_변경하지_않는다() { + Restaurant restaurant = readyForRefresh(); + UUID request = request(restaurant); + MapCoordinates updated = MapCoordinates.of(BigDecimal.ONE, BigDecimal.TEN); + assertThatThrownBy(() -> restaurant.completeLocation(1, request, updated, + RestaurantLocationSource.ADMIN, NOW.plusSeconds(1), NOW.plusDays(2), CLOCK)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> restaurant.completeLocation(1, request, updated, + RestaurantLocationSource.ADMIN, NOW.minusDays(1), NOW, CLOCK)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> restaurant.completeLocation(1, request, updated, + null, NOW, NOW.plusDays(2), CLOCK)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> restaurant.completeLocation(1, request, null, + RestaurantLocationSource.ADMIN, NOW, NOW.plusDays(2), CLOCK)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> restaurant.deferLocation(1, request, NOW, CLOCK)) + .isInstanceOf(IllegalArgumentException.class); + assertAcceptedLocationUnchanged(restaurant); + assertThat(restaurant.getLocation().getStatus()).isEqualTo(RestaurantLocationStatus.PENDING); + assertThat(request(restaurant)).isEqualTo(request); + assertThat(restaurant.getLocation().getNextAttemptAt()).isNull(); + } + + @Test + void 갱신_중_주소가_바뀌면_이전_좌표를_지우고_늦은_갱신_결과를_거절한다() { + Restaurant restaurant = readyForRefresh(); + UUID refreshRequest = request(restaurant); + restaurant.assignMapRegion(7L); + + updateAddress(restaurant, "갱신 중 바뀐 합성 주소"); + + assertPendingWithoutCoordinates(restaurant); + assertThat(restaurant.getLocation().getAddressRevision()).isEqualTo(2); + assertThat(restaurant.getMapRegionId()).isEqualTo(7L); + assertThat(complete(restaurant, 1, refreshRequest)).isFalse(); + assertThat(complete(restaurant, 2, refreshRequest)).isFalse(); + assertThat(restaurant.deferLocation(1, refreshRequest, NOW.plusHours(1), CLOCK)).isFalse(); + assertThat(complete(restaurant, 2, request(restaurant))).isTrue(); + } + + @Test + void 갱신_중_삭제된_식당은_유효한_이전_좌표도_노출하지_않고_작업을_중단한다() { + Restaurant restaurant = readyForRefresh(); + UUID request = request(restaurant); + restaurant.softDelete(); + assertThat(restaurant.hasUsableMapLocation(CLOCK)).isFalse(); + assertThat(complete(restaurant, 1, request)).isFalse(); + assertThat(restaurant.deferLocation(1, request, NOW.plusHours(1), CLOCK)).isFalse(); + assertThat(restaurant.rejectLocation(1, request, RestaurantLocationStatus.FAILED)).isFalse(); + assertThat(restaurant.retryLocationWhenDue(CLOCK)).isFalse(); + assertThatThrownBy(restaurant::refreshLocation).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(restaurant::requestLocationResolution).isInstanceOf(IllegalStateException.class); + assertAcceptedLocationUnchanged(restaurant); + } + + @Test + void 삭제_식당은_좌표를_노출하거나_늦은_결과를_반영하지_않는다() { + Restaurant pending = pending(); + UUID request = request(pending); + pending.softDelete(); + assertThat(complete(pending, 1, request)).isFalse(); + assertThat(pending.deferLocation(1, request, NOW.plusHours(1), CLOCK)).isFalse(); + assertThat(pending.rejectLocation(1, request, RestaurantLocationStatus.FAILED)).isFalse(); + assertThatThrownBy(pending::requestLocationResolution).isInstanceOf(IllegalStateException.class); + Restaurant ready = pending(); + complete(ready, 1, request(ready)); + ready.softDelete(); + assertThat(ready.hasUsableMapLocation(CLOCK)).isFalse(); + assertThat(ready.getLocation().getCoordinates()).isEqualTo(POINT); + } + + @Test + void 유효하지_않은_결과는_상태를_부분_변경하지_않는다() { + Restaurant restaurant = pending(); + UUID request = request(restaurant); + assertThatThrownBy(() -> restaurant.completeLocation(1, request, POINT, + RestaurantLocationSource.GOOGLE_GEOCODING, NOW.plusSeconds(1), NOW.plusDays(1), CLOCK)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> restaurant.completeLocation(1, request, POINT, + RestaurantLocationSource.GOOGLE_GEOCODING, NOW.minusDays(1), NOW, CLOCK)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> restaurant.completeLocation(1, request, POINT, null, NOW, NOW.plusDays(1), CLOCK)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> restaurant.completeLocation(1, request, null, + RestaurantLocationSource.ADMIN, NOW, NOW.plusDays(1), CLOCK)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> restaurant.deferLocation(1, request, NOW, CLOCK)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> restaurant.rejectLocation(1, request, RestaurantLocationStatus.READY)) + .isInstanceOf(IllegalArgumentException.class); + assertPendingWithoutCoordinates(restaurant); + } + + @Test + void DB_시간_정밀도로_내린_후에도_유효기간을_검증한다() { + Restaurant restaurant = pending(); + assertThatThrownBy(() -> restaurant.completeLocation(1, request(restaurant), POINT, + RestaurantLocationSource.ADMIN, NOW, NOW.plusNanos(999), CLOCK)) + .isInstanceOf(IllegalArgumentException.class); + restaurant.completeLocation(1, request(restaurant), POINT, RestaurantLocationSource.ADMIN, + NOW.minusNanos(1), NOW.plusSeconds(1).plusNanos(999), CLOCK); + assertThat(restaurant.getLocation().getObtainedAt()).isEqualTo(NOW.minusNanos(1000)); + assertThat(restaurant.getLocation().getValidUntil()).isEqualTo(NOW.plusSeconds(1)); + } + + @Test + void 지역_수정과_해제는_위치_상태를_변경하지_않는다() { + Restaurant restaurant = pending(); + UUID request = request(restaurant); + restaurant.assignMapRegion(1L); + restaurant.assignMapRegion(null); + assertThatThrownBy(() -> restaurant.assignMapRegion(0L)).isInstanceOf(IllegalArgumentException.class); + assertThat(request(restaurant)).isEqualTo(request); + assertThat(restaurant.getMapRegionId()).isNull(); + } + + private Restaurant restaurant() { + return Restaurant.create("합성 식당", "fixture", "요약", "설명", "합성 주소", "표시 지역", + RestaurantGenre.SUSHI, "초밥", RestaurantPlaceType.RESTAURANT, + PriceCurrency.JPY, BigDecimal.ONE, BigDecimal.TEN); + } + + private Restaurant restaurantWithGeocodingAddress(String geocodingAddress) { + return Restaurant.create("합성 식당", "fixture", "요약", "설명", "표시용 전체 주소 架空ビル1F", + geocodingAddress, "표시 지역", RestaurantGenre.SUSHI, "초밥", + RestaurantPlaceType.RESTAURANT, PriceCurrency.JPY, BigDecimal.ONE, BigDecimal.TEN); + } + + private Restaurant pending() { + Restaurant restaurant = restaurant(); + restaurant.requestLocationResolution(); + return restaurant; + } + + private UUID request(Restaurant restaurant) { + return restaurant.getLocation().getRequestId(); + } + + private Restaurant readyForRefresh() { + Restaurant restaurant = pending(); + complete(restaurant, 1, request(restaurant)); + restaurant.refreshLocation(); + return restaurant; + } + + private Clock clockAt(LocalDateTime time) { + return Clock.fixed(time.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); + } + + private void assertAcceptedLocationUnchanged(Restaurant restaurant) { + RestaurantLocation location = restaurant.getLocation(); + assertThat(location.getCoordinates()).isEqualTo(POINT); + assertThat(location.getSource()).isEqualTo(RestaurantLocationSource.GOOGLE_GEOCODING); + assertThat(location.getObtainedAt()).isEqualTo(NOW); + assertThat(location.getValidUntil()).isEqualTo(NOW.plusDays(1)); + } + + private boolean complete(Restaurant restaurant, long revision, UUID requestId) { + return restaurant.completeLocation(revision, requestId, POINT, RestaurantLocationSource.GOOGLE_GEOCODING, + NOW, NOW.plusDays(1), CLOCK); + } + + private void updateAddress(Restaurant restaurant, String address) { + restaurant.updateBasicInfo(null, null, null, null, address, null, null, null, null, null, null, null); + } + + private void updateGeocodingAddress(Restaurant restaurant, String geocodingAddress) { + restaurant.updateBasicInfo(null, null, null, null, null, geocodingAddress, + null, null, null, null, null, null, null); + } + + private void assertPendingWithoutCoordinates(Restaurant restaurant) { + RestaurantLocation location = restaurant.getLocation(); + assertThat(location.getStatus()).isEqualTo(RestaurantLocationStatus.PENDING); + assertThat(location.getCoordinates()).isNull(); + assertThat(location.getSource()).isNull(); + assertThat(location.getObtainedAt()).isNull(); + assertThat(location.getValidUntil()).isNull(); + assertThat(location.getNextAttemptAt()).isNull(); + assertThat(restaurant.hasUsableMapLocation(CLOCK)).isFalse(); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantLocationTimezoneTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantLocationTimezoneTest.java new file mode 100644 index 00000000..7c9d0840 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantLocationTimezoneTest.java @@ -0,0 +1,155 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.TimeZone; +import org.flywaydb.core.Flyway; +import org.hibernate.SessionFactory; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.api.parallel.Isolated; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@Isolated("Temporarily changes the JVM default timezone and restores it after each case") +@Execution(ExecutionMode.SAME_THREAD) +class RestaurantLocationTimezoneTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-01-01T00:00:00.123456Z"), ZoneOffset.UTC); + private static final LocalDateTime NOW = LocalDateTime.ofInstant(CLOCK.instant(), ZoneOffset.UTC); + private static final DateTimeFormatter DATABASE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi_location_timezone").withUsername("hashi").withPassword("hashi"); + + @BeforeAll + static void 스키마를_준비한다() { + Flyway.configure().dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration").load().migrate(); + } + + @ParameterizedTest(name = "JVM={0}, JDBC={1}") + @CsvSource({"UTC,UTC", "UTC,Asia/Seoul", "Asia/Seoul,UTC", "Asia/Seoul,Asia/Seoul"}) + void 위치의_세_시각은_JVM과_JDBC_시간대가_달라도_UTC_그대로_저장하고_읽는다( + String jvmTimezone, String jdbcTimezone) { + TimeZone originalTimezone = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(jvmTimezone)); + assertThat(TimeZone.getDefault().getID()).isEqualTo(jvmTimezone); + String url = MYSQL.getJdbcUrl() + (MYSQL.getJdbcUrl().contains("?") ? "&" : "?") + + "serverTimezone=" + jdbcTimezone; + JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource( + url, MYSQL.getUsername(), MYSQL.getPassword())); + var registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.url", url) + .applySetting("hibernate.connection.username", MYSQL.getUsername()) + .applySetting("hibernate.connection.password", MYSQL.getPassword()) + .applySetting("hibernate.physical_naming_strategy", + "org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy") + .applySetting("hibernate.hbm2ddl.auto", "validate") + .build(); + try (SessionFactory factory = new MetadataSources(registry) + .addAnnotatedClass(RestaurantLocation.class).buildMetadata().buildSessionFactory()) { + verifyPersistence(factory, jdbc); + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } finally { + TimeZone.setDefault(originalTimezone); + } + } + + private void verifyPersistence(SessionFactory factory, JdbcTemplate jdbc) { + LocalDateTime obtained = NOW.minusHours(1); + LocalDateTime validUntil = NOW.plusHours(1); + LocalDateTime nextAttempt = NOW.plusMinutes(5); + RestaurantLocation ready = RestaurantLocation.pending(); + assertThat(ready.complete(1, ready.getRequestId(), point(), RestaurantLocationSource.ADMIN, + obtained, validUntil, CLOCK)).isTrue(); + RestaurantLocation retry = RestaurantLocation.pending(); + assertThat(retry.defer(1, retry.getRequestId(), nextAttempt, CLOCK)).isTrue(); + + try (var session = factory.openSession()) { + var transaction = session.beginTransaction(); + session.persist(ready); + session.persist(retry); + transaction.commit(); + } + assertRawValues(jdbc, ready.getId(), retry.getId(), obtained, validUntil, nextAttempt); + + try (var session = factory.openSession()) { + var transaction = session.beginTransaction(); + RestaurantLocation loadedReady = session.find(RestaurantLocation.class, ready.getId()); + RestaurantLocation loadedRetry = session.find(RestaurantLocation.class, retry.getId()); + assertTimesAndBoundaries(loadedReady, loadedRetry, obtained, validUntil, nextAttempt); + loadedReady.beginRefresh(); + assertThat(loadedReady.complete(1, loadedReady.getRequestId(), point(), RestaurantLocationSource.ADMIN, + obtained.plusMinutes(1), validUntil.plusMinutes(1), CLOCK)).isTrue(); + assertThat(loadedRetry.beginScheduledRetry(clockAt(nextAttempt))).isTrue(); + assertThat(loadedRetry.defer(1, loadedRetry.getRequestId(), nextAttempt.plusMinutes(1), + clockAt(nextAttempt))).isTrue(); + transaction.commit(); + } + assertRawValues(jdbc, ready.getId(), retry.getId(), obtained.plusMinutes(1), + validUntil.plusMinutes(1), nextAttempt.plusMinutes(1)); + + // Text binding makes this a raw DATETIME fixture without Timestamp/Calendar conversion. + jdbc.update("UPDATE restaurant_location SET obtained_at=?, valid_until=? WHERE id=?", + obtained.format(DATABASE_TIME), validUntil.format(DATABASE_TIME), ready.getId()); + jdbc.update("UPDATE restaurant_location SET next_attempt_at=? WHERE id=?", + nextAttempt.format(DATABASE_TIME), retry.getId()); + try (var session = factory.openSession()) { + assertTimesAndBoundaries(session.find(RestaurantLocation.class, ready.getId()), + session.find(RestaurantLocation.class, retry.getId()), obtained, validUntil, nextAttempt); + } + } + + private void assertRawValues(JdbcTemplate jdbc, Long readyId, Long retryId, + LocalDateTime obtained, LocalDateTime validUntil, LocalDateTime nextAttempt) { + var ready = jdbc.queryForMap(""" + SELECT DATE_FORMAT(obtained_at, '%Y-%m-%d %H:%i:%s.%f') AS obtained, + DATE_FORMAT(valid_until, '%Y-%m-%d %H:%i:%s.%f') AS until_time + FROM restaurant_location WHERE id=? + """, readyId); + assertThat(ready.get("obtained")).isEqualTo(obtained.format(DATABASE_TIME)); + assertThat(ready.get("until_time")).isEqualTo(validUntil.format(DATABASE_TIME)); + assertThat(jdbc.queryForObject(""" + SELECT DATE_FORMAT(next_attempt_at, '%Y-%m-%d %H:%i:%s.%f') + FROM restaurant_location WHERE id=? + """, String.class, retryId)).isEqualTo(nextAttempt.format(DATABASE_TIME)); + } + + private void assertTimesAndBoundaries(RestaurantLocation ready, RestaurantLocation retry, + LocalDateTime obtained, LocalDateTime validUntil, LocalDateTime nextAttempt) { + assertThat(ready.getObtainedAt()).isEqualTo(obtained); + assertThat(ready.getValidUntil()).isEqualTo(validUntil); + assertThat(retry.getNextAttemptAt()).isEqualTo(nextAttempt); + assertThat(ready.isUsable(clockAt(validUntil.minusNanos(1_000)))).isTrue(); + assertThat(ready.isUsable(clockAt(validUntil))).isFalse(); + assertThat(retry.beginScheduledRetry(clockAt(nextAttempt.minusNanos(1_000)))).isFalse(); + } + + private Clock clockAt(LocalDateTime time) { + return Clock.fixed(time.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); + } + + private MapCoordinates point() { + return MapCoordinates.of(BigDecimal.TEN, BigDecimal.TEN); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMapMigrationTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMapMigrationTest.java new file mode 100644 index 00000000..7de2c0ca --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMapMigrationTest.java @@ -0,0 +1,285 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import org.flywaydb.core.Flyway; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +class RestaurantMapMigrationTest { + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi_map_upgrade").withUsername("hashi").withPassword("hashi"); + + private JdbcTemplate jdbc; + + @BeforeEach + void 이전_스키마를_준비한다() { + // Exercise every migration merged through support terms before the new map version. + Flyway baseline = flyway("37"); + baseline.clean(); + baseline.migrate(); + jdbc = new JdbcTemplate(new DriverManagerDataSource( + MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword())); + } + + @Test + void 기존_데이터와_명시주소를_보존하며_위치확인기록_스키마까지_적용한다() { + jdbc.update(""" + INSERT INTO restaurant (id, name, local_name, summary, description, address, area, + genre, food_category, place_type, price_currency, price_min, price_max, + rating_sum, review_count, rating, deleted, created_at, updated_at) + VALUES (1, 'fixture active', 'fixture', 'summary', 'description', 'synthetic address', 'area', + 'SUSHI', 'food', 'RESTAURANT', 'JPY', 100, 200, 9, 2, 4.5, FALSE, NOW(6), NOW(6)), + (2, 'fixture deleted', 'fixture', 'summary', 'description', 'old address', 'area', + 'SUSHI', 'food', 'CAFE', 'JPY', 100, 200, 0, 0, 0.0, TRUE, NOW(6), NOW(6)) + """); + jdbc.update("INSERT INTO restaurant_hashtag (restaurant_id, hashtag) VALUES (1, 'fixture')"); + jdbc.update(""" + INSERT INTO restaurant_image (restaurant_id, file_key, display_order) + VALUES (1, 'fixtures/map-test.jpg', 1) + """); + jdbc.update(""" + INSERT INTO restaurant_menu (restaurant_id, name, description, price_currency, price_amount, is_main) + VALUES (1, 'fixture menu', 'description', 'JPY', 100, TRUE) + """); + List> restaurants = jdbc.queryForList("SELECT * FROM restaurant ORDER BY id"); + List> images = jdbc.queryForList("SELECT * FROM restaurant_image"); + List> menus = jdbc.queryForList("SELECT * FROM restaurant_menu"); + List> hashtags = jdbc.queryForList("SELECT * FROM restaurant_hashtag"); + + Flyway migration = flyway("38"); + assertThat(migration.migrate().migrationsExecuted).isEqualTo(1); + List> upgraded = jdbc.queryForList("SELECT * FROM restaurant ORDER BY id"); + assertThat(upgraded).hasSize(2); + for (int index = 0; index < restaurants.size(); index++) { + assertThat(upgraded.get(index)).containsAllEntriesOf(restaurants.get(index)) + .containsEntry("location_id", null).containsEntry("map_region_id", null); + } + assertThat(jdbc.queryForList("SELECT * FROM restaurant_image")).isEqualTo(images); + assertThat(jdbc.queryForList("SELECT * FROM restaurant_menu")).isEqualTo(menus); + assertThat(jdbc.queryForList("SELECT * FROM restaurant_hashtag")).isEqualTo(hashtags); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM restaurant_location", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM map_region", Integer.class)).isZero(); + assertThat(migration.validateWithResult().validationSuccessful).isTrue(); + jdbc.update("UPDATE restaurant SET map_region_id=123 WHERE id=1"); + assertThat(migration.migrate().migrationsExecuted).isZero(); + assertThat(jdbc.queryForObject("SELECT map_region_id FROM restaurant WHERE id=1", Long.class)) + .isEqualTo(123L); + + Flyway jobs = flyway("39"); + assertThat(jobs.migrate().migrationsExecuted).isEqualTo(1); + Flyway geocodingAddress = flyway("39.1"); + assertThat(geocodingAddress.migrate().migrationsExecuted).isEqualTo(1); + assertThat(jdbc.queryForList("SELECT address FROM restaurant ORDER BY id")) + .extracting(row -> row.get("address")) + .containsExactly("synthetic address", "old address"); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM restaurant WHERE geocoding_address IS NOT NULL", Integer.class)).isZero(); + jdbc.update("UPDATE restaurant SET geocoding_address='東京都千代田区丸の内1-9-1' WHERE id=1"); + assertThat(jdbc.queryForObject("SELECT geocoding_address FROM restaurant WHERE id=1", String.class)) + .isEqualTo("東京都千代田区丸の内1-9-1"); + assertThat(geocodingAddress.validateWithResult().validationSuccessful).isTrue(); + assertThat(geocodingAddress.migrate().migrationsExecuted).isZero(); + + List> beforePlaces = jdbc.queryForList("SELECT * FROM restaurant ORDER BY id"); + Flyway places = flyway("39.2"); + assertThat(places.migrate().migrationsExecuted).isEqualTo(1); + assertThat(jdbc.queryForList("SELECT * FROM restaurant ORDER BY id")).isEqualTo(beforePlaces); + assertThat(jdbc.queryForList("SELECT * FROM restaurant_image")).isEqualTo(images); + assertThat(jdbc.queryForList("SELECT * FROM restaurant_menu")).isEqualTo(menus); + assertThat(jdbc.queryForList("SELECT * FROM restaurant_hashtag")).isEqualTo(hashtags); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM restaurant_places_budget WHERE enabled=FALSE", Integer.class)) + .isEqualTo(2); + assertThat(places.validateWithResult().validationSuccessful).isTrue(); + assertThat(places.migrate().migrationsExecuted).isZero(); + + jdbc.update(""" + INSERT INTO restaurant_location (id, status, address_revision, request_id, lock_version, + latitude, longitude, source, google_place_id, places_attributions, obtained_at, valid_until) + VALUES (1, 'READY', 1, '00000000-0000-0000-0000-000000000001', 0, + 35.0, 139.0, 'GOOGLE_GEOCODING', NULL, NULL, '2026-01-01', '2026-01-02'), + (2, 'READY', 1, '00000000-0000-0000-0000-000000000002', 0, + 35.1, 139.1, 'GOOGLE_PLACES', 'fixture-place', '[]', '2026-01-01', '2026-01-02'), + (3, 'READY', 1, '00000000-0000-0000-0000-000000000003', 0, + 35.2, 139.2, 'ADMIN', NULL, NULL, '2026-01-01', '2026-01-02'), + (4, 'PENDING', 1, '00000000-0000-0000-0000-000000000004', 0, + NULL, NULL, NULL, NULL, NULL, NULL, NULL) + """); + List> beforeConfirmation = jdbc.queryForList( + "SELECT * FROM restaurant_location ORDER BY id"); + Flyway confirmation = flyway("39.3"); + assertThat(confirmation.migrate().migrationsExecuted).isEqualTo(1); + List> confirmedLocations = jdbc.queryForList( + "SELECT * FROM restaurant_location ORDER BY id"); + assertThat(confirmedLocations).hasSize(beforeConfirmation.size()); + for (int index = 0; index < beforeConfirmation.size(); index++) { + Map previous = beforeConfirmation.get(index); + String method = "GOOGLE_GEOCODING".equals(previous.get("source")) ? "AUTOMATIC" : null; + assertThat(confirmedLocations.get(index)).containsAllEntriesOf(previous) + .containsEntry("confirmation_method", method) + .containsEntry("confirmed_by_admin_id", null) + .containsEntry("confirmed_at", null) + .containsEntry("confirmed_revision", null); + } + assertThat(jdbc.queryForList("SELECT * FROM restaurant ORDER BY id")).isEqualTo(beforePlaces); + assertThat(jdbc.queryForList("SELECT * FROM restaurant_image")).isEqualTo(images); + assertThat(jdbc.queryForList("SELECT * FROM restaurant_menu")).isEqualTo(menus); + assertThat(jdbc.queryForList("SELECT * FROM restaurant_hashtag")).isEqualTo(hashtags); + for (String change : List.of( + "confirmation_method=NULL, confirmed_by_admin_id=7, confirmed_at=NOW(6), confirmed_revision=1", + "confirmation_method='ADMIN_PLACE_SELECTION', confirmed_by_admin_id=NULL, confirmed_at=NOW(6), confirmed_revision=1", + "confirmation_method='ADMIN_PLACE_SELECTION', confirmed_by_admin_id=7, confirmed_at=NOW(6), confirmed_revision=2")) { + assertThatThrownBy(() -> jdbc.update("UPDATE restaurant_location SET " + change + " WHERE id=4")) + .as(change).isInstanceOf(DataAccessException.class) + .hasMessageContaining("chk_location_confirmation"); + } + jdbc.update(""" + UPDATE restaurant_location SET confirmation_method='ADMIN_PLACE_SELECTION', + confirmed_by_admin_id=7, confirmed_at=NOW(6), confirmed_revision=1 WHERE id=4 + """); + assertThat(confirmation.validateWithResult().validationSuccessful).isTrue(); + assertThat(confirmation.migrate().migrationsExecuted).isZero(); + validateUpgradedSchema(); + } + + @Test + void 좌표_쌍과_출처_수명과_재시도_제약은_SQL_우회도_차단한다() { + flyway("38").migrate(); + jdbc.update(""" + INSERT INTO restaurant_location (id, status, address_revision, request_id, lock_version) + VALUES (1, 'PENDING', 1, '00000000-0000-0000-0000-000000000001', 0) + """); + for (String change : List.of( + "latitude=0", "longitude=0", "status='READY'", "status='UNKNOWN'", "status='pending'", + "status='PENDING '", "address_revision=0", + "lock_version=-1", "source='ADMIN'", "obtained_at=NOW(6)", "valid_until=NOW(6)", + "status='RETRY_WAIT'", "next_attempt_at=NOW(6)")) { + assertThatThrownBy(() -> jdbc.update("UPDATE restaurant_location SET " + change + " WHERE id=1")) + .as(change).isInstanceOf(DataAccessException.class); + } + jdbc.update(""" + UPDATE restaurant_location SET status='READY', latitude=0, longitude=0, + source='GOOGLE_GEOCODING', obtained_at='2026-01-01 00:00:00', + valid_until='2026-01-02 00:00:00' WHERE id=1 + """); + for (String status : List.of("READY", "PENDING", "RETRY_WAIT", "REVIEW_REQUIRED", "FAILED")) { + String nextAttempt = status.equals("RETRY_WAIT") ? "'2026-01-01 01:00:00'" : "NULL"; + jdbc.update("UPDATE restaurant_location SET status=?, next_attempt_at=" + nextAttempt + " WHERE id=1", + status); + for (String change : List.of( + "latitude=NULL", "longitude=NULL", "latitude=90.000001", "longitude=-180.000001", + "source=NULL", "source='UNKNOWN'", "source='OPERATOR'", "source='admin'", "source='ADMIN '", + "obtained_at=NULL", "valid_until=NULL", "valid_until=obtained_at", + status.equals("RETRY_WAIT") ? "next_attempt_at=NULL" : "next_attempt_at=NOW(6)")) { + assertThatThrownBy(() -> jdbc.update("UPDATE restaurant_location SET " + change + " WHERE id=1")) + .as(status + ": " + change).isInstanceOf(DataAccessException.class); + } + assertThat(jdbc.queryForObject("SELECT source FROM restaurant_location", String.class)) + .isEqualTo("GOOGLE_GEOCODING"); + assertThat(jdbc.queryForObject("SELECT valid_until FROM restaurant_location", String.class)) + .startsWith("2026-01-02 00:00:00"); + } + assertThat(jdbc.queryForObject("SELECT latitude FROM restaurant_location", java.math.BigDecimal.class)) + .isEqualByComparingTo("0"); + } + + @Test + void 지역_범위와_대표_위치와_코드_유일성을_검증한다() { + flyway("38").migrate(); + jdbc.update(""" + INSERT INTO map_region (id, code, name, cluster_latitude, cluster_longitude, + south, north, west, east, display_order) VALUES (1, 'FIXTURE', 'region', 10, 20, 10, 11, 20, 21, 0) + """); + for (String change : List.of("south=north", "west=east", "north=91", "east=181", + "cluster_latitude=12", "cluster_longitude=19", "display_order=-1", "name=' '", "code='lower'")) { + assertThatThrownBy(() -> jdbc.update("UPDATE map_region SET " + change + " WHERE id=1")) + .as(change).isInstanceOf(DataAccessException.class); + } + for (String blankName : List.of("", "\t", "\n", "\r\n", "\t \n", "\u001C", "\u0085", + "\u00A0", "\u2007", "\u202F", "\u3000", "\u001C\u00A0")) { + assertThatThrownBy(() -> jdbc.update("UPDATE map_region SET name=? WHERE id=1", blankName)) + .isInstanceOf(DataAccessException.class); + } + assertThatThrownBy(() -> jdbc.update(""" + INSERT INTO map_region (code, name, cluster_latitude, cluster_longitude, + south, north, west, east, display_order) VALUES ('FIXTURE', 'other', 10, 20, 10, 11, 20, 21, 1) + """)).isInstanceOf(DataAccessException.class); + assertThat(jdbc.queryForObject("SELECT active FROM map_region WHERE id=1", Boolean.class)).isFalse(); + } + + @Test + void FK는_식당과_소유_위치에만_걸고_좌표의_정밀도와_인덱스를_고정한다() { + flyway("38").migrate(); + assertThat(jdbc.queryForObject(""" + SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema=DATABASE() AND referenced_table_name='map_region' + """, Integer.class)).isZero(); + assertThat(jdbc.queryForObject(""" + SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema=DATABASE() AND table_name='restaurant' + AND referenced_table_name='restaurant_location' AND column_name='location_id' + """, Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject(""" + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='restaurant' AND column_name='location_id' + AND non_unique=0 + """, Integer.class)).isEqualTo(1); + List> columns = jdbc.queryForList(""" + SELECT numeric_precision, numeric_scale FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='restaurant_location' + AND column_name IN ('latitude', 'longitude') ORDER BY column_name + """); + assertThat(columns).hasSize(2); + assertThat(((Number) columns.getFirst().get("numeric_precision")).intValue()).isEqualTo(9); + assertThat(((Number) columns.getLast().get("numeric_precision")).intValue()).isEqualTo(10); + for (Map column : columns) { + assertThat(((Number) column.get("numeric_scale")).intValue()).isEqualTo(6); + } + } + + private Flyway flyway(String target) { + return Flyway.configure().dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration").target(target).validateOnMigrate(true) + .baselineOnMigrate(false).cleanDisabled(false).load(); + } + + private void validateUpgradedSchema() { + var registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.url", MYSQL.getJdbcUrl()) + .applySetting("hibernate.connection.username", MYSQL.getUsername()) + .applySetting("hibernate.connection.password", MYSQL.getPassword()) + .applySetting("hibernate.physical_naming_strategy", + "org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy") + .applySetting("hibernate.hbm2ddl.auto", "validate") + .build(); + try { + try (var sessionFactory = new MetadataSources(registry) + .addAnnotatedClass(Restaurant.class) + .addAnnotatedClass(RestaurantLocation.class) + .addAnnotatedClass(MapRegion.class) + .addAnnotatedClass(RestaurantMenu.class) + .addAnnotatedClass(RestaurantImage.class) + .addAnnotatedClass(RestaurantBusinessHour.class) + .buildMetadata().buildSessionFactory()) { + assertThat(sessionFactory.isOpen()).isTrue(); + } + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMapSchemaValidationTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMapSchemaValidationTest.java new file mode 100644 index 00000000..41a3f8be --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMapSchemaValidationTest.java @@ -0,0 +1,346 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.OptimisticLockException; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.hibernate.Hibernate; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.sopt.hashi.config.TimeConfig; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.AdminRestaurantCommand; +import org.sopt.hashi.restaurant.service.RestaurantService; +import org.sopt.hashi.restaurant.service.RestaurantLocationService; +import org.sopt.hashi.shared.storage.FileStorage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({RestaurantService.class, RestaurantLocationService.class, TimeConfig.class}) +@TestPropertySource(properties = { + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.generate_statistics=true", + "spring.flyway.enabled=true" +}) +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class RestaurantMapSchemaValidationTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + private static final LocalDateTime NOW = LocalDateTime.ofInstant(CLOCK.instant(), ZoneOffset.UTC); + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi_map_schema").withUsername("hashi").withPassword("hashi"); + + @Autowired + private RestaurantRepository restaurants; + @Autowired + private MapRegionRepository regions; + @Autowired + private RestaurantService service; + @Autowired + private EntityManager entityManager; + @Autowired + private EntityManagerFactory entityManagerFactory; + @Autowired + private JdbcTemplate jdbc; + @Autowired + private TransactionTemplate transactions; + @MockitoBean + private MediaPort mediaPort; + @MockitoBean + private FileStorage fileStorage; + + @ParameterizedTest + @EnumSource(RestaurantLocationSource.class) + void 빈_스키마_migration과_validate_후_위치_좌표와_출처와_지역을_정확히_왕복한다( + RestaurantLocationSource source) { + MapRegion region = regions.saveAndFlush(MapRegion.create("ROUNDTRIP", "합성 지역", point(), bounds(), 0)); + region.activate(); + Restaurant restaurant = ready(source); + restaurant.assignMapRegion(region.getId()); + restaurants.saveAndFlush(restaurant); + Long id = restaurant.getId(); + entityManager.clear(); + + Restaurant reloaded = restaurants.findById(id).orElseThrow(); + assertThat(reloaded.hasUsableMapLocation(CLOCK)).isTrue(); + assertThat(reloaded.getLocation().getCoordinates()).isEqualTo(point()); + assertThat(reloaded.getLocation().getObtainedAt()).isEqualTo(NOW); + assertThat(reloaded.getLocation().getValidUntil()).isEqualTo(NOW.plusDays(1)); + assertThat(reloaded.getLocation().getSource()).isEqualTo(source); + assertThat(reloaded.getLocation().getGooglePlaceId()) + .isEqualTo(source == RestaurantLocationSource.GOOGLE_PLACES ? "place-roundtrip" : null); + assertThat(reloaded.getLocation().getPlacesAttributions()) + .isEqualTo(source == RestaurantLocationSource.GOOGLE_PLACES ? List.of() : null); + assertThat(jdbc.queryForObject("SELECT source FROM restaurant_location WHERE id=?", String.class, + reloaded.getLocation().getId())).isEqualTo(source.name()); + assertThat(reloaded.getMapRegionId()).isEqualTo(region.getId()); + assertThat(regions.findAllByActiveTrueOrderByDisplayOrderAscIdAsc()) + .extracting(MapRegion::getCode).contains("ROUNDTRIP"); + assertThat(regions.findById(region.getId()).orElseThrow().getCameraBounds()).isEqualTo(bounds()); + } + + @Test + void 기존_관리자_주소_PATCH는_위치와_같이_커밋하고_지역과_통계를_보존한다() { + Restaurant restaurant = restaurants.saveAndFlush(ready()); + restaurant.assignMapRegion(123L); + restaurants.flush(); + Long id = restaurant.getId(); + jdbc.update("UPDATE restaurant SET rating_sum=9, review_count=2, rating=4.5 WHERE id=?", id); + entityManager.clear(); + + var response = service.updateByAdmin(id, addressCommand("새 합성 주소")); + entityManager.flush(); + entityManager.clear(); + + Restaurant reloaded = restaurants.findById(id).orElseThrow(); + assertThat(response.address()).isEqualTo("새 합성 주소"); + assertThat(reloaded.getLocation().getStatus()).isEqualTo(RestaurantLocationStatus.PENDING); + assertThat(reloaded.getLocation().getAddressRevision()).isEqualTo(2); + assertThat(reloaded.getLocation().getCoordinates()).isNull(); + assertThat(reloaded.getLocation().getSource()).isNull(); + assertThat(reloaded.getMapRegionId()).isEqualTo(123L); + assertThat(reloaded.getRatingSum()).isEqualTo(9); + assertThat(reloaded.getReviewCount()).isEqualTo(2); + assertThat(reloaded.getRating()).isEqualByComparingTo("4.5"); + } + + @ParameterizedTest + @EnumSource(value = RestaurantLocationStatus.class, + names = {"PENDING", "RETRY_WAIT", "REVIEW_REQUIRED", "FAILED"}) + void 갱신의_대기와_실패를_저장해도_기존_좌표는_실제_만료까지만_사용한다(RestaurantLocationStatus status) { + Restaurant restaurant = restaurants.saveAndFlush(ready()); + Long id = restaurant.getId(); + UUID acceptedRequest = restaurant.getLocation().getRequestId(); + restaurant.refreshLocation(); + UUID refreshRequest = restaurant.getLocation().getRequestId(); + if (status == RestaurantLocationStatus.RETRY_WAIT) { + restaurant.deferLocation(1, refreshRequest, NOW.plusMinutes(5), CLOCK); + } else if (status != RestaurantLocationStatus.PENDING) { + restaurant.rejectLocation(1, refreshRequest, status); + } + restaurants.flush(); + entityManager.clear(); + + Restaurant reloaded = restaurants.findById(id).orElseThrow(); + RestaurantLocation location = reloaded.getLocation(); + assertThat(location.getStatus()).isEqualTo(status); + assertThat(location.getCoordinates()).isEqualTo(point()); + assertThat(location.getSource()).isEqualTo(RestaurantLocationSource.GOOGLE_GEOCODING); + assertThat(location.getObtainedAt()).isEqualTo(NOW); + assertThat(location.getValidUntil()).isEqualTo(NOW.plusDays(1)); + assertThat(location.getRequestId()).isEqualTo(refreshRequest).isNotEqualTo(acceptedRequest); + assertThat(location.getNextAttemptAt()).isEqualTo( + status == RestaurantLocationStatus.RETRY_WAIT ? NOW.plusMinutes(5) : null); + assertThat(reloaded.hasUsableMapLocation(CLOCK)).isTrue(); + assertThat(reloaded.hasUsableMapLocation(Clock.offset(CLOCK, java.time.Duration.ofDays(1)))).isFalse(); + assertThat(reloaded.completeLocation(1, acceptedRequest, point(), RestaurantLocationSource.GOOGLE_GEOCODING, + NOW, NOW.plusDays(2), CLOCK)).isFalse(); + + service.updateByAdmin(id, addressCommand("갱신 중 변경한 합성 주소")); + entityManager.flush(); + entityManager.clear(); + Restaurant changed = restaurants.findById(id).orElseThrow(); + assertThat(changed.getLocation().getCoordinates()).isNull(); + assertThat(changed.getLocation().getSource()).isNull(); + assertThat(changed.getLocation().getObtainedAt()).isNull(); + assertThat(changed.getLocation().getValidUntil()).isNull(); + assertThat(changed.getLocation().getNextAttemptAt()).isNull(); + assertThat(changed.getLocation().getAddressRevision()).isEqualTo(2); + assertThat(changed.completeLocation(1, refreshRequest, point(), RestaurantLocationSource.GOOGLE_GEOCODING, + NOW, NOW.plusDays(2), CLOCK)).isFalse(); + } + + @Test + void 일반_조회는_위치_수에_따른_추가_SELECT를_발생시키지_않는다() { + List fixtures = restaurants.saveAllAndFlush(List.of(ready(), ready(), ready(), restaurant())); + List ids = fixtures.stream().map(Restaurant::getId).toList(); + entityManager.clear(); + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.clear(); + + List rows = restaurants.findAllByIdInAndDeletedFalse(ids); + + assertThat(rows).hasSize(4); + for (Restaurant row : rows) { + if (row.getLocation() != null) { + assertThat(Hibernate.isInitialized(row.getLocation())).isFalse(); + } + } + assertThat(statistics.getPrepareStatementCount()).isEqualTo(1); + } + + @Test + void soft_delete는_위치_행을_물리_삭제하지_않고_일반_공개_조회에서_제외한다() { + Restaurant restaurant = restaurants.saveAndFlush(ready()); + Long id = restaurant.getId(); + Long locationId = restaurant.getLocation().getId(); + restaurants.delete(restaurant); + restaurants.flush(); + entityManager.clear(); + + assertThat(restaurants.findByIdAndDeletedFalse(id)).isEmpty(); + Restaurant deleted = restaurants.findById(id).orElseThrow(); + assertThat(deleted.hasUsableMapLocation(CLOCK)).isFalse(); + assertThat(deleted.getLocation().getId()).isEqualTo(locationId); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM restaurant_location WHERE id=?", Integer.class, + locationId)).isEqualTo(1); + } + + @Test + void 위치는_여러_식당에_공유될_수_없고_존재하지_않는_자식을_참조하지_못한다() { + Restaurant first = restaurants.saveAndFlush(ready()); + Restaurant second = restaurants.saveAndFlush(restaurant()); + assertThatThrownBy(() -> jdbc.update("UPDATE restaurant SET location_id=? WHERE id=?", + first.getLocation().getId(), second.getId())).isInstanceOf(DataAccessException.class); + assertThatThrownBy(() -> jdbc.update("UPDATE restaurant SET location_id=-1 WHERE id=?", second.getId())) + .isInstanceOf(DataAccessException.class); + assertThatThrownBy(() -> jdbc.update("UPDATE restaurant SET map_region_id=0 WHERE id=?", second.getId())) + .isInstanceOf(DataAccessException.class); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void 오래된_위치_엔티티의_동시_변경은_낙관적_버전으로_거절한다() { + Restaurant saved = restaurant(); + saved.requestLocationResolution(); + Long id = restaurants.saveAndFlush(saved).getId(); + try (EntityManager first = entityManagerFactory.createEntityManager(); + EntityManager second = entityManagerFactory.createEntityManager()) { + first.getTransaction().begin(); + second.getTransaction().begin(); + Restaurant winner = first.find(Restaurant.class, id); + Restaurant stale = second.find(Restaurant.class, id); + UUID request = stale.getLocation().getRequestId(); + winner.rejectLocation(1, request, RestaurantLocationStatus.REVIEW_REQUIRED); + first.getTransaction().commit(); + stale.rejectLocation(1, request, RestaurantLocationStatus.FAILED); + assertThatThrownBy(() -> second.getTransaction().commit()) + .hasCauseInstanceOf(OptimisticLockException.class); + } + assertThat(jdbc.queryForObject(""" + SELECT l.status FROM restaurant r JOIN restaurant_location l ON r.location_id=l.id WHERE r.id=? + """, String.class, id)).isEqualTo("REVIEW_REQUIRED"); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void 늦은_갱신_완료는_다른_transaction의_주소_변경과_좌표_제거를_덮어쓰지_못한다() { + Restaurant saved = ready(); + saved.refreshLocation(); + Long id = restaurants.saveAndFlush(saved).getId(); + try (EntityManager first = entityManagerFactory.createEntityManager(); + EntityManager second = entityManagerFactory.createEntityManager()) { + first.getTransaction().begin(); + second.getTransaction().begin(); + Restaurant winner = first.find(Restaurant.class, id); + Restaurant stale = second.find(Restaurant.class, id); + UUID request = stale.getLocation().getRequestId(); + winner.updateBasicInfo(null, null, null, null, "동시 변경한 합성 주소", null, + null, null, null, null, null, null); + first.getTransaction().commit(); + + assertThat(stale.completeLocation(1, request, point(), RestaurantLocationSource.GOOGLE_GEOCODING, + NOW, NOW.plusDays(2), CLOCK)).isTrue(); + assertThatThrownBy(() -> second.getTransaction().commit()) + .hasCauseInstanceOf(OptimisticLockException.class); + } + transactions.executeWithoutResult(transaction -> { + Restaurant reloaded = restaurants.findById(id).orElseThrow(); + assertThat(reloaded.getAddress()).isEqualTo("동시 변경한 합성 주소"); + assertThat(reloaded.getLocation().getAddressRevision()).isEqualTo(2); + assertThat(reloaded.getLocation().getStatus()).isEqualTo(RestaurantLocationStatus.PENDING); + assertThat(reloaded.getLocation().getCoordinates()).isNull(); + assertThat(reloaded.getLocation().getValidUntil()).isNull(); + }); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void 주소_PATCH가_rollback되면_주소와_READY_위치가_함께_복원된다() { + Restaurant saved = restaurants.saveAndFlush(ready()); + Long id = saved.getId(); + UUID request = saved.getLocation().getRequestId(); + transactions.executeWithoutResult(transaction -> { + service.updateByAdmin(id, addressCommand("rollback 합성 주소")); + transaction.setRollbackOnly(); + }); + transactions.executeWithoutResult(transaction -> { + Restaurant reloaded = restaurants.findById(id).orElseThrow(); + assertThat(reloaded.getAddress()).isEqualTo("합성 주소"); + assertThat(reloaded.getLocation().getAddressRevision()).isEqualTo(1); + assertThat(reloaded.getLocation().getRequestId()).isEqualTo(request); + assertThat(reloaded.hasUsableMapLocation(CLOCK)).isTrue(); + }); + } + + private Restaurant restaurant() { + return Restaurant.create("합성 식당", "fixture", "요약", "설명", "합성 주소", "표시 지역", + RestaurantGenre.SUSHI, "초밥", RestaurantPlaceType.RESTAURANT, + PriceCurrency.JPY, BigDecimal.ONE, BigDecimal.TEN); + } + + private Restaurant ready() { + return ready(RestaurantLocationSource.GOOGLE_GEOCODING); + } + + private Restaurant ready(RestaurantLocationSource source) { + Restaurant restaurant = restaurant(); + restaurant.requestLocationResolution(); + if (source == RestaurantLocationSource.GOOGLE_PLACES) { + restaurant.completePlacesLocation(1, restaurant.getLocation().getRequestId(), point(), + "place-roundtrip", List.of(), NOW, NOW.plusDays(1), CLOCK); + } else { + restaurant.completeLocation(1, restaurant.getLocation().getRequestId(), point(), + source, NOW, NOW.plusDays(1), CLOCK); + } + return restaurant; + } + + private MapCoordinates point() { + return MapCoordinates.of(new BigDecimal("10.123456"), new BigDecimal("20.654321")); + } + + private MapBounds bounds() { + return MapBounds.of(new BigDecimal("10"), new BigDecimal("11"), + new BigDecimal("20"), new BigDecimal("21")); + } + + private AdminRestaurantCommand addressCommand(String address) { + return new AdminRestaurantCommand(null, null, null, null, address, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); + } + +} diff --git a/src/test/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageRequestTest.java b/src/test/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageRequestTest.java new file mode 100644 index 00000000..81e77bef --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/dto/RestaurantMapPageRequestTest.java @@ -0,0 +1,88 @@ +package org.sopt.hashi.restaurant.dto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import java.math.BigDecimal; +import java.time.Duration; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.util.LinkedMultiValueMap; + +class RestaurantMapPageRequestTest { + @Test + void BBOX와_검색어_내부공백을_보존하고_정렬은_추천을_기본값으로_한다() { + var parameters = bounds(); + parameters.add("keyword", " sushi A "); + var request = RestaurantMapPageRequest.from(parameters); + assertThat(request.criteria().keyword()).isEqualTo("sushi A"); + assertThat(request.sort()).isEqualTo(RestaurantMapSort.RECOMMEND); + assertThat(request.toString()).doesNotContain("sushi"); + } + + @ParameterizedTest + @ValueSource(strings = {"size", "unknown", "foodCategory", "cursor", "querySessionId", "south"}) + void 미지원_혼합_중복_파라미터를_거절한다(String parameter) { + var parameters = bounds(); + parameters.add(parameter, "1"); + assertThatThrownBy(() -> RestaurantMapPageRequest.from(parameters)).isInstanceOf(BusinessException.class); + } + + @Test + void cursor만_혹은_세션과정렬만_허용한다() { + var parameters = new LinkedMultiValueMap(); + parameters.add("cursor", "cursor"); + assertThat(RestaurantMapPageRequest.from(parameters).cursor()).isEqualTo("cursor"); + parameters.add("sort", "rating"); + assertThatThrownBy(() -> RestaurantMapPageRequest.from(parameters)).isInstanceOf(BusinessException.class); + parameters.clear(); + parameters.add("querySessionId", "session"); + assertThatThrownBy(() -> RestaurantMapPageRequest.from(parameters)).isInstanceOf(BusinessException.class); + parameters.add("sort", "reviews"); + assertThat(RestaurantMapPageRequest.from(parameters).sort()).isEqualTo(RestaurantMapSort.REVIEWS); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-1", "9223372036854775808", "", "+1", "1.0"}) + void 지역ID_형식을_검증한다(String id) { + var parameters = bounds(); + parameters.add("mapRegionId", id); + assertThatThrownBy(() -> RestaurantMapPageRequest.from(parameters)).isInstanceOf(BusinessException.class); + } + + private LinkedMultiValueMap bounds() { + var parameters = new LinkedMultiValueMap(); + parameters.add("south", "0"); + parameters.add("north", "1"); + parameters.add("west", "0"); + parameters.add("east", "1"); + return parameters; + } + + @ParameterizedTest + @ValueSource(strings = {"1e-2147483647", "1e2147483647", "1e2147483648", "1e-129", "1e129", "NaN", "Infinity"}) + void 비정상_지수는_과대한_산술_전에_빠르게_거절한다(String south) { + var parameters = bounds(); + parameters.set("south", south); + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> assertThatThrownBy( + () -> RestaurantMapPageRequest.from(parameters)).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_BOUNDS_INVALID))); + } + + @Test + void 숫자길이128과_scale128까지_원문정밀도를_보존하고_초과를_거절한다() { + var parameters = bounds(); + parameters.set("south", "0." + "1".repeat(126)); + assertThat(RestaurantMapPageRequest.from(parameters).criteria().bounds().south()) + .isEqualTo(new BigDecimal("0." + "1".repeat(126))); + parameters.set("south", "1e-128"); + assertThat(RestaurantMapPageRequest.from(parameters).criteria().bounds().south()).isEqualTo(new BigDecimal("1e-128")); + parameters.set("south", "0." + "1".repeat(127)); + assertThatThrownBy(() -> RestaurantMapPageRequest.from(parameters)).isInstanceOf(BusinessException.class); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/GeocodingBoundaryTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/GeocodingBoundaryTest.java new file mode 100644 index 00000000..606f9f46 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/GeocodingBoundaryTest.java @@ -0,0 +1,28 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +class GeocodingBoundaryTest { + + @Test + void provider와_후보_DTO는_restaurant_밖으로_노출하지_않는다() { + var classes = new ClassFileImporter().withImportOption(new ImportOption.DoNotIncludeTests()) + .importPackages("org.sopt.hashi"); + noClasses().that().resideOutsideOfPackage("org.sopt.hashi.restaurant..") + .should().dependOnClassesThat().resideInAPackage("org.sopt.hashi.restaurant.internal.map..") + .check(classes); + } + + @Test + void 영구지도구현은_한시적_migration_코드에_의존하지_않는다() { + var classes = new ClassFileImporter().withImportOption(new ImportOption.DoNotIncludeTests()) + .importPackages("org.sopt.hashi.restaurant"); + noClasses().that().resideInAPackage("org.sopt.hashi.restaurant.internal.map..") + .should().dependOnClassesThat().resideInAPackage("org.sopt.hashi.restaurant.migration..") + .check(classes); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/LocationJobConfigurationTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/LocationJobConfigurationTest.java new file mode 100644 index 00000000..1dbd5e28 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/LocationJobConfigurationTest.java @@ -0,0 +1,63 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.scheduling.TaskScheduler; + +class LocationJobConfigurationTest { + private final ApplicationContextRunner context = new ApplicationContextRunner() + .withUserConfiguration(LocationJobConfiguration.class); + + @Test + void 기본_비활성은_키와_운영_지도설정_없이_부팅한다() { + context.run(result -> { + assertThat(result).hasNotFailed(); + assertThat(result.getBean(LocationJobProperties.class).enabled()).isFalse(); + }); + } + + @Test + void worker를_켜도_지도_설정이_부족하면_기존_애플리케이션_부팅을_막지_않는다() { + context.withPropertyValues("hashi.map.location-job.enabled=true").run(result -> { + assertThat(result).hasNotFailed(); + assertThat(result.getBean(LocationJobProperties.class).isConfigured()).isFalse(); + }); + } + + @Test + void 위치_HTTP_대기_중에도_기본_스케줄러의_다른_작업은_실행된다() { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + RestaurantLocationWorker worker = mock(RestaurantLocationWorker.class); + doAnswer(invocation -> { + entered.countDown(); + release.await(10, TimeUnit.SECONDS); + return null; + }).when(worker).runOnce(); + context.withConfiguration(AutoConfigurations.of(TaskSchedulingAutoConfiguration.class)) + .withUserConfiguration(LocationJobScheduler.class) + .withBean(RestaurantLocationWorker.class, () -> worker) + .withPropertyValues("hashi.map.location-job.enabled=true", "hashi.map.location-job.poll-delay=60000") + .run(result -> { + try { + assertThat(result).hasNotFailed(); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + CountDownLatch otherTask = new CountDownLatch(1); + result.getBean("taskScheduler", TaskScheduler.class) + .schedule(otherTask::countDown, Instant.now()); + assertThat(otherTask.await(2, TimeUnit.SECONDS)).isTrue(); + } finally { + release.countDown(); + } + }); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionConfigurationTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionConfigurationTest.java new file mode 100644 index 00000000..4d5421c9 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/LocationRetentionConfigurationTest.java @@ -0,0 +1,81 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class LocationRetentionConfigurationTest { + @Test + void 영구정리의_기본한도를_유지하고_scheduler는_기본비활성이다() { + new ApplicationContextRunner() + .withUserConfiguration(LocationRetentionConfiguration.class, LocationRetentionScheduler.class) + .withBean(LocationRetentionService.class, () -> mock(LocationRetentionService.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(LocationRetentionScheduler.class); + assertThat(context.getBean(LocationRetentionProperties.class)) + .isEqualTo(new LocationRetentionProperties(50, 1, Duration.ofDays(3), + Duration.ZERO, false, Duration.ofMinutes(1))); + }); + } + + @Test + void 기존_외부설정이름을_영구정리설정으로_바인딩한다() { + new ApplicationContextRunner().withUserConfiguration(LocationRetentionConfiguration.class) + .withPropertyValues("hashi.map.maintenance.batch-size=25", "hashi.map.maintenance.max-batches=3", + "hashi.map.maintenance.refresh-ahead=2d", "hashi.map.maintenance.purge-ahead=2h", + "hashi.map.maintenance.retention-enabled=true", "hashi.map.maintenance.poll-delay=2m") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(LocationRetentionProperties.class)) + .isEqualTo(new LocationRetentionProperties(25, 3, Duration.ofDays(2), + Duration.ofHours(2), true, Duration.ofMinutes(2))); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"batch-size=0", "batch-size=101", "max-batches=0", "max-batches=101", + "poll-delay=999ms", "poll-delay=61m", "purge-ahead=-1s", "refresh-ahead=0s", + "refresh-ahead=31d", "refresh-ahead=PT3600.000000001S"}) + void 영구정리설정도_기존한도와_기한전여유시간을_검증한다(String property) { + new ApplicationContextRunner().withUserConfiguration(LocationRetentionConfiguration.class) + .withPropertyValues("hashi.map.maintenance." + property) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void 활성정리scheduler는_전용thread에서_실행하고_context와_함께_종료된다() { + var service = mock(LocationRetentionService.class); + CountDownLatch called = new CountDownLatch(1); + AtomicReference name = new AtomicReference<>(); + AtomicReference scheduler = new AtomicReference<>(); + when(service.purge(any())).thenAnswer(invocation -> { + name.set(Thread.currentThread().getName()); + called.countDown(); + return new LocationRetentionService.Report(Instant.now(), 0, 0, 0, 0); + }); + new ApplicationContextRunner() + .withUserConfiguration(LocationRetentionConfiguration.class, LocationRetentionScheduler.class) + .withBean(LocationRetentionService.class, () -> service) + .withPropertyValues("hashi.map.maintenance.retention-enabled=true") + .run(context -> { + assertThat(context).hasNotFailed(); + scheduler.set(context.getBean(LocationRetentionScheduler.class)); + assertThat(called.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(name.get()).isEqualTo("location-retention"); + assertThat(scheduler.get().isRunning()).isTrue(); + }); + assertThat(scheduler.get().isRunning()).isFalse(); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/MapCapacityMetricsTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapCapacityMetricsTest.java new file mode 100644 index 00000000..13c99786 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapCapacityMetricsTest.java @@ -0,0 +1,44 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics.Operation; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics.Stage; + +class MapCapacityMetricsTest { + + @Test + void 단계_타이머는_실패도_기록하고_operation과_stage만_고정_태그로_사용한다() { + var registry = new SimpleMeterRegistry(); + var metrics = new MapCapacityMetrics(registry); + + for (Operation operation : Operation.values()) { + for (Stage stage : Stage.values()) { + assertThat(metrics.record(operation, stage, () -> "recorded")).isEqualTo("recorded"); + } + } + assertThatThrownBy(() -> metrics.record(Operation.NEW_QUERY, Stage.CANDIDATES, () -> { + throw new IllegalStateException("synthetic failure"); + })).isInstanceOf(IllegalStateException.class); + + var timers = registry.getMeters().stream() + .filter(meter -> meter.getId().getName().equals(MapCapacityMetrics.STAGE_DURATION_METRIC)) + .toList(); + assertThat(timers).hasSize(Operation.values().length * Stage.values().length) + .allSatisfy(meter -> { + assertThat(meter.getId().getTags()).extracting(Tag::getKey) + .containsExactlyInAnyOrder("operation", "stage"); + assertThat(meter.getId().getTag("operation")) + .isIn("new_query", "sort_change", "next_page"); + assertThat(meter.getId().getTag("stage")) + .isIn("admit", "load_session", "candidates", "save", "read_page", "touch"); + }); + assertThat(registry.get(MapCapacityMetrics.STAGE_DURATION_METRIC) + .tags("operation", "new_query", "stage", "candidates") + .timer().count()).isEqualTo(2); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/MapCursorCodecTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapCursorCodecTest.java new file mode 100644 index 00000000..d1cee999 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapCursorCodecTest.java @@ -0,0 +1,86 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.UUID; +import javax.crypto.Mac; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; + +class MapCursorCodecTest { + private final MapSessionProperties properties = configured(); + private final MapCursorCodec codec = new MapCursorCodec(properties); + + @Test + void 고정_서명키로_다른_인스턴스에서도_같은_불투명_토큰을_읽는다() { + var id = new MapSessionId(UUID.randomUUID()); + String token = codec.encode(id, RestaurantMapSort.REVIEWS, 499); + assertThat(token).hasSize(72).doesNotContain(id.value()); + assertThat(new MapCursorCodec(configured()).decode(token)) + .isEqualTo(new MapCursorCodec.DecodedCursor(id, RestaurantMapSort.REVIEWS, 499)); + assertThat(codec.encode(id, RestaurantMapSort.REVIEWS, 499)).isEqualTo(token); + } + + @Test + void 변조_과길이_비정규_base64_일반목록cursor를_거절하고_원문을_남기지_않는다() { + String token = codec.encode(new MapSessionId(UUID.randomUUID()), RestaurantMapSort.RECOMMEND, 10); + for (String value : new String[]{"!" + token.substring(1), token + "=", "x".repeat(513), "eyJpZCI6MX0"}) { + assertThatThrownBy(() -> codec.decode(value)).isInstanceOfSatisfying(BusinessException.class, exception -> { + assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT); + assertThat(exception.getMessage()).doesNotContain(value); + assertThat(exception.getCause()).isNull(); + }); + } + byte[] bytes = Base64.getUrlDecoder().decode(token); + bytes[8] ^= 1; + assertThatThrownBy(() -> codec.decode(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes))) + .isInstanceOf(BusinessException.class); + } + + @ParameterizedTest + @ValueSource(ints = {0, 17, 18}) + void 서명이_맞아도_버전_슬롯_정렬_후보위치가_잘못되면_거절한다(int offset) throws Exception { + byte[] bytes = Base64.getUrlDecoder().decode(codec.encode(new MapSessionId(UUID.randomUUID()), + RestaurantMapSort.RATING, 10)); + if (offset == 18) { + ByteBuffer.wrap(bytes).putInt(18, Integer.MAX_VALUE); + } else { + bytes[offset] = (byte) 255; + } + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(properties.requireSigningKey()); + System.arraycopy(mac.doFinal(Arrays.copyOf(bytes, 22)), 0, bytes, 22, 32); + assertThatThrownBy(() -> codec.decode(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes))) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); + } + + @Test + void 설정_누락과_잘못된_키는_요청시_503이며_설정_toString에_키를_넣지_않는다() { + MapSessionProperties missing = new MapSessionProperties(); + for (String key : new String[]{null, "", "private-fixture", "x".repeat(129)}) { + missing.setSigningKey(key); + assertThatThrownBy(missing::requireSigningKey).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE)); + } + assertThat(configured().toString()).doesNotContain("fixture", "signingKey"); + } + + static MapSessionProperties configured() { + var properties = new MapSessionProperties(); + properties.setEnabled(true); + properties.setSigningKey(Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes(StandardCharsets.UTF_8))); + return properties; + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/MapQueryPropertiesTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapQueryPropertiesTest.java new file mode 100644 index 00000000..96817e8a --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapQueryPropertiesTest.java @@ -0,0 +1,33 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +class MapQueryPropertiesTest { + + @ParameterizedTest + @ValueSource(strings = {"", "bad-number", "NaN", "91", "0"}) + void 설정_누락이나_오류가_서버_시작을_막지_않고_지도_요청에서만_503이다(String value) { + new ApplicationContextRunner().withUserConfiguration(PropertiesConfiguration.class) + .withPropertyValues("hashi.restaurant.map.initial-bounds.south=" + value) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThatThrownBy(() -> context.getBean(MapQueryProperties.class).requireConfiguration()) + .isInstanceOfSatisfying(BusinessException.class, exception -> assertThat(exception.getErrorCode()) + .isEqualTo(RestaurantErrorCode.MAP_CONFIGURATION_UNAVAILABLE)); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(MapQueryProperties.class) + static class PropertiesConfiguration { + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/MapQuerySessionTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapQuerySessionTest.java new file mode 100644 index 00000000..5a2aefed --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapQuerySessionTest.java @@ -0,0 +1,174 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent.ResultBounds; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class MapQuerySessionTest { + private static final Instant RANKING_AS_OF = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant COORDINATES_VALID_UNTIL = RANKING_AS_OF.plusSeconds(1800); + private static final Instant SESSION_EXPIRES_AT = RANKING_AS_OF.plusSeconds(900); + + @Test + void 순위_동점은_각기다른_정렬에서도_최초추천순을_보존한다() { + var session = session(List.of(candidate(3, "4.0", 2), candidate(1, "5.0", 1), + candidate(2, "4.0", 2), candidate(4, "5.0", 2))); + assertThat(session.ordered(RestaurantMapSort.RECOMMEND)).extracting(RestaurantMapCandidate::restaurantId) + .containsExactly(3L, 1L, 2L, 4L); + assertThat(session.ordered(RestaurantMapSort.RATING)).extracting(RestaurantMapCandidate::restaurantId) + .containsExactly(1L, 4L, 3L, 2L); + assertThat(session.ordered(RestaurantMapSort.REVIEWS)).extracting(RestaurantMapCandidate::restaurantId) + .containsExactly(3L, 2L, 4L, 1L); + assertThat(session.candidates()).extracting(RestaurantMapCandidate::restaurantId).containsExactly(3L, 1L, 2L, 4L); + } + + @Test + void 손상세션과_중복후보는_복원하지_않고_허용한_정밀도는_그대로_왕복한다() { + var serializer = new MapSessionSerializer(); + for (String json : List.of("null", "{}", "{\"formatVersion\":2}", "{\"schemaVersion\":1}", + "[\"java.lang.Runtime\",{}]")) { + assertThatThrownBy(() -> serializer.deserialize(json)).isInstanceOf(BusinessException.class); + } + assertThatThrownBy(() -> session(List.of(candidate(1, "4.0", 1), candidate(1, "4.0", 1)))) + .isInstanceOf(IllegalArgumentException.class); + var criteria = MapSearchCriteria.of(MapQueryBounds.parse("0." + "1".repeat(2000), "1", "0", "1"), + null, null, null, null); + var session = new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), criteria, List.of(), + null, Instant.now(), Instant.now().plusSeconds(900)); + assertThat(serializer.deserialize(serializer.serialize(session))).isEqualTo(session); + } + + @Test + void 검색결과요약은_개별좌표없이_왕복하고_빈결과와_단일좌표를_구분한다() throws Exception { + var serializer = new MapSessionSerializer(); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var point = new ResultBounds(new BigDecimal("0.5"), new BigDecimal("0.5"), + new BigDecimal("0.5"), new BigDecimal("0.5")); + var one = searchSession(List.of(candidate(1, "4.0", 2)), + new MapSearchResultExtent(1, point, COORDINATES_VALID_UNTIL)); + + String oneJson = serializer.serialize(one); + assertThat(serializer.deserialize(oneJson)).isEqualTo(one); + assertThat(oneJson).contains("\"searchResult\":{\"totalCount\":1") + .doesNotContain("latitude", "longitude"); + + var empty = searchSession(List.of(), MapSearchResultExtent.empty()); + var emptyPayload = mapper.readTree(serializer.serialize(empty)); + assertThat(emptyPayload.at("/searchResult/totalCount").asLong()).isZero(); + assertThat(emptyPayload.at("/searchResult/bounds").isNull()).isTrue(); + } + + @Test + void 검색결과요약의_잘못된경계와_후보수불일치_구버전은_410으로_거절한다() throws Exception { + var serializer = new MapSessionSerializer(); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var point = new ResultBounds(new BigDecimal("0.5"), new BigDecimal("0.5"), + new BigDecimal("0.5"), new BigDecimal("0.5")); + String json = serializer.serialize( + searchSession(List.of(candidate(1, "4.0", 2)), + new MapSearchResultExtent(1, point, COORDINATES_VALID_UNTIL))); + + ObjectNode countMismatch = (ObjectNode) mapper.readTree(json); + ((ObjectNode) countMismatch.path("searchResult")).put("totalCount", 2); + ObjectNode invalidBounds = (ObjectNode) mapper.readTree(json); + ((ObjectNode) invalidBounds.at("/searchResult/bounds")).put("south", 2); + ObjectNode invalidExpiry = (ObjectNode) mapper.readTree(json); + invalidExpiry.put("expiresAt", COORDINATES_VALID_UNTIL.plusSeconds(1).toString()); + ObjectNode legacy = (ObjectNode) mapper.readTree(json); + legacy.put("formatVersion", 1).put("schemaVersion", 1); + + for (String corrupted : List.of(mapper.writeValueAsString(countMismatch), + mapper.writeValueAsString(invalidBounds), mapper.writeValueAsString(invalidExpiry), + mapper.writeValueAsString(legacy))) { + assertThatThrownBy(() -> serializer.deserialize(corrupted)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_SESSION_EXPIRED)); + } + } + + @Test + void 후보_tuple은_세값을_함께_보존하고_620개_payload를_기존객체형식보다_줄인다() throws Exception { + var candidates = IntStream.rangeClosed(1, 620) + .mapToObj(id -> candidate(id, "0.0", 0)).toList(); + var session = session(candidates); + var serializer = new MapSessionSerializer(); + + String compact = serializer.serialize(session); + var legacyMapper = new ObjectMapper().registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + int legacyBytes = legacyMapper.writeValueAsString(session).getBytes(StandardCharsets.UTF_8).length; + int compactBytes = compact.getBytes(StandardCharsets.UTF_8).length; + System.out.printf("MAP_SESSION_SERIALIZATION candidates=620 legacyBytes=%d compactBytes=%d%n", + legacyBytes, compactBytes); + + assertThat(serializer.deserialize(compact)).isEqualTo(session); + assertThat(compact).contains("\"candidates\":[[1,0.0,0],[2,0.0,0]") + .doesNotContain("restaurantId", "reviewCount"); + assertThat(compactBytes).isLessThan(legacyBytes / 3); + } + + @Test + void 후보_tuple의_누락_추가_필드는_손상세션으로_거절한다() { + var serializer = new MapSessionSerializer(); + String json = serializer.serialize(session(List.of(candidate(1, "4.0", 2)))); + + for (String corrupted : List.of(json.replace("[1,4.0,2]", "[1,4.0]"), + json.replace("[1,4.0,2]", "[1,4.0,2,3]"), + json.replace("[[1,4.0,2]]", "[null]"))) { + assertThatThrownBy(() -> serializer.deserialize(corrupted)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_SESSION_EXPIRED)); + } + } + + @Test + void 운영설정은_키를_출력하는_getter없이도_바인딩된다() { + String key = Base64.getEncoder().encodeToString("synthetic-map-test-key-32-bytes-only".getBytes()); + var binder = new Binder(new MapConfigurationPropertySource(Map.of( + "hashi.restaurant.map.session.enabled", "true", "hashi.restaurant.map.session.signing-key", key))); + var properties = binder.bind("hashi.restaurant.map.session", Bindable.of(MapSessionProperties.class)).get(); + properties.requireConfigured(); + assertThat(properties.requireSigningKey().getEncoded()).isEqualTo(Base64.getDecoder().decode(key)); + assertThat(properties.toString()).doesNotContain(key); + } + + private MapQuerySession session(List candidates) { + return new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, null), candidates, null, + Instant.now(), Instant.now().plusSeconds(900)); + } + + private MapQuerySession searchSession(List candidates, MapSearchResultExtent searchResult) { + return new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, "fixture"), candidates, searchResult, + RANKING_AS_OF, SESSION_EXPIRES_AT); + } + + private RestaurantMapCandidate candidate(long id, String rating, long reviews) { + return new RestaurantMapCandidate(id, new BigDecimal(rating), reviews); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/MapRedisIsolationIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapRedisIsolationIntegrationTest.java new file mode 100644 index 00000000..b1c0dade --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapRedisIsolationIntegrationTest.java @@ -0,0 +1,283 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Ports; +import com.github.dockerjava.api.model.PortBinding; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ServerSocket; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.sopt.hashi.auth.internal.jwt.JwtProperties; +import org.sopt.hashi.auth.internal.token.RefreshTokenStore; +import org.sopt.hashi.config.RedisConfig; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** 실제 Boot 인증 배선 + 서로 다른 메모리 정책의 Redis 두 개를 사용한다. 운영 서버에는 연결하지 않는다. */ +@Testcontainers +class MapRedisIsolationIntegrationTest { + private static final long CONTAINER_BYTES = 128L * 1024 * 1024; + private static final String PREFIX = "hashi:restaurant:map:{sessions-v2}:"; + private static final String AUTH_KEY = "auth:refresh:ROLE_USER:1"; + @Container + static final GenericContainer AUTH = redis("volatile-lru"); + @Container + static final GenericContainer MAP = redis("noeviction"); + + private static GenericContainer redis(String policy) { + // Docker Desktop은 hostPort=0 매핑을 컨테이너 재시작 때 다시 할당할 수 있다. + // 테스트가 같은 Redis endpoint의 재시작을 검증하도록 빈 루프백 포트를 한 번 정한다. + int port; + try (var socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + return new GenericContainer<>(DockerImageName.parse( + "redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499")) + .withExposedPorts(6379) + .withCommand("redis-server", "--maxmemory", "64mb", "--maxmemory-policy", policy, + "--appendonly", "no", "--save", "") + .withCreateContainerCmdModifier(command -> command.getHostConfig() + .withMemory(CONTAINER_BYTES).withMemorySwap(CONTAINER_BYTES).withNanoCPUs(500_000_000L) + .withPortBindings(new PortBinding(Ports.Binding.bindIpAndPort("127.0.0.1", port), + ExposedPort.tcp(6379)))); + } + + @BeforeEach + void resetOnlyDisposableRedis() throws Exception { + for (var redis : List.of(AUTH, MAP)) { + assertThat(redis.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "64mb").getExitCode()).isZero(); + assertThat(redis.execInContainer("redis-cli", "FLUSHDB").getExitCode()).isZero(); + } + } + + private ApplicationContextRunner runner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RedisAutoConfiguration.class)) + .withUserConfiguration(ConfigurationUnderTest.class) + .withPropertyValues("spring.data.redis.host=" + AUTH.getHost(), + "spring.data.redis.port=" + AUTH.getMappedPort(6379), + "spring.data.redis.timeout=1s", "spring.data.redis.connect-timeout=1s", + "hashi.restaurant.map.redis.host=" + MAP.getHost(), + "hashi.restaurant.map.redis.port=" + MAP.getMappedPort(6379), + "hashi.restaurant.map.redis.timeout=1s", "hashi.restaurant.map.redis.connect-timeout=1s", + "hashi.restaurant.map.session.enabled=true", + "hashi.restaurant.map.session.signing-key=" + Base64.getEncoder().encodeToString(new byte[32])); + } + + @Test + void 공유모드도_기존_인증배선을_유지하며_지도세션유실이_토큰회전을_변경하지_않는다() { + runner().withPropertyValues("hashi.restaurant.map.redis.mode=shared", + "hashi.restaurant.map.redis.host=" + AUTH.getHost(), + "hashi.restaurant.map.redis.port=" + AUTH.getMappedPort(6379)).run(context -> { + assertThat(context).hasSingleBean(RedisConnectionFactory.class).hasSingleBean(StringRedisTemplate.class); + var auth = context.getBean(StringRedisTemplate.class); + var map = context.getBean(MapRedisConnection.class).template(); + var store = context.getBean(RedisMapSessionStore.class); + var tokens = context.getBean(RefreshTokenStore.class); + String shared = "hashi:restaurant:map:{sessions-shared-v3}:"; + tokens.save("ROLE_USER", 1L, "synthetic-original"); + assertThatThrownBy(() -> store.admit("warmup", true)).isInstanceOf(BusinessException.class); + // 테스트 전용 시각 이동. 제품에는 준비/복구 대기를 우회하는 API가 없다. + map.opsForHash().put(shared + "requests", "__readyAt", "0"); + map.opsForHash().put(shared + "admission", "__readyAt", "0"); + var session = session(store); + var id = store.save(session); + assertThat(auth.hasKey(shared + "query:" + id.value())).isTrue(); + map.delete(shared + "query:" + id.value()); + assertThatThrownBy(() -> store.find(id)).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_SESSION_EXPIRED)); + tokens.rotate("ROLE_USER", 1L, "synthetic-original", "synthetic-rotated"); + assertThat(context.getBean("redisTemplate", RedisTemplate.class).opsForValue().get(AUTH_KEY)) + .isEqualTo("synthetic-rotated"); + assertThat(map.getConnectionFactory()).isNotSameAs(auth.getConnectionFactory()); + }); + } + + @Test + void 자동구성은_인증용으로_유지하고_지도키는_별도_Redis에만_저장한다() { + runner().run(context -> { + assertThat(context).hasSingleBean(RedisConnectionFactory.class).hasSingleBean(StringRedisTemplate.class); + var auth = context.getBean(StringRedisTemplate.class); + var map = context.getBean(MapRedisConnection.class).template(); + var store = context.getBean(RedisMapSessionStore.class); + var tokens = context.getBean(RefreshTokenStore.class); + tokens.save("ROLE_USER", 1L, "synthetic-original"); + var session = session(store); + var id = store.save(session); + assertThat(store.find(id)).isEqualTo(session); + assertThat(auth.keys(PREFIX + "*")).isEmpty(); + assertThat(map.hasKey(AUTH_KEY)).isFalse(); + assertThat(map.hasKey(PREFIX + "query:" + id.value())).isTrue(); + tokens.rotate("ROLE_USER", 1L, "synthetic-original", "synthetic-rotated"); + assertThat(context.getBean("redisTemplate", RedisTemplate.class).opsForValue().get(AUTH_KEY)) + .isEqualTo("synthetic-rotated"); + assertThat(auth.getConnectionFactory()).isSameAs(context.getBean(RedisConnectionFactory.class)); + assertThat(map.getConnectionFactory()).isNotSameAs(auth.getConnectionFactory()); + try (var connection = auth.getConnectionFactory().getConnection()) { + assertThat(connection.serverCommands().info("memory").getProperty("maxmemory_policy")) + .isEqualTo("volatile-lru"); + } + }); + } + + @Test + void 지도_비활성화나_호스트누락은_인증연결로_대체하지_않는다() { + runner().withPropertyValues("hashi.restaurant.map.session.enabled=false").run(context -> { + assertThat(context).hasNotFailed(); + var holder = context.getBean(MapRedisConnection.class); + assertCode(holder::template, RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(ReflectionTestUtils.getField(holder, "connections")).isNull(); + verifyTokenRotation(context.getBean(RefreshTokenStore.class)); + }); + runner().withPropertyValues("hashi.restaurant.map.redis.host=").run(context -> { + assertThat(context).hasNotFailed(); + assertCode(() -> context.getBean(RedisMapSessionStore.class).admit("synthetic", true), + RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(ReflectionTestUtils.getField(context.getBean(MapRedisConnection.class), "connections")).isNull(); + verifyTokenRotation(context.getBean(RefreshTokenStore.class)); + }); + } + + @ParameterizedTest + @CsvSource({"port,not-a-port", "ssl,not-a-boolean", "connect-timeout,not-a-duration", "timeout,not-a-duration"}) + void 지도설정_형식오류도_앱시작과_인증을_막지_않고_지도연결만_거절한다(String property, String invalidValue) { + for (boolean enabled : List.of(false, true)) { + runner().withPropertyValues("hashi.restaurant.map.redis." + property + "=" + invalidValue, + "hashi.restaurant.map.session.enabled=" + enabled).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(RedisConnectionFactory.class); + var holder = context.getBean(MapRedisConnection.class); + assertCode(holder::template, RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(ReflectionTestUtils.getField(holder, "connections")).isNull(); + assertThat(ReflectionTestUtils.getField(holder, "clientResources")).isNull(); + verifyTokenRotation(context.getBean(RefreshTokenStore.class)); + assertThat(context.getBean(StringRedisTemplate.class).keys(PREFIX + "*")).isEmpty(); + }); + } + } + + @Test + void 지도무응답은_503으로_끝나고_동시에_인증토큰은_회전한다() { + runner().run(context -> { + var store = context.getBean(RedisMapSessionStore.class); + var id = store.save(session(store)); + MAP.getDockerClient().pauseContainerCmd(MAP.getContainerId()).exec(); + try { + Instant start = Instant.now(); + assertCode(() -> store.find(id), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(Duration.between(start, Instant.now())).isLessThan(Duration.ofSeconds(5)); + verifyTokenRotation(context.getBean(RefreshTokenStore.class)); + } finally { + MAP.getDockerClient().unpauseContainerCmd(MAP.getContainerId()).exec(); + } + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> assertThat(store.find(id)).isNotNull()); + }); + } + + @Test + void 지도재시작은_기존조회만_410으로_만료시키고_새조회와_인증은_정상이다() { + runner().run(context -> { + var store = context.getBean(RedisMapSessionStore.class); + var id = store.save(session(store)); + var tokens = context.getBean(RefreshTokenStore.class); + tokens.save("ROLE_USER", 1L, "synthetic-original"); + MAP.getDockerClient().restartContainerCmd(MAP.getContainerId()).withTimeout(1).exec(); + await().atMost(Duration.ofSeconds(15)).ignoreExceptions().untilAsserted(() -> + assertThat(store.admit("after-restart", false)).isNotNull()); + assertCode(() -> store.find(id), RestaurantErrorCode.MAP_SESSION_EXPIRED); + tokens.rotate("ROLE_USER", 1L, "synthetic-original", "synthetic-rotated"); + assertThat(store.find(store.save(session(store)))).isNotNull(); + }); + } + + @Test + void 지도_메모리쓰기거절과_TTL회수는_인증토큰을_삭제하지_않는다() { + runner().run(context -> { + var store = context.getBean(RedisMapSessionStore.class); + var holder = context.getBean(MapRedisConnection.class); + context.getBean(MapSessionLimits.class).setIdleTimeout(Duration.ofSeconds(1)); + var id = store.save(session(store)); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertCode(() -> store.find(id), RestaurantErrorCode.MAP_SESSION_EXPIRED)); + assertThat(MAP.getDockerClient().inspectContainerCmd(MAP.getContainerId()).exec() + .getHostConfig().getMemory()).isEqualTo(CONTAINER_BYTES); + try (var connection = holder.template().getConnectionFactory().getConnection()) { + connection.serverCommands().setConfig("maxmemory", "1"); + assertThatThrownBy(() -> holder.template().opsForValue().set("synthetic-overflow", "value")) + .isInstanceOf(DataAccessException.class); + assertCode(() -> store.admit("memory-full", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + verifyTokenRotation(context.getBean(RefreshTokenStore.class)); + connection.serverCommands().setConfig("maxmemory", "64mb"); + } + assertThat(store.find(store.save(session(store)))).isNotNull(); + }); + } + + private static MapQuerySession session(RedisMapSessionStore store) { + Instant now = store.admit("synthetic-caller", true); + return new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, null), List.of(), null, + now, now.plus(Duration.ofMinutes(5))); + } + + private static void verifyTokenRotation(RefreshTokenStore tokens) { + tokens.save("ROLE_USER", 1L, "synthetic-original"); + tokens.rotate("ROLE_USER", 1L, "synthetic-original", "synthetic-rotated"); + tokens.rotate("ROLE_USER", 1L, "synthetic-rotated", "synthetic-final"); + } + + private static void assertCode(Runnable action, RestaurantErrorCode expected) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(expected)); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties({MapRedisProperties.class, MapSessionProperties.class}) + @Import({RedisConfig.class, MapRedisConnection.class, RedisMapSessionStore.class, MapSessionSerializer.class, + MapSessionLimits.class, MapCapacityMetrics.class, RefreshTokenStore.class}) + static class ConfigurationUnderTest { + @Bean + MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + + @Bean + JwtProperties jwtProperties() { + return new JwtProperties("synthetic-not-used-to-sign-a-token", Duration.ofMinutes(30), + Duration.ofDays(14), Duration.ofMinutes(30)); + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/MapRedisPropertiesTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapRedisPropertiesTest.java new file mode 100644 index 00000000..1a387f8e --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapRedisPropertiesTest.java @@ -0,0 +1,79 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +class MapRedisPropertiesTest { + @Test + void 운영환경변수_이름으로_지도전용_연결설정을_바인딩한다() { + new ApplicationContextRunner().withUserConfiguration(PropertiesConfiguration.class) + .withInitializer(context -> context.getEnvironment().getPropertySources().addFirst( + new SystemEnvironmentPropertySource("systemEnvironment", Map.of( + "HASHI_RESTAURANT_MAP_REDIS_HOST", "map-redis.synthetic", + "HASHI_RESTAURANT_MAP_REDIS_PORT", "16379", + "HASHI_RESTAURANT_MAP_REDIS_SSL", "true", + "HASHI_RESTAURANT_MAP_REDIS_USERNAME", "synthetic-user", + "HASHI_RESTAURANT_MAP_REDIS_PASSWORD", "synthetic-password", + "HASHI_RESTAURANT_MAP_REDIS_CONNECTTIMEOUT", "500ms", + "HASHI_RESTAURANT_MAP_REDIS_TIMEOUT", "1s")))) + .run(context -> { + assertThat(context).hasNotFailed(); + var properties = context.getBean(MapRedisProperties.class); + assertThatCode(properties::requireConfiguration).doesNotThrowAnyException(); + var settings = properties.requireConfiguration(); + assertThat(properties.getHost()).isEqualTo("map-redis.synthetic"); + assertThat(settings.port()).isEqualTo(16379); + assertThat(settings.ssl()).isTrue(); + assertThat(settings.connectTimeout()).isEqualTo(Duration.ofMillis(500)); + assertThat(settings.timeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(properties.getUsername()).isEqualTo("synthetic-user"); + assertThat(properties.getPassword()).isEqualTo("synthetic-password"); + assertThat(properties.toString()).doesNotContain("synthetic-user", "synthetic-password"); + assertThat(properties.shared()).isFalse(); + properties.setMode("shared"); + assertThat(properties.shared()).isTrue(); + }); + } + + @ParameterizedTest + @MethodSource("invalidConfiguration") + void 잘못된_연결설정은_503이고_접속정보를_예외에_포함하지_않는다(Consumer mutate) { + var properties = new MapRedisProperties(); + properties.setHost("map-redis.synthetic"); + properties.setPassword("synthetic-password"); + mutate.accept(properties); + assertThatThrownBy(properties::requireConfiguration).isInstanceOfSatisfying(BusinessException.class, exception -> { + assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(exception.getCause()).isNull(); + assertThat(exception.getMessage()).doesNotContain("synthetic-password", "map-redis.synthetic"); + }); + } + + private static Stream> invalidConfiguration() { + return Stream.of(properties -> properties.setHost(null), properties -> properties.setHost(" "), + properties -> properties.setPort("0"), properties -> properties.setPort("65536"), + properties -> properties.setConnectTimeout(null), properties -> properties.setTimeout("0ms"), + properties -> properties.setTimeout("31s"), properties -> properties.setMode("auto"), + properties -> properties.setMode(null)); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(MapRedisProperties.class) + static class PropertiesConfiguration { + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/MapSessionLimitsTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapSessionLimitsTest.java new file mode 100644 index 00000000..9c9331b9 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapSessionLimitsTest.java @@ -0,0 +1,48 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; + +class MapSessionLimitsTest { + @Test + void 기본값은_5분유휴와_30분상한이며_후보상한은_byte예산에서_계산한다() { + var limits = new MapSessionLimits(); + limits.validate(); + assertThat(limits.getIdleTimeout()).isEqualTo(Duration.ofMinutes(5)); + assertThat(limits.getMaxLifetime()).isEqualTo(Duration.ofMinutes(30)); + assertThat(limits.candidateCapacity()).isEqualTo(32_768); + limits.setSnapshotBytes(65_536); + assertThat(limits.candidateCapacity()).isEqualTo(2048); + } + + @Test + void 잘못된_시간_메모리_호출수_설정은_요청에서_failclosed한다() { + List> invalid = List.of( + value -> value.setConcurrentRequests(0), + value -> value.setConcurrentRequests(17), + value -> value.setIdleTimeout(Duration.ZERO), + value -> value.setMaxLifetime(Duration.ofMinutes(31)), + value -> value.setMaxLifetime(Duration.ofMinutes(1)), + value -> value.setSnapshotBytes(1), + value -> value.setTotalBytes(1024), + value -> value.setRedisHeadroom(1024), + value -> value.setRedisMemoryCeiling(1024), + value -> value.setSessions(4097), + value -> value.setCallersPerMinute(4097), + value -> value.setNewQueriesPerCaller(121)); + for (var configure : invalid) { + var limits = new MapSessionLimits(); + configure.accept(limits); + assertThatThrownBy(limits::validate).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE)); + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/MapSessionPropertiesTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapSessionPropertiesTest.java new file mode 100644 index 00000000..7f985427 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/MapSessionPropertiesTest.java @@ -0,0 +1,132 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.LoggerFactory; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +class MapSessionPropertiesTest { + private static final String KEY = Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes(StandardCharsets.UTF_8)); + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(PropertiesConfiguration.class) + .withBean("unrelatedApiMarker", Object.class, Object::new); + + @Test + void 활성화_설정을_생략하면_유효한_키가_있어도_기본_차단한다() { + withLogs(events -> withEnvironment(null, KEY).run(context -> { + assertThat(context).hasNotFailed().hasBean("unrelatedApiMarker"); + assertUnavailable(context.getBean(MapSessionProperties.class)); + assertThat(events.list).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.INFO); + assertThat(event.getFormattedMessage()).isEqualTo("Map query sessions are disabled."); + }); + })); + } + + @Test + void 기존_SIGNINGKEY_환경변수와_명시적_enabled가_실제_설정빈에_바인딩된다() { + withLogs(events -> withEnvironment("true", KEY).run(context -> { + assertThat(context).hasNotFailed(); + var properties = context.getBean(MapSessionProperties.class); + assertThatCode(properties::requireConfigured).doesNotThrowAnyException(); + assertThat(properties.requireSigningKey().getEncoded()).isEqualTo(Base64.getDecoder().decode(KEY)); + assertThat(properties.toString()).doesNotContain(KEY); + assertThat(events.list).isEmpty(); + })); + } + + @ParameterizedTest + @MethodSource("invalidKeys") + void 비활성화는_키가_없거나_무효여도_경고없이_부팅한다(String key) { + withLogs(events -> withEnvironment("false", key).run(context -> { + assertThat(context).hasNotFailed().hasBean("unrelatedApiMarker"); + assertUnavailable(context.getBean(MapSessionProperties.class)); + assertThat(events.list).noneMatch(event -> Level.WARN.equals(event.getLevel())); + assertSafe(events, key); + })); + } + + @ParameterizedTest + @MethodSource("invalidKeys") + void 활성화됐지만_키가_없거나_무효이면_안전한_경고_한번과_503만_남긴다(String key) { + withLogs(events -> withEnvironment("true", key).run(context -> { + assertThat(context).hasNotFailed().hasBean("unrelatedApiMarker"); + var properties = context.getBean(MapSessionProperties.class); + assertUnavailable(properties); + assertUnavailable(properties); + assertThat(events.list).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).isEqualTo( + "Map query sessions are enabled but the signing key is missing or invalid."); + assertThat(event.getArgumentArray()).isNull(); + }); + assertSafe(events, key); + })); + } + + private ApplicationContextRunner withEnvironment(String enabled, String key) { + Map values = new HashMap<>(); + if (enabled != null) values.put("HASHI_RESTAURANT_MAP_SESSION_ENABLED", enabled); + if (key != null) values.put("HASHI_RESTAURANT_MAP_SESSION_SIGNINGKEY", key); + // Boot는 실제 systemEnvironment 이름을 통해 환경변수 전용 relaxed binding을 선택한다. + return runner.withInitializer(context -> context.getEnvironment().getPropertySources().addFirst( + new SystemEnvironmentPropertySource("systemEnvironment", values))); + } + + private static Stream invalidKeys() { + return Stream.of(null, "", "synthetic-private-key!", "c2hvcnQ=", "x".repeat(129)); + } + + private static void assertUnavailable(MapSessionProperties properties) { + assertThatThrownBy(properties::requireConfigured).isInstanceOfSatisfying(BusinessException.class, failure -> { + assertThat(failure.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(failure.getCause()).isNull(); + }); + } + + private static void assertSafe(ListAppender events, String key) { + assertThat(events.list).allSatisfy(event -> { + assertThat(event.getThrowableProxy()).isNull(); + if (key != null && !key.isEmpty()) assertThat(event.getFormattedMessage()).doesNotContain(key); + }); + } + + private static void withLogs(Consumer> assertion) { + Logger logger = (Logger) LoggerFactory.getLogger(MapSessionProperties.class); + var appender = new ListAppender(); + appender.start(); + logger.addAppender(appender); + try { + assertion.accept(appender); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(MapSessionProperties.class) + static class PropertiesConfiguration { + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionTokenTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionTokenTest.java new file mode 100644 index 00000000..e0b944de --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/PlacesSelectionTokenTest.java @@ -0,0 +1,71 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.RestaurantLocationPreviewCommand; + +class PlacesSelectionTokenTest { + private static final Instant NOW = Instant.parse("2030-01-02T03:04:05Z"); + private static final String KEY = Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + private static final PlacesSelectionProperties PROPERTIES = + new PlacesSelectionProperties(true, KEY, Duration.ofMinutes(10)); + + @Test + void 식당_revision_request_PlaceID를_10분_서명토큰으로_왕복한다() { + UUID requestId = UUID.randomUUID(); + PlacesSelectionToken tokens = tokensAt(NOW); + + var issued = tokens.issue(11L, 3, requestId, "place-123", 7L); + + assertThat(issued.expiresAt()).isEqualTo(NOW.plus(Duration.ofMinutes(10))); + assertThat(tokens.verify(issued.token())).hasValueSatisfying(claims -> { + assertThat(claims.restaurantId()).isEqualTo(11L); + assertThat(claims.addressRevision()).isEqualTo(3); + assertThat(claims.requestId()).isEqualTo(requestId); + assertThat(claims.placeId()).isEqualTo("place-123"); + assertThat(claims.adminId()).isEqualTo(7L); + assertThat(claims.preview()).isFalse(); + }); + assertThat(issued.token()).doesNotContain("place-123"); + } + + @Test + void 변조와_만료와_설정오류는_검증을_통과하지못한다() { + String token = tokensAt(NOW).issue(11L, 3, UUID.randomUUID(), "place-123", 7L).token(); + String tampered = token.substring(0, token.length() - 1) + + (token.endsWith("A") ? "B" : "A"); + + assertThat(tokensAt(NOW).verify(tampered)).isEmpty(); + assertThat(tokensAt(NOW.plus(Duration.ofMinutes(10))).verify(token)).isEmpty(); + assertThat(new PlacesSelectionProperties(true, + Base64.getEncoder().encodeToString(new byte[31]), Duration.ofMinutes(10)).isConfigured()).isFalse(); + assertThat(new PlacesSelectionProperties(true, KEY, Duration.ofMinutes(11)).isConfigured()).isFalse(); + } + + @Test + void 등록전_토큰은_관리자와_네가지_원문입력을_묶고_빈값과_null도_구분한다() { + var input = new RestaurantLocationPreviewCommand("이름", "現地名", "住所", null); + var issued = tokensAt(NOW).issuePreview(input, "place-123", 7L); + assertThat(tokensAt(NOW).verify(issued.token())).hasValueSatisfying(claims -> { + assertThat(claims.preview()).isTrue(); + assertThat(claims.adminId()).isEqualTo(7L); + assertThat(claims.fingerprint()).isEqualTo(PlacesSelectionToken.fingerprint(input)); + }); + assertThat(PlacesSelectionToken.fingerprint(input)).isNotEqualTo(PlacesSelectionToken.fingerprint( + new RestaurantLocationPreviewCommand("이름", "現地名", "住所", ""))); + assertThat(tokensAt(NOW.plusSeconds(600)).verify(issued.token())).isEmpty(); + } + + private PlacesSelectionToken tokensAt(Instant instant) { + return new PlacesSelectionToken(PROPERTIES, Clock.fixed(instant, ZoneOffset.UTC)); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/RedisMapSessionStoreIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/RedisMapSessionStoreIntegrationTest.java new file mode 100644 index 00000000..bb991a6e --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/RedisMapSessionStoreIntegrationTest.java @@ -0,0 +1,587 @@ +package org.sopt.hashi.restaurant.internal.map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent.ResultBounds; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageRequest; +import org.sopt.hashi.restaurant.service.RestaurantMapPageReader; +import org.sopt.hashi.restaurant.service.RestaurantMapPageService; +import org.sopt.hashi.restaurant.service.RestaurantMapService; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class RedisMapSessionStoreIntegrationTest { + @Container + static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse( + "redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499")) + .withExposedPorts(6379); + private static LettuceConnectionFactory connections; + private static StringRedisTemplate redis; + private static RedisMapSessionStore store; + private static MapSessionLimits limits; + private static final SimpleMeterRegistry METRICS = new SimpleMeterRegistry(); + private static final MapCapacityMetrics CAPACITY_METRICS = new MapCapacityMetrics(METRICS); + private static final MapSessionSerializer SERIALIZER = new MapSessionSerializer(); + private static final String PREFIX = "hashi:restaurant:map:{sessions-v2}:query:"; + private static final String SHARED = "hashi:restaurant:map:{sessions-shared-v3}:"; + + @BeforeAll + static void connect() { + var client = LettuceClientConfiguration.builder().commandTimeout(Duration.ofSeconds(3)) + .clientOptions(ClientOptions.builder().socketOptions(SocketOptions.builder() + .connectTimeout(Duration.ofSeconds(2)).build()).build()).build(); + connections = new LettuceConnectionFactory(new RedisStandaloneConfiguration( + REDIS.getHost(), REDIS.getMappedPort(6379)), client); + connections.afterPropertiesSet(); + redis = new StringRedisTemplate(connections); + var connection = mock(MapRedisConnection.class); + when(connection.template()).thenReturn(redis); + limits = new MapSessionLimits(); + store = new RedisMapSessionStore(connection, SERIALIZER, limits, + CAPACITY_METRICS); + } + + @AfterAll + static void disconnect() { + if (connections != null) { + connections.destroy(); + } + } + + @BeforeEach + void clearOnlyTestRedis() { + redis.getConnectionFactory().getConnection().serverCommands().flushDb(); + METRICS.clear(); + limits.setIdleTimeout(Duration.ofMinutes(5)); + limits.setMaxLifetime(Duration.ofMinutes(30)); + limits.setSessions(1024); + limits.setSnapshotBytes(1_048_576); + limits.setTotalBytes(16_777_216); + limits.setRequestsPerMinute(600); + limits.setRequestsPerCaller(120); + limits.setNewQueriesPerCaller(12); + limits.setCallersPerMinute(2048); + limits.setRedisMemoryCeiling(134_217_728); + limits.setRedisHeadroom(33_554_432); + } + + @AfterEach + void restoreOnlyTestRedisPolicy() throws Exception { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "0"); + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory-policy", "noeviction"); + } + + @Test + void 공개전_준비명령은_대기시간을_줄이지_않고_지도회계키만_준비한다() throws Exception { + var shared = sharedStore(); + var script = new DefaultRedisScript<>( + new ClassPathResource("map/shared.lua").getContentAsString(StandardCharsets.UTF_8) + "\n" + + new ClassPathResource("map/prepare-shared.lua").getContentAsString(StandardCharsets.UTF_8), + List.class); + var keys = List.of(SHARED + "requests", SHARED + "admission"); + List first = redis.execute(script, keys, "134217728", "33554432"); + assertThat(first.get(0)).isEqualTo(-6L); + assertThat((Long) first.get(1)).isBetween(1_799_000L, 1_800_000L); + Object readyAt = redis.opsForHash().get(SHARED + "admission", "__readyAt"); + List second = redis.execute(script, keys, "134217728", "33554432"); + assertThat(second.get(0)).isEqualTo(-6L); + assertThat(redis.opsForHash().get(SHARED + "admission", "__readyAt")).isEqualTo(readyAt); + completeSharedWarmup(); + shared.admit("caller", true); + List ready = redis.execute(script, keys, "134217728", "33554432"); + assertThat(ready.get(0)).isEqualTo(0L); + assertThat(ready.get(1)).isEqualTo(0L); + assertThat(redis.opsForHash().get(SHARED + "requests", "caller")).isEqualTo("1:1"); + redis.opsForValue().set("auth:synthetic", "unchanged"); + List invalid = redis.execute(script, List.of("auth:synthetic", SHARED + "admission"), + "134217728", "33554432"); + assertThat(invalid.get(0)).isEqualTo(-7L); + assertThat(redis.opsForValue().get("auth:synthetic")).isEqualTo("unchanged"); + } + + @Test + void 공유모드_최초시작과_회계유실은_30분간_새쓰기와_기존수명연장을_차단한다() throws Exception { + var shared = sharedStore(); + assertCode(() -> shared.admit("caller", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(Long.parseLong((String) redis.opsForHash().get(SHARED + "admission", "__readyAt"))) + .isBetween(redisNow().plusSeconds(1798).toEpochMilli(), redisNow().plusSeconds(1800).toEpochMilli()); + assertThat(rejections("redis_recovery")).isEqualTo(1); + assertCode(() -> shared.save(session(Duration.ofMinutes(30))), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + completeSharedWarmup(); + shared.admit("caller", true); + var session = session(Duration.ofMinutes(30)); + var id = shared.save(session); + redis.delete(SHARED + "admission"); + assertCode(() -> shared.touch(id, session), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + long ttl = redis.getExpire(SHARED + "query:" + id.value(), java.util.concurrent.TimeUnit.MILLISECONDS); + assertCode(() -> shared.save(session(Duration.ofMinutes(30))), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(redis.getExpire(SHARED + "query:" + id.value(), java.util.concurrent.TimeUnit.MILLISECONDS)) + .isLessThanOrEqualTo(ttl); + assertThat(redis.opsForHash().size(SHARED + "admission")).isEqualTo(1); + } + + @Test + void 공유모드_예약과_요청한도는_영구키로_보존하고_조회유실은_410으로_복구한다() throws Exception { + var shared = readySharedStore(); + limits.setSessions(1); + limits.setNewQueriesPerCaller(1); + redis.opsForValue().set("auth:synthetic", "unchanged", Duration.ofMinutes(2)); + shared.admit("caller", true); + assertCode(() -> shared.admit("caller", true), RestaurantErrorCode.MAP_RATE_LIMITED); + var session = session(Duration.ofMinutes(30)); + var id = shared.save(session); + assertThat(redis.getExpire(SHARED + "admission")).isEqualTo(-1); + assertThat(redis.getExpire(SHARED + "requests")).isEqualTo(-1); + assertThat(redis.getExpire(SHARED + "query:" + id.value())).isBetween(1L, 300L); + assertCode(() -> shared.save(session(Duration.ofMinutes(30))), RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + redis.delete(SHARED + "query:" + id.value()); + assertCode(() -> shared.find(id), RestaurantErrorCode.MAP_SESSION_EXPIRED); + assertCode(() -> shared.touch(id, session), RestaurantErrorCode.MAP_SESSION_EXPIRED); + // 유실된 조회의 예약은 원래 만료 때까지 보수적으로 남겨서 예산을 과소 계산하지 않는다. + assertCode(() -> shared.save(session(Duration.ofMinutes(30))), RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + assertThat(redis.opsForValue().get("auth:synthetic")).isEqualTo("unchanged"); + assertThat(redis.getExpire("auth:synthetic")).isBetween(1L, 120L); + } + + @Test + void 공유모드_회계손상이나_TTL추가는_제한을_초기화하지_않고_503으로_막는다() throws Exception { + var shared = readySharedStore(); + redis.opsForHash().put(SHARED + "admission", "broken", "invalid"); + assertCode(() -> shared.save(session(Duration.ofMinutes(30))), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(redis.opsForHash().get(SHARED + "admission", "broken")).isEqualTo("invalid"); + redis.opsForHash().delete(SHARED + "admission", "broken"); + shared.admit("caller", true); + redis.opsForHash().put(SHARED + "requests", "caller", "invalid"); + assertCode(() -> shared.admit("caller", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + redis.expire(SHARED + "requests", Duration.ofMinutes(1)); + assertCode(() -> shared.admit("other", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(rejections("redis_accounting")).isEqualTo(3); + assertThat(redis.keys(SHARED + "query:*")).isEmpty(); + } + + @Test + void 공유모드도_전역메모리여유분을_확보하지_못하면_지도만_거절한다() throws Exception { + var shared = readySharedStore(); + redis.opsForValue().set("auth:synthetic", "unchanged", Duration.ofMinutes(2)); + limits.setSnapshotBytes(65_536); + limits.setTotalBytes(65_536); + limits.setRedisHeadroom(65_536); + limits.setRedisMemoryCeiling(131_072); + assertCode(() -> shared.admit("caller", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertCode(() -> shared.save(session(Duration.ofMinutes(30))), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(rejections("redis_memory_guard")).isEqualTo(2); + assertThat(redis.opsForValue().get("auth:synthetic")).isEqualTo("unchanged"); + assertThat(redis.keys(SHARED + "query:*")).isEmpty(); + } + + @Test + void 공유모드는_volatile_lru와_유한메모리만_허용한다() throws Exception { + var shared = readySharedStore(); + assertCode(() -> store.admit("dedicated", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + for (String policy : List.of("allkeys-lru", "noeviction")) { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory-policy", policy); + assertCode(() -> shared.admit("caller", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertCode(() -> shared.save(session(Duration.ofMinutes(30))), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory-policy", "volatile-lru"); + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "0"); + assertCode(() -> shared.admit("caller", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } + + @Test + void 실제_volatile_lru_eviction에도_요청한도와_예약키는_사라지지_않는다() throws Exception { + var shared = readySharedStore(); + shared.admit("caller", true); + var session = session(Duration.ofMinutes(30)); + shared.save(session); + long used; + try (var connection = connections.getConnection()) { + used = Long.parseLong(connection.serverCommands().info("memory").getProperty("used_memory")); + } + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", Long.toString(used + 65_536)); + for (int index = 0; index < 100; index++) { + redis.opsForValue().set("pressure:synthetic:" + index, "x".repeat(4096), Duration.ofMinutes(1)); + } + try (var connection = connections.getConnection()) { + assertThat(Long.parseLong(connection.serverCommands().info("stats").getProperty("evicted_keys"))) + .isPositive(); + } + assertThat(redis.getExpire(SHARED + "admission")).isEqualTo(-1); + assertThat(redis.getExpire(SHARED + "requests")).isEqualTo(-1); + assertThat(redis.opsForHash().get(SHARED + "requests", "caller")).isEqualTo("1:1"); + assertThat(redis.opsForHash().size(SHARED + "admission")).isEqualTo(2); + } + + @Test + void 메모리지표는_여유분과_사전경고기준을_공개하고_실패한_관측은_정상으로_표시하지_않는다() throws Exception { + var connection = mock(MapRedisConnection.class); + when(connection.template()).thenReturn(redis); + var sessions = new MapSessionProperties(); + sessions.setEnabled(true); + var registry = new SimpleMeterRegistry(); + var metrics = new MapRedisMetrics(connection, sessions, limits, registry); + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "128mb"); + metrics.refresh(); + assertThat(registry.get("hashi.restaurant.map.redis.probe.success").gauge().value()).isEqualTo(1); + assertThat(registry.get("hashi.restaurant.map.redis.used.bytes").gauge().value()).isPositive(); + assertThat(registry.get("hashi.restaurant.map.redis.admission.limit.bytes").gauge().value()) + .isEqualTo(96 * 1024 * 1024); + assertThat(registry.get("hashi.restaurant.map.redis.warning.limit.bytes").gauge().value()) + .isEqualTo(72 * 1024 * 1024); + when(connection.template()).thenThrow(new IllegalStateException("synthetic-secret")); + metrics.refresh(); + assertThat(registry.get("hashi.restaurant.map.redis.probe.success").gauge().value()).isZero(); + assertThat(registry.get("hashi.restaurant.map.redis.used.bytes").gauge().value()).isNaN(); + } + + private RedisMapSessionStore readySharedStore() throws Exception { + var shared = sharedStore(); + assertCode(() -> shared.admit("warmup", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + completeSharedWarmup(); + METRICS.clear(); + return shared; + } + + private RedisMapSessionStore sharedStore() throws Exception { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "128mb"); + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory-policy", "volatile-lru"); + var connection = mock(MapRedisConnection.class); + when(connection.template()).thenReturn(redis); + when(connection.shared()).thenReturn(true); + return new RedisMapSessionStore(connection, SERIALIZER, limits, CAPACITY_METRICS); + } + + private void completeSharedWarmup() { + // 테스트만 대기 시각을 이동한다. 제품에는 복구 대기를 우회하는 설정/API가 없다. + redis.opsForHash().put(SHARED + "requests", "__readyAt", "0"); + redis.opsForHash().put(SHARED + "admission", "__readyAt", "0"); + } + + @Test + void 앱시각의_조회메타데이터가_앞서도_Redis시각으로_세션을_생성한다() { + var configuration = new MapSessionLimits(); + configuration.setIdleTimeout(Duration.ofSeconds(2)); + configuration.setMaxLifetime(Duration.ofSeconds(10)); + var repository = mock(RestaurantMapService.class); + var reader = mock(RestaurantMapPageReader.class); + var properties = new MapSessionProperties(); + properties.setEnabled(true); + properties.setSigningKey(Base64.getEncoder().encodeToString(new byte[32])); + var sessionStore = storeWith(configuration); + var service = new RestaurantMapPageService(repository, reader, + sessionStore, new MapCursorCodec(properties), configuration, CAPACITY_METRICS); + Instant before = redisNow(); + Instant appRankingTime = before.plusSeconds(20); + var criteria = session(Duration.ofSeconds(10)).criteria(); + when(repository.findCandidates(any(), + anyInt())).thenReturn( + new RestaurantMapService.CandidateSnapshot(List.of(), appRankingTime)); + when(reader.read(any(), any(), + anyInt())).thenReturn( + new RestaurantMapPageReader.Page(List.of(), false, 0)); + var response = service.getPage(new RestaurantMapPageRequest(criteria, + RestaurantMapSort.RECOMMEND, null, null), "synthetic-caller"); + Instant after = redisNow(); + var saved = sessionStore.find(MapSessionId.parse(response.querySessionId())); + assertThat(saved.rankingAsOf()).isEqualTo(appRankingTime); + assertThat(saved.expiresAt()).isBetween(before.plusSeconds(10), after.plusSeconds(10)); + assertThat(response.expiresAt()).isBetween(before.plusSeconds(2), after.plusSeconds(2)); + } + + @Test + void 최종_serializer의_최대후보_bytes와_실제_Redis메모리를_측정하고_Long을_보존한다() throws Exception { + var candidates = IntStream.range(0, 500).mapToObj(index -> new RestaurantMapCandidate( + Long.MAX_VALUE - index, new BigDecimal("5.0"), Long.MAX_VALUE)).toList(); + var criteria = MapSearchCriteria.of(MapQueryBounds.parse("0." + "1".repeat(126), "0." + "9".repeat(126), + "0." + "1".repeat(126), "0." + "9".repeat(126)), Long.MAX_VALUE, + "rice-bowl", "restaurant", "😀".repeat(30)); + Instant rankingAsOf = Instant.now(); + Instant expiresAt = rankingAsOf.plusSeconds(900); + var result = new MapSearchResultExtent(candidates.size(), + new ResultBounds(criteria.bounds().south(), criteria.bounds().north(), + criteria.bounds().west(), criteria.bounds().east()), expiresAt); + var session = new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), criteria, candidates, + result, rankingAsOf, expiresAt); + String json = SERIALIZER.serialize(session); + int bytes = json.getBytes(StandardCharsets.UTF_8).length; + assertThat(bytes).isLessThanOrEqualTo(MapQuerySession.MAX_BYTES); + MapSessionId id = store.save(session); + assertThat(store.find(id)).isEqualTo(session); + assertThat(json).doesNotContain("@class", "latitude", "longitude", "image", "isSaved"); + String memory = REDIS.execInContainer("redis-cli", "MEMORY", "USAGE", PREFIX + id.value()).getStdout().strip(); + System.out.println("MAP_SESSION_MEASUREMENT candidates=500 utf8Bytes=" + bytes + " redisMemoryBytes=" + memory); + assertThat(Long.parseLong(memory)).isLessThan(100_000); + } + + @Test + void 작은조회_160개는_128슬롯에_막히지_않고_설정예산_안에서_보관된다() { + for (int index = 0; index < 160; index++) { + store.save(session(Duration.ofMinutes(30))); + } + assertThat(redis.keys(PREFIX + "*")).hasSize(160); + assertThat(redis.opsForHash().size("hashi:restaurant:map:{sessions-v2}:admission")).isEqualTo(160); + } + + @Test + void 동시_생성은_설정된_예산을_지키며_만료한_예약은_다시_사용한다() throws Exception { + limits.setSessions(2); + limits.setIdleTimeout(Duration.ofSeconds(1)); + try (var executor = Executors.newFixedThreadPool(8)) { + List> calls = IntStream.range(0, 8).mapToObj(index -> (Callable) () -> { + try { + store.save(session(Duration.ofMinutes(30))); + return true; + } catch (BusinessException exception) { + assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + return false; + } + }).toList(); + int accepted = 0; + for (var result : executor.invokeAll(calls)) { + if (result.get()) accepted++; + } + assertThat(accepted).isEqualTo(2); + } + assertThat(rejections("session_count")).isEqualTo(6); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(redis.keys(PREFIX + "*")).isEmpty()); + assertThat(store.find(store.save(session(Duration.ofMinutes(30))))).isNotNull(); + } + + @Test + void 조회만으로_연장하지_않고_성공_touch는_유휴수명만_연장하며_절대수명을_넘지_않는다() throws Exception { + limits.setIdleTimeout(Duration.ofSeconds(2)); + var session = session(Duration.ofMillis(4500)); + var id = store.save(session); + long before = redis.getExpire(PREFIX + id.value(), java.util.concurrent.TimeUnit.MILLISECONDS); + Thread.sleep(250); + assertThat(store.find(id)).isEqualTo(session); + assertThat(redis.getExpire(PREFIX + id.value(), java.util.concurrent.TimeUnit.MILLISECONDS)).isLessThan(before); + Instant expiry = store.touch(id, session); + assertThat(expiry).isAfter(Instant.now().plusMillis(1700)).isBeforeOrEqualTo(session.expiresAt()); + try (var executor = Executors.newFixedThreadPool(8)) { + List> calls = IntStream.range(0, 8) + .mapToObj(index -> (Callable) () -> store.touch(id, session)).toList(); + for (var result : executor.invokeAll(calls)) { + assertThat(result.get()).isAfterOrEqualTo(expiry).isBeforeOrEqualTo(session.expiresAt()); + } + } + Thread.sleep(2000); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(redis.hasKey(PREFIX + id.value())).isFalse()); + assertCode(() -> store.touch(id, session), RestaurantErrorCode.MAP_SESSION_EXPIRED); + assertThat(redis.hasKey(PREFIX + id.value())).isFalse(); + } + + @Test + void 짧은설정의_새인스턴스가_기존ledger수명과_갱신한예약을_줄이지_않는다() throws Exception { + String ledger = "hashi:restaurant:map:{sessions-v2}:admission"; + var longLimits = new MapSessionLimits(); + longLimits.setIdleTimeout(Duration.ofSeconds(4)); + longLimits.setMaxLifetime(Duration.ofSeconds(10)); + var longStore = storeWith(longLimits); + var existing = session(Duration.ofSeconds(10)); + var existingId = longStore.save(existing); + long before = redis.getExpire(ledger, java.util.concurrent.TimeUnit.MILLISECONDS); + + var shortLimits = new MapSessionLimits(); + shortLimits.setIdleTimeout(Duration.ofSeconds(1)); + shortLimits.setMaxLifetime(Duration.ofSeconds(2)); + var shortStore = storeWith(shortLimits); + var shorter = shortStore.save(session(Duration.ofSeconds(2))); + long after = redis.getExpire(ledger, java.util.concurrent.TimeUnit.MILLISECONDS); + // Normal command elapsed time is allowed, but replacing the 11s lifetime with 3s is not. + assertThat(after).isGreaterThan(before - 1000); + + Thread.sleep(2200); + assertThat(redis.hasKey(PREFIX + shorter.value())).isFalse(); + longStore.touch(existingId, existing); + Thread.sleep(1200); // Past the short instance's maxLifetime + 1s ledger deadline. + assertThat(redis.opsForHash().hasKey(ledger, existingId.value())).isTrue(); + assertThat(longStore.find(existingId)).isEqualTo(existing); + assertThat(longStore.touch(existingId, existing)).isAfter(Instant.now()); + } + + @Test + void 기존ledger에_TTL이_없으면_살아있는예약을_보존한다() { + String ledger = "hashi:restaurant:map:{sessions-v2}:admission"; + var existing = session(Duration.ofMinutes(30)); + var existingId = store.save(existing); + redis.persist(ledger); + store.save(session(Duration.ofMinutes(30))); + assertThat(redis.getExpire(ledger)).isEqualTo(-1); + assertThat(redis.opsForHash().hasKey(ledger, existingId.value())).isTrue(); + assertThat(store.touch(existingId, existing)).isAfter(Instant.now()); + } + + @Test + void 호출자_신규조회와_고유호출자수_제한은_DB작업_앞에서_사용할_수_있다() { + limits.setNewQueriesPerCaller(2); + limits.setCallersPerMinute(2); + store.admit("caller-a", true); + store.admit("caller-a", true); + assertCode(() -> store.admit("caller-a", true), RestaurantErrorCode.MAP_RATE_LIMITED); + assertThat(rejections("caller_rate")).isEqualTo(1); + store.admit("caller-a", false); + store.admit("caller-b", true); + assertCode(() -> store.admit("caller-c", true), RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + assertThat(rejections("caller_cardinality")).isEqualTo(1); + assertThat(redis.keys(PREFIX + "*")).isEmpty(); + } + + @Test + void 전역호출한도_거절은_호출자한도와_다른_내부사유로_집계한다() { + limits.setRequestsPerMinute(2); + store.admit("caller-a", false); + store.admit("caller-b", false); + + assertCode(() -> store.admit("caller-c", false), RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + assertThat(rejections("global_requests")).isEqualTo(1); + } + + @Test + void 세션수가_남아도_전체_byte예산을_넘으면_인증키를_보존하고_거절한다() { + limits.setSnapshotBytes(65_536); + limits.setTotalBytes(65_536); + redis.opsForValue().set("auth:synthetic", "unchanged"); + var candidates = IntStream.range(0, 500).mapToObj(index -> new RestaurantMapCandidate( + Long.MAX_VALUE - index, new BigDecimal("5.0"), Long.MAX_VALUE)).toList(); + var base = session(Duration.ofMinutes(30)); + var first = new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), base.criteria(), candidates, + base.searchResult(), base.rankingAsOf(), base.expiresAt()); + store.save(first); + var second = new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), base.criteria(), candidates, + base.searchResult(), base.rankingAsOf(), base.expiresAt()); + assertCode(() -> store.save(second), RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + assertThat(rejections("total_bytes")).isEqualTo(1); + assertThat(redis.keys(PREFIX + "*")).hasSize(1); + assertThat(redis.opsForValue().get("auth:synthetic")).isEqualTo("unchanged"); + } + + @Test + void 공유Redis의_eviction설정은_변경하지_않고_지도입장만_거절한다() throws Exception { + redis.opsForValue().set("auth:synthetic", "unchanged"); + try { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory-policy", "allkeys-lru"); + assertCode(() -> store.admit("caller", true), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertCode(() -> store.save(session(Duration.ofMinutes(30))), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(rejections("redis_memory_guard")).isEqualTo(2); + assertThat(redis.opsForValue().get("auth:synthetic")).isEqualTo("unchanged"); + } finally { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory-policy", "noeviction"); + } + } + + @Test + void bytes초과와_구버전_손상_유실을_분리하고_인증키를_변경하지_않는다() { + redis.opsForValue().set("auth:synthetic", "unchanged", Duration.ofSeconds(90)); + var id = store.save(session(Duration.ofMinutes(15))); + redis.opsForValue().set(PREFIX + id.value(), "{\"schemaVersion\":999}"); + assertCode(() -> store.find(id), RestaurantErrorCode.MAP_SESSION_EXPIRED); + redis.delete(PREFIX + id.value()); + assertCode(() -> store.find(id), RestaurantErrorCode.MAP_SESSION_EXPIRED); + var criteria = MapSearchCriteria.of(new MapQueryBounds(new BigDecimal("0." + "1".repeat(70_000)), + BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ONE), null, null, null, null); + limits.setSnapshotBytes(65_536); + var oversized = new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), criteria, List.of(), + null, Instant.now(), Instant.now().plusSeconds(900)); + assertCode(() -> store.save(oversized), RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + assertThat(rejections("snapshot_bytes")).isEqualTo(1); + assertThat(redis.keys(PREFIX + "*")).isEmpty(); + assertThat(redis.opsForValue().get("auth:synthetic")).isEqualTo("unchanged"); + assertThat(redis.getExpire("auth:synthetic")).isBetween(1L, 90L); + } + + @Test + void 실제쓰기거절은_503이고_부분키를_남기지_않는다() throws Exception { + try { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "1"); + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory-policy", "noeviction"); + assertCode(() -> store.save(session(Duration.ofMinutes(15))), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + } finally { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "0"); + } + assertThat(redis.keys(PREFIX + "*")).isEmpty(); + } + + @Test + void 무응답_Redis는_기존_명령timeout_안에_503으로_끝난다() { + var id = store.save(session(Duration.ofMinutes(15))); + REDIS.getDockerClient().pauseContainerCmd(REDIS.getContainerId()).exec(); + long start = System.nanoTime(); + try { + assertCode(() -> store.find(id), RestaurantErrorCode.MAP_SESSION_UNAVAILABLE); + assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofSeconds(10)); + } finally { + REDIS.getDockerClient().unpauseContainerCmd(REDIS.getContainerId()).exec(); + } + } + + private RedisMapSessionStore storeWith(MapSessionLimits configuration) { + var connection = mock(MapRedisConnection.class); + when(connection.template()).thenReturn(redis); + return new RedisMapSessionStore(connection, SERIALIZER, configuration, + CAPACITY_METRICS); + } + + private MapQuerySession session(Duration ttl) { + Instant now = redisNow(); + return new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, null), List.of(), null, + now, now.plus(ttl)); + } + + private Instant redisNow() { + var timeScript = new DefaultRedisScript( + "local t=redis.call('TIME'); return tonumber(t[1])*1000+math.floor(tonumber(t[2])/1000)", Long.class); + return Instant.ofEpochMilli(redis.execute(timeScript, List.of())); + } + + private void assertCode(Runnable action, RestaurantErrorCode code) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(code)); + } + + private double rejections(String reason) { + return METRICS.get(MapCapacityMetrics.REJECTION_METRIC).tag("reason", reason).counter().count(); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GeocodingFixtures.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GeocodingFixtures.java new file mode 100644 index 00000000..87023532 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GeocodingFixtures.java @@ -0,0 +1,18 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +final class GeocodingFixtures { + + static final String API_KEY = "synthetic-server-key"; + static final String ADDRESS = "東京都 テスト + & # / % ?= 店"; + static final String CANDIDATE = """ + {"location":{"latitude":35.12345678901234567,"longitude":139.76543210987654321}, + "granularity":"ROOFTOP", "postalAddress":{"regionCode":"JP","administrativeArea":"東京都"}, + "addressComponents":[{"longText":"架空住所","shortText":"架空","types":["route"]}, + {"longText":"日本","shortText":"JP","types":["country","political"]}], + "types":["street_address"], "formattedAddress":"must-not-be-retained"} + """; + static final String SUCCESS = "{\"results\":[" + CANDIDATE + "]}"; + + private GeocodingFixtures() { + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingConfigTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingConfigTest.java new file mode 100644 index 00000000..0bc71bcc --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingConfigTest.java @@ -0,0 +1,103 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Duration; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import org.slf4j.LoggerFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.internal.map.GeocodingProvider; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; + +@ExtendWith(OutputCaptureExtension.class) +class GoogleGeocodingConfigTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(GoogleGeocodingConfig.class); + + @Test + void 기본_비활성에서는_키없이_부팅하고_호출을_거부한다() { + runner.run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(GeocodingProvider.class); + assertThat(context.getBean(GeocodingProvider.class).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.DISABLED, null)); + }); + } + + @Test + void 활성_설정은_실제_HTTP_호출없이_adapter를_생성한다() { + enabled().run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(GeocodingProvider.class)).isInstanceOf(GoogleGeocodingProvider.class); + }); + } + + @Test + void 활성인데_키가_없으면_부팅_중에_실패한다() { + runner.withPropertyValues("hashi.map.google-geocoding.enabled=true").run(context -> + assertThat(context.getStartupFailure()) + .hasRootCauseMessage("Google geocoding requires a valid server API key")); + } + + @Test + void 잘못된_키를_설정_문자열과_부팅_예외_원인과_로그에_노출하지_않는다(CapturedOutput output) { + String secret = "synthetic private key!"; + enabled().withPropertyValues("hashi.map.google-geocoding.api-key=" + secret).run(context -> { + assertThat(context).hasFailed(); + StringWriter trace = new StringWriter(); + context.getStartupFailure().printStackTrace(new PrintWriter(trace)); + assertThat(trace.toString()).doesNotContain(secret); + }); + assertThat(new GoogleGeocodingProperties(true, secret, null, null, null).toString()).doesNotContain(secret); + assertThat(output.getAll()).doesNotContain(secret); + } + + @ParameterizedTest + @ValueSource(strings = {"connect-timeout=0ms", "connect-timeout=-1ms", "connect-timeout=11s", + "response-timeout=0ms", "response-timeout=31s", "response-timeout=1s", + "max-response-bytes=0", "max-response-bytes=1023", "max-response-bytes=1048577"}) + void 활성_설정의_무제한_또는_과도한_제한값을_거부한다(String property) { + enabled().withPropertyValues("hashi.map.google-geocoding." + property) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void 양의_밀리초_최솟값과_허용_최댓값을_검증한다() { + new GoogleGeocodingProperties(true, GeocodingFixtures.API_KEY, Duration.ofMillis(1), + Duration.ofMillis(1), 1024).validateEnabled(); + new GoogleGeocodingProperties(true, GeocodingFixtures.API_KEY, Duration.ofSeconds(10), + Duration.ofSeconds(30), 1_048_576).validateEnabled(); + assertThatThrownBy(() -> new GoogleGeocodingProperties(true, GeocodingFixtures.API_KEY, + Duration.ofNanos(1), Duration.ofSeconds(1), 1024).validateEnabled()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void HTTP_wire_logging_활성_환경에서는_외부_호출을_허용하지_않는다() { + Logger logger = (Logger) LoggerFactory.getLogger("org.apache.hc.client5.http.wire"); + Level previous = logger.getLevel(); + try { + logger.setLevel(Level.DEBUG); + enabled().run(context -> assertThat(context.getStartupFailure()) + .hasRootCauseMessage("Google geocoding requires HTTP wire logging to be disabled")); + } finally { + logger.setLevel(previous); + } + } + + private ApplicationContextRunner enabled() { + return runner.withPropertyValues("hashi.map.google-geocoding.enabled=true", + "hashi.map.google-geocoding.api-key=" + GeocodingFixtures.API_KEY); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingDeadlineTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingDeadlineTest.java new file mode 100644 index 00000000..93becbc8 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingDeadlineTest.java @@ -0,0 +1,167 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.hc.client5.http.DnsResolver; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.NoResults; + +class GoogleGeocodingDeadlineTest { + + private static final int AWAIT_SECONDS = 15; + + private HttpServer server; + private ExecutorService callers; + private final AtomicInteger requests = new AtomicInteger(); + + @BeforeEach + void setUp() throws IOException { + callers = Executors.newVirtualThreadPerTaskExecutor(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setExecutor(callers); + server.createContext("/v4/geocode/address", exchange -> { + requests.incrementAndGet(); + try (exchange) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 2); + exchange.getResponseBody().write(new byte[]{'{', '}'}); + } + }); + server.start(); + } + + @AfterEach + void tearDown() { + server.stop(0); + callers.shutdownNow(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void DNS가_취소를_무시해도_호출자는_반환하고_늦은_HTTP를_전송하지_않는다(boolean interruptCaller) + throws Exception { + DelayedResolver resolver = new DelayedResolver(1); + GoogleGeocodingProvider provider = provider(resolver); + AtomicReference caller = new AtomicReference<>(); + AtomicBoolean interruptedAfterReturn = new AtomicBoolean(); + Future result = callers.submit(() -> { + caller.set(Thread.currentThread()); + GeocodingResult outcome = provider.geocode(GeocodingFixtures.ADDRESS); + interruptedAfterReturn.set(Thread.currentThread().isInterrupted()); + return outcome; + }); + try { + assertThat(resolver.entered.await(AWAIT_SECONDS, TimeUnit.SECONDS)).isTrue(); + if (interruptCaller) { + caller.get().interrupt(); + } + FailureKind expected = interruptCaller ? FailureKind.CANCELLED : FailureKind.TIMEOUT; + assertThat(result.get(AWAIT_SECONDS, TimeUnit.SECONDS)).isEqualTo(new Failure(expected, null)); + assertThat(interruptedAfterReturn.get()).isEqualTo(interruptCaller); + assertThat(resolver.threads).hasSize(1).allMatch(Thread::isAlive); + assertThat(requests).hasValue(0); + } finally { + resolver.release.countDown(); + } + await().atMost(Duration.ofSeconds(AWAIT_SECONDS)).until(() -> resolver.threads.stream().noneMatch(Thread::isAlive)); + assertThat(requests).hasValue(0); + } + + @Test + void 끝나지_않은_DNS가_있어도_작업과_대기열이_계속_늘지_않는다() throws Exception { + int maximum = GoogleGeocodingProvider.MAX_CONCURRENT_CALLS; + DelayedResolver resolver = new DelayedResolver(maximum); + GoogleGeocodingProvider provider = provider(resolver); + ArrayList> results = new ArrayList<>(); + for (int i = 0; i < maximum; i++) { + results.add(callers.submit(() -> provider.geocode(GeocodingFixtures.ADDRESS))); + } + try { + assertThat(resolver.entered.await(AWAIT_SECONDS, TimeUnit.SECONDS)).isTrue(); + for (Future result : results) { + assertThat(result.get(AWAIT_SECONDS, TimeUnit.SECONDS)) + .isEqualTo(new Failure(FailureKind.TIMEOUT, null)); + } + for (int i = 0; i < 20; i++) { + assertThat(provider.geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.CAPACITY_EXCEEDED, null)); + } + assertThat(resolver.calls).hasValue(maximum); + assertThat(resolver.threads).hasSize(maximum).allMatch(Thread::isAlive); + assertThat(requests).hasValue(0); + } finally { + resolver.release.countDown(); + } + await().atMost(Duration.ofSeconds(AWAIT_SECONDS)).until(() -> resolver.threads.stream().noneMatch(Thread::isAlive)); + assertThat(requests).hasValue(0); + assertThat(provider.geocode(GeocodingFixtures.ADDRESS)).isEqualTo(new NoResults()); + assertThat(requests).hasValue(1); + } + + private GoogleGeocodingProvider provider(DnsResolver resolver) { + GoogleGeocodingProperties properties = new GoogleGeocodingProperties(true, GeocodingFixtures.API_KEY, + Duration.ofMillis(100), Duration.ofSeconds(10), 1024); + return new GoogleGeocodingProvider(properties, factory -> (uri, method) -> factory.createRequest( + URI.create("http://synthetic.invalid:" + server.getAddress().getPort() + + uri.getRawPath() + "?" + uri.getRawQuery()), method), resolver); + } + + private static final class DelayedResolver implements DnsResolver { + private final CountDownLatch entered; + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicInteger calls = new AtomicInteger(); + private final Set threads = ConcurrentHashMap.newKeySet(); + + private DelayedResolver(int expectedCalls) { + entered = new CountDownLatch(expectedCalls); + } + + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + calls.incrementAndGet(); + threads.add(Thread.currentThread()); + entered.countDown(); + boolean released = false; + while (!released) { + try { + release.await(); + released = true; + } catch (InterruptedException ignored) { + // Model a platform DNS lookup that cannot be interrupted. + } + } + return new InetAddress[]{InetAddress.getByAddress(new byte[]{127, 0, 0, 1})}; + } + + @Override + public String resolveCanonicalHostname(String host) { + return host; + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProviderTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProviderTest.java new file mode 100644 index 00000000..9ef0f524 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingProviderTest.java @@ -0,0 +1,214 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +@ExtendWith(OutputCaptureExtension.class) +class GoogleGeocodingProviderTest { + + private final AtomicInteger attempts = new AtomicInteger(); + + @ParameterizedTest + @CsvSource({"400,INVALID_REQUEST", "401,ACCESS_DENIED", "403,ACCESS_DENIED", "404,CONFIGURATION_ERROR", + "408,TIMEOUT", "429,QUOTA_EXCEEDED", "500,TRANSIENT_ERROR", "502,TRANSIENT_ERROR", + "503,TRANSIENT_ERROR", "504,TRANSIENT_ERROR", "204,INVALID_RESPONSE", "201,INVALID_RESPONSE", + "302,REDIRECT_REJECTED", "418,INVALID_RESPONSE"}) + void HTTP_오류는_원문을_읽거나_재시도하지_않고_분류한다(int status, FailureKind expected) { + MockClientHttpResponse response = new MockClientHttpResponse(new ByteArrayInputStream(new byte[0]) { + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + throw new AssertionError("Error body must not be read"); + } + }, HttpStatusCode.valueOf(status)); + assertThat(provider(response).geocode(GeocodingFixtures.ADDRESS)).isEqualTo(new Failure(expected, status)); + assertThat(attempts).hasValue(1); + } + + @ParameterizedTest + @CsvSource({"RESOURCE_EXHAUSTED,QUOTA_EXCEEDED", "OVER_QUERY_LIMIT,ACCESS_DENIED", + "rateLimitExceeded,ACCESS_DENIED", "userRateLimitExceeded,ACCESS_DENIED", + "PERMISSION_DENIED,ACCESS_DENIED", "UNKNOWN,ACCESS_DENIED", + "OVER_DAILY_LIMIT,ACCESS_DENIED", "dailyLimitExceeded,ACCESS_DENIED"}) + void HTTP_403의_제한된_상태값으로_quota와_권한오류를_구분하고_원문은_버린다( + String rpcStatus, FailureKind expected, CapturedOutput output) { + String body = "{\"error\":{\"code\":403,\"message\":\"" + GeocodingFixtures.API_KEY + + "\",\"status\":\"" + rpcStatus + "\",\"details\":[{\"private\":\"" + + GeocodingFixtures.ADDRESS + "\"}]}}"; + assertThat(provider(json(body, 403)).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(expected, 403)); + assertThat(output.getAll()).doesNotContain(GeocodingFixtures.API_KEY, GeocodingFixtures.ADDRESS); + assertThat(attempts).hasValue(1); + } + + @Test + void HTTP_403의_잘못된_JSON이나_초과한_오류본문은_권한오류로_안전하게_되돌린다() { + assertThat(provider(json("{\"error\":", 403)).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.ACCESS_DENIED, 403)); + assertThat(provider(json("{\"error\":{\"message\":\"" + "x".repeat(8192) + + "\",\"status\":\"RESOURCE_EXHAUSTED\"}}", 403)).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.ACCESS_DENIED, 403)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + void 빈_입력은_HTTP_이전에_거부한다(String address) { + assertThat(provider(json("{}", 200)).geocode(address)) + .isEqualTo(new Failure(FailureKind.INVALID_REQUEST, null)); + assertThat(attempts).hasValue(0); + } + + @Test + void 기존_주소_길이_한도를_넘는_입력은_HTTP_이전에_거부한다() { + assertThat(provider(json("{}", 200)).geocode("a".repeat(256))) + .isEqualTo(new Failure(FailureKind.INVALID_REQUEST, null)); + assertThat(attempts).hasValue(0); + } + + @Test + void 연결_예외의_URL_키_주소와_cause를_결과나_로그에_노출하지_않는다(CapturedOutput output) { + String privateDetails = GeocodingFixtures.API_KEY + GeocodingFixtures.ADDRESS + "https://private.invalid/full"; + GoogleGeocodingProvider provider = new GoogleGeocodingProvider(properties(), ignored -> (uri, method) -> { + attempts.incrementAndGet(); + throw new IOException(privateDetails, new IllegalArgumentException(privateDetails)); + }); + assertThat(provider.geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.CONNECTION_ERROR, null)); + assertThat(attempts).hasValue(1); + assertThat(output.getAll()) + .doesNotContain(privateDetails, GeocodingFixtures.API_KEY, GeocodingFixtures.ADDRESS); + } + + @Test + void timeout_예외도_원인을_보존하지_않고_분류한다() { + GoogleGeocodingProvider provider = new GoogleGeocodingProvider(properties(), ignored -> (uri, method) -> { + attempts.incrementAndGet(); + throw new SocketTimeoutException(GeocodingFixtures.API_KEY); + }); + assertThat(provider.geocode(GeocodingFixtures.ADDRESS)).isEqualTo(new Failure(FailureKind.TIMEOUT, null)); + assertThat(attempts).hasValue(1); + } + + @ParameterizedTest + @CsvSource({"false,execute,java.lang.IllegalStateException", "true,await,java.lang.AssertionError"}) + void 예상하지_못한_실패는_분류를_유지하며_WARN에_단계와_클래스명만_남긴다( + boolean isError, String phase, String exceptionType, CapturedOutput output) { + String privateUrl = "https://private.invalid/geocode?address=" + GeocodingFixtures.ADDRESS; + String privateMessage = "synthetic-private-message " + GeocodingFixtures.API_KEY + privateUrl; + String privateCause = "synthetic-private-cause " + GeocodingFixtures.API_KEY; + GoogleGeocodingProvider provider = new GoogleGeocodingProvider(properties(), ignored -> (uri, method) -> { + attempts.incrementAndGet(); + IllegalArgumentException cause = new IllegalArgumentException(privateCause); + if (isError) { + throw new AssertionError(privateMessage, cause); + } + throw new IllegalStateException(privateMessage, cause); + }); + Logger logger = (Logger) LoggerFactory.getLogger(GoogleGeocodingProvider.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + Failure expected = new Failure(FailureKind.INVALID_RESPONSE, null); + assertThat(provider.geocode(GeocodingFixtures.ADDRESS)).isEqualTo(expected); + assertThat(attempts).hasValue(1); + assertThat(appender.list).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).isEqualTo( + "Google geocoding failure phase=" + phase + " exceptionType=" + exceptionType); + assertThat(event.getArgumentArray()).containsExactly(exceptionType); + assertThat(event.getThrowableProxy()).isNull(); + }); + assertThat(output.getAll()).doesNotContain(privateUrl, privateMessage, privateCause, + GeocodingFixtures.API_KEY, GeocodingFixtures.ADDRESS, + "java.lang.IllegalArgumentException", "Caused by:"); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + + @Test + void 비_JSON과_압축된_응답을_거부한다() { + MockClientHttpResponse html = json("private response", 200); + html.getHeaders().setContentType(MediaType.TEXT_HTML); + assertThat(provider(html).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.INVALID_RESPONSE, 200)); + MockClientHttpResponse compressed = json("{}", 200); + compressed.getHeaders().set("Content-Encoding", "gzip"); + assertThat(provider(compressed).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.INVALID_RESPONSE, 200)); + } + + @Test + void 성공_실패_원문을_로그에_노출하지_않는다(CapturedOutput output) { + provider(json(GeocodingFixtures.SUCCESS, 200)).geocode(GeocodingFixtures.ADDRESS); + provider(json("{\"private\":\"" + GeocodingFixtures.API_KEY, 200)).geocode(GeocodingFixtures.ADDRESS); + assertThat(output.getAll()).doesNotContain(GeocodingFixtures.API_KEY, GeocodingFixtures.ADDRESS, + "35.12345678901234567", "架空住所", "must-not-be-retained"); + } + + @Test + void 주소로_endpoint를_바꾸거나_키를_query에_넣을_수_없다() { + String hostile = "https://attacker.invalid/x?key=bad#fragment®ionCode=US"; + GoogleGeocodingProvider provider = new GoogleGeocodingProvider(properties(), ignored -> (uri, method) -> { + assertThat(uri.getScheme()).isEqualTo("https"); + assertThat(uri.getHost()).isEqualTo("geocode.googleapis.com"); + assertThat(uri.getPath()).isEqualTo("/v4/geocode/address"); + assertThat(uri.getRawFragment()).isNull(); + assertThat(uri.getRawQuery()).doesNotContain("key=", GeocodingFixtures.API_KEY); + assertThat(uri.getRawQuery().split("&")).hasSize(4); + return request(uri, json("{}", 200)); + }); + provider.geocode(hostile); + assertThat(attempts).hasValue(1); + } + + private GoogleGeocodingProvider provider(MockClientHttpResponse response) { + return new GoogleGeocodingProvider(properties(), ignored -> (uri, method) -> request(uri, response)); + } + + private MockClientHttpRequest request(URI uri, MockClientHttpResponse response) { + attempts.incrementAndGet(); + MockClientHttpRequest request = new MockClientHttpRequest(); + request.setURI(uri); + request.setResponse(response); + return request; + } + + private MockClientHttpResponse json(String body, int status) { + MockClientHttpResponse response = new MockClientHttpResponse(body.getBytes(StandardCharsets.UTF_8), + HttpStatusCode.valueOf(status)); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + return response; + } + + private GoogleGeocodingProperties properties() { + return new GoogleGeocodingProperties(true, GeocodingFixtures.API_KEY, null, null, null); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingResponseParserTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingResponseParserTest.java new file mode 100644 index 00000000..1f56dcbe --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingResponseParserTest.java @@ -0,0 +1,133 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.Granularity; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Candidates; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.NoResults; + +class GoogleGeocodingResponseParserTest { + + private final GoogleGeocodingResponseParser parser = new GoogleGeocodingResponseParser(); + + @Test + void 좌표_정밀도와_채택에_필요한_메타데이터를_보존한다() { + Candidates result = (Candidates) parse(GeocodingFixtures.SUCCESS); + var candidate = result.candidates().getFirst(); + assertThat(candidate.latitude()).isEqualByComparingTo("35.12345678901234567"); + assertThat(candidate.longitude()).isEqualByComparingTo("139.76543210987654321"); + assertThat(candidate.granularity()).isEqualTo(Granularity.ROOFTOP); + assertThat(candidate.countryCode()).isEqualTo("JP"); + assertThat(candidate.administrativeArea()).isEqualTo("東京都"); + assertThat(candidate.addressComponents().getFirst().longText()).isEqualTo("架空住所"); + assertThat(candidate.addressComponents().getFirst().types()).containsExactly("route"); + assertThat(candidate.types()).containsExactly("street_address"); + assertThatThrownBy(() -> result.candidates().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> candidate.addressComponents().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void 복수_후보와_다른_국가를_그대로_반환하고_첫_항목을_채택하지_않는다() { + String foreign = GeocodingFixtures.CANDIDATE.replace("JP", "US").replace("ROOFTOP", "APPROXIMATE"); + Candidates result = (Candidates) parse("{\"results\":[" + GeocodingFixtures.CANDIDATE + "," + foreign + "]}"); + assertThat(result.candidates()).hasSize(2); + assertThat(result.candidates().getLast().countryCode()).isEqualTo("US"); + assertThat(result.candidates().getLast().granularity()).isEqualTo(Granularity.APPROXIMATE); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "{\"results\":[]}", "{\"futureMetadata\":true}"}) + void 생략된_repeated_results와_명시적_빈_배열은_0건이다(String body) { + assertThat(parse(body)).isEqualTo(new NoResults()); + } + + @ParameterizedTest + @CsvSource({"0,0", "90,180", "-90,-180", "0,-180"}) + void 영점과_전세계_좌표_경계는_유효하다(String latitude, String longitude) { + Candidates result = (Candidates) parse(withLocation(latitude, longitude)); + assertThat(result.candidates().getFirst().latitude()).isEqualByComparingTo(latitude); + assertThat(result.candidates().getFirst().longitude()).isEqualByComparingTo(longitude); + } + + @ParameterizedTest + @ValueSource(strings = {"ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER", "APPROXIMATE"}) + void v4_정확도_종류를_보존한다(String granularity) { + Candidates result = (Candidates) parse(GeocodingFixtures.SUCCESS.replace("ROOFTOP", granularity)); + assertThat(result.candidates().getFirst().granularity()).isEqualTo(Granularity.valueOf(granularity)); + } + + @Test + void 누락된_메타데이터나_새_정확도는_기본_정확도로_승격하지_않는다() { + Candidates missing = (Candidates) parse(withLocation("1", "2")); + assertThat(missing.candidates().getFirst().granularity()).isEqualTo(Granularity.UNKNOWN); + assertThat(missing.candidates().getFirst().countryCode()).isEmpty(); + assertThat(missing.candidates().getFirst().addressComponents()).isEmpty(); + Candidates unknown = (Candidates) parse(GeocodingFixtures.SUCCESS.replace("ROOFTOP", "FUTURE_VALUE")); + assertThat(unknown.candidates().getFirst().granularity()).isEqualTo(Granularity.UNKNOWN); + } + + @ParameterizedTest + @MethodSource("invalidBodies") + void 잘못된_JSON과_좌표_누락을_0건이나_영점으로_변환하지_않는다(String body) { + assertThat(parse(body)).isEqualTo(new Failure(FailureKind.INVALID_RESPONSE, 200)); + } + + @ParameterizedTest + @CsvSource({"RESOURCE_EXHAUSTED,QUOTA_EXCEEDED", "PERMISSION_DENIED,ACCESS_DENIED", + "UNKNOWN,ACCESS_DENIED", "OVER_QUERY_LIMIT,ACCESS_DENIED", "rateLimitExceeded,ACCESS_DENIED", + "userRateLimitExceeded,ACCESS_DENIED", "OVER_DAILY_LIMIT,ACCESS_DENIED", + "dailyLimitExceeded,ACCESS_DENIED"}) + void HTTP_403에서_canonical_quota만_구분하고_legacy와_알_수_없는_상태는_권한오류다( + String status, FailureKind expected) { + byte[] body = ("{\"error\":{\"status\":\"" + status + "\"}}").getBytes(StandardCharsets.UTF_8); + assertThat(parser.classifyForbidden(body)).isEqualTo(expected); + } + + static Stream invalidBodies() { + return Stream.of("", " ", "null", "[]", "true", "1", "\"text\"", "{", "{}{}", "{}garbage", + "{\"results\":null}", "{\"results\":{}}", "{\"results\":[null]}", "{\"results\":[{}]}", + "{\"results\":[],\"results\":[]}", "{\"error\":{\"message\":\"sensitive\"}}", + "{\"status\":\"ZERO_RESULTS\"}", "{\"error_message\":\"sensitive\"}", + withLocation("90.000001", "0"), withLocation("0", "-180.000001"), + withLocation("NaN", "0"), withLocation("\"NaN\"", "0"), withLocation("\"Infinity\"", "0"), + withLocation("1e9999", "0"), withLocation("\"35\"", "0"), withLocation("null", "0"), + withLocation("true", "0"), withLocation("{}", "0"), + "{\"results\":[{\"location\":{\"latitude\":0}}]}", + "{\"results\":[{\"location\":{\"longitude\":0}}]}", + GeocodingFixtures.SUCCESS.replace("\"ROOFTOP\"", "7"), + GeocodingFixtures.SUCCESS.replace("\"regionCode\":\"JP\"", "\"regionCode\":null"), + GeocodingFixtures.SUCCESS.replace("[\"street_address\"]", "[null]"), + GeocodingFixtures.SUCCESS.replace("[\"street_address\"]", "{}"), + "{\"results\":[" + GeocodingFixtures.CANDIDATE + ",{}]}", + "{\"nested\":" + "[".repeat(25) + "0" + "]".repeat(25) + "}", + "{\"text\":\"" + "a".repeat(10_000) + "\"}"); + } + + @Test + void 후보와_주소_구성요소의_toString은_원문과_좌표를_노출하지_않는다() { + Candidates result = (Candidates) parse(GeocodingFixtures.SUCCESS); + assertThat(result.toString()).isEqualTo("GeocodingCandidates[redacted]"); + assertThat(result.candidates().getFirst().toString()).isEqualTo("GeocodingCandidate[redacted]"); + assertThat(result.candidates().getFirst().addressComponents().toString()).doesNotContain("架空", "日本"); + } + + private GeocodingResult parse(String body) { + return parser.parse(body.getBytes(StandardCharsets.UTF_8)); + } + + private static String withLocation(String latitude, String longitude) { + return "{\"results\":[{\"location\":{\"latitude\":" + latitude + ",\"longitude\":" + longitude + "}}]}"; + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingWireTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingWireTest.java new file mode 100644 index 00000000..3dfaae58 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/GoogleGeocodingWireTest.java @@ -0,0 +1,258 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Candidates; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; + +class GoogleGeocodingWireTest { + + private HttpServer server; + private ExecutorService executor; + private final AtomicInteger requests = new AtomicInteger(); + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + executor = Executors.newVirtualThreadPerTaskExecutor(); + server.setExecutor(executor); + server.start(); + } + + @AfterEach + void tearDown() { + server.stop(0); + executor.shutdownNow(); + } + + @Test + void 일본어와_예약문자를_보존하고_고정_헤더로_요청한다() { + AtomicReference query = new AtomicReference<>(); + AtomicReference key = new AtomicReference<>(); + AtomicReference mask = new AtomicReference<>(); + AtomicReference method = new AtomicReference<>(); + server.createContext("/v4/geocode/address", exchange -> { + requests.incrementAndGet(); + query.set(exchange.getRequestURI().getRawQuery()); + key.set(exchange.getRequestHeaders().getFirst("X-Goog-Api-Key")); + mask.set(exchange.getRequestHeaders().getFirst("X-Goog-FieldMask")); + method.set(exchange.getRequestMethod()); + respond(exchange, 200, GeocodingFixtures.SUCCESS, false); + }); + + assertThat(provider(2000).geocode(GeocodingFixtures.ADDRESS)).isInstanceOf(Candidates.class); + assertThat(requests).hasValue(1); + assertThat(method.get()).isEqualTo("GET"); + assertThat(key.get()).isEqualTo(GeocodingFixtures.API_KEY); + assertThat(mask.get()).isEqualTo(GoogleGeocodingProvider.FIELD_MASK).doesNotContain("*", "formattedAddress"); + String[] params = query.get().split("&"); + assertThat(params).hasSize(4); + assertThat(URLDecoder.decode(params[0].substring("address.addressLines=".length()), StandardCharsets.UTF_8)) + .isEqualTo(GeocodingFixtures.ADDRESS); + assertThat(params).contains("address.regionCode=JP", "languageCode=en", "regionCode=JP"); + assertThat(query.get()).contains("%2B", "%26", "%23", "%2F", "%25"); + assertThat(query.get()).doesNotContain(GeocodingFixtures.API_KEY); + } + + @ParameterizedTest + @ValueSource(ints = {301, 302, 303, 307, 308}) + void redirect를_따라가지_않아_다음_주소로_키를_전달하지_않는다(int status) { + AtomicInteger redirected = new AtomicInteger(); + server.createContext("/v4/geocode/address", exchange -> { + requests.incrementAndGet(); + exchange.getResponseHeaders().set("Location", + "http://127.0.0.1:" + server.getAddress().getPort() + "/leak"); + respond(exchange, status, "sensitive error", false); + }); + server.createContext("/leak", exchange -> { + redirected.incrementAndGet(); + respond(exchange, 200, "{}", false); + }); + assertThat(provider(2000).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.REDIRECT_REJECTED, status)); + assertThat(requests).hasValue(1); + assertThat(redirected).hasValue(0); + } + + @ParameterizedTest + @ValueSource(ints = {429, 503}) + void quota와_일시_실패는_실제_HTTP에서도_재시도하지_않는다(int status) { + server.createContext("/v4/geocode/address", exchange -> { + requests.incrementAndGet(); + respond(exchange, status, "{\"error\":{\"message\":\"private\"}}", false); + }); + assertThat(provider(2000).geocode(GeocodingFixtures.ADDRESS)).isInstanceOf(Failure.class); + assertThat(requests).hasValue(1); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void ContentLength와_chunked_모두_응답_크기를_제한한다(boolean chunked) { + server.createContext("/v4/geocode/address", exchange -> { + requests.incrementAndGet(); + respond(exchange, 200, " ".repeat(2048), chunked); + }); + assertThat(provider(2000).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.RESPONSE_TOO_LARGE, 200)); + assertThat(requests).hasValue(1); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 헤더와_본문_지연을_모두_유한한_timeout으로_중단한다(boolean bodyDelay) { + server.createContext("/v4/geocode/address", exchange -> { + requests.incrementAndGet(); + try (exchange) { + if (bodyDelay) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + for (int i = 0; i < 20; i++) { + exchange.getResponseBody().write(' '); + exchange.getResponseBody().flush(); + Thread.sleep(50); + } + } else { + Thread.sleep(1000); + respond(exchange, 200, "{}", false); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { + // The client closes the stream at the deadline. + } + }); + long started = System.nanoTime(); + assertThat(provider(200).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.TIMEOUT, null)); + assertThat(Duration.ofNanos(System.nanoTime() - started)).isLessThan(Duration.ofSeconds(2)); + assertThat(requests).hasValue(1); + } + + @Test + void 요청을_받은_뒤_응답없이_끊어도_HTTP를_재전송하지_않는다() throws Exception { + try (ServerSocket socketServer = new ServerSocket(0, 10, java.net.InetAddress.getByName("127.0.0.1"))) { + // Allow first-use TLS/client initialization before the first accept, then observe possible replays. + socketServer.setSoTimeout(5000); + var received = executor.submit(() -> { + int count = 0; + while (true) { + try (Socket socket = socketServer.accept()) { + socket.setSoTimeout(700); + BufferedReader reader = new BufferedReader(new InputStreamReader( + socket.getInputStream(), StandardCharsets.US_ASCII)); + String line; + while ((line = reader.readLine()) != null && !line.isEmpty()) { + // Consume just the request headers and then close without a response. + } + count++; + socketServer.setSoTimeout(700); + } catch (SocketTimeoutException done) { + return count; + } + } + }); + GoogleGeocodingProvider provider = localProvider(socketServer.getLocalPort(), 2000); + var result = provider.geocode(GeocodingFixtures.ADDRESS); + assertThat(received.get(6, TimeUnit.SECONDS)).isEqualTo(1); + assertThat(result).isEqualTo(new Failure(FailureKind.CONNECTION_ERROR, null)); + } + } + + @Test + void 인증_challenge에도_두번째_요청을_보내지_않는다() { + server.createContext("/v4/geocode/address", exchange -> { + requests.incrementAndGet(); + exchange.getResponseHeaders().set("WWW-Authenticate", "Basic realm=synthetic"); + respond(exchange, 401, "{}", false); + }); + assertThat(provider(2000).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.ACCESS_DENIED, 401)); + assertThat(requests).hasValue(1); + } + + @ParameterizedTest + @ValueSource(ints = {200, 503}) + void 초과_또는_오류_본문은_cleanup에서도_끝까지_읽지_않는다(int status) { + CountDownLatch finishBody = new CountDownLatch(1); + server.createContext("/v4/geocode/address", exchange -> { + try (exchange) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, 0); + exchange.getResponseBody().write(new byte[1025]); + exchange.getResponseBody().flush(); + finishBody.await(5, TimeUnit.SECONDS); + exchange.getResponseBody().write(' '); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { + // Expected: cleanup aborts the connection without draining this delayed body. + } + }); + FailureKind expected = status == 200 ? FailureKind.RESPONSE_TOO_LARGE : FailureKind.TRANSIENT_ERROR; + try { + // A draining close would wait for finishBody, hit the client deadline and return TIMEOUT instead. + assertThat(provider(2000).geocode(GeocodingFixtures.ADDRESS)).isEqualTo(new Failure(expected, status)); + } finally { + finishBody.countDown(); + } + } + + @Test + void 연결_거부도_분류하며_다음_HTTP_호출을_시작하지_않는다() throws Exception { + int port; + try (ServerSocket reserved = new ServerSocket(0)) { + port = reserved.getLocalPort(); + } + assertThat(localProvider(port, 2000).geocode(GeocodingFixtures.ADDRESS)) + .isEqualTo(new Failure(FailureKind.CONNECTION_ERROR, null)); + } + + private GoogleGeocodingProvider provider(int timeoutMillis) { + return localProvider(server.getAddress().getPort(), timeoutMillis); + } + + private GoogleGeocodingProvider localProvider(int port, int timeoutMillis) { + GoogleGeocodingProperties properties = new GoogleGeocodingProperties(true, GeocodingFixtures.API_KEY, + Duration.ofMillis(100), Duration.ofMillis(timeoutMillis), 1024); + return new GoogleGeocodingProvider(properties, factory -> (uri, method) -> { + assertThat(uri.getScheme()).isEqualTo("https"); + assertThat(uri.getHost()).isEqualTo("geocode.googleapis.com"); + return factory.createRequest(URI.create("http://127.0.0.1:" + port + uri.getRawPath() + + "?" + uri.getRawQuery()), method); + }); + } + + private void respond(HttpExchange exchange, int status, String body, boolean chunked) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + try (exchange) { + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, chunked ? 0 : bytes.length); + exchange.getResponseBody().write(bytes); + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapGoogleLiveFlowTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapGoogleLiveFlowTest.java new file mode 100644 index 00000000..0bd90f88 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapGoogleLiveFlowTest.java @@ -0,0 +1,275 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Base64; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.internal.map.LocationJobScheduler; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionService; +import org.sopt.hashi.restaurant.internal.map.MapSessionProperties; +import org.sopt.hashi.restaurant.internal.map.RestaurantLocationWorker; +import org.sopt.hashi.shared.storage.FileStorage; +import org.sopt.hashi.user.domain.User; +import org.sopt.hashi.user.domain.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@org.junit.jupiter.api.Tag("map-live") +@Testcontainers +@SpringBootTest(properties = { + "spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test", + "hashi.map.google-geocoding.enabled=false", "hashi.map.maintenance.retention-enabled=false", + "hashi.map.maintenance.refresh-ahead=3d", + "hashi.map.location-job.max-attempts=1", "hashi.map.location-job.enabled=true", "hashi.map.location-job.retention=30d", + "hashi.map.location-job.south=35", "hashi.map.location-job.north=36", + "hashi.map.location-job.west=139", "hashi.map.location-job.east=140", + "hashi.restaurant.map.initial-bounds.south=35", "hashi.restaurant.map.initial-bounds.north=36", + "hashi.restaurant.map.initial-bounds.west=139", "hashi.restaurant.map.initial-bounds.east=140", + "hashi.restaurant.map.supported-bounds.south=35", "hashi.restaurant.map.supported-bounds.north=36", + "hashi.restaurant.map.supported-bounds.west=139", "hashi.restaurant.map.supported-bounds.east=140", + "hashi.restaurant.map.session.limits.requests-per-caller=600", + "hashi.restaurant.map.session.limits.new-queries-per-caller=100", + "hashi.restaurant.map.session.limits.requests-per-minute=3000" +}) +@org.springframework.context.annotation.Import(MapLiveProviderConfiguration.class) +@AutoConfigureMockMvc(print = org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class MapGoogleLiveFlowTest { + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("map_flow").withUsername("hashi").withPassword("hashi") + .withUrlParam("connectionTimeZone", "Asia/Seoul") + .withUrlParam("forceConnectionTimeZoneToSession", "true"); + @Container + @ServiceConnection(name = "redis") + static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse( + "redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499")) + .withExposedPorts(6379) + .withCommand("redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "noeviction"); + + + @DynamicPropertySource + static void mapRedis(DynamicPropertyRegistry registry) { + registry.add("hashi.restaurant.map.redis.host", REDIS::getHost); + registry.add("hashi.restaurant.map.redis.port", () -> REDIS.getMappedPort(6379)); + } + @Autowired MockMvc mvc; + @Autowired ObjectMapper json; + @Autowired JwtProvider jwt; + @Autowired JdbcTemplate jdbc; + @Autowired UserRepository users; + @Autowired RestaurantLocationWorker worker; + @Autowired LocationRetentionService retention; + @Autowired LocationRetentionProperties retentionOptions; + @Autowired MapSessionProperties sessionProperties; + @Autowired MapLiveProviderConfiguration.LiveRequestCounter liveRequests; + @MockitoBean LocationJobScheduler scheduler; + @MockitoBean MediaPort mediaPort; + @MockitoBean FileStorage fileStorage; + @Test + void 관리자_저장과_실제_Google_갱신후에도_지도와_컬렉션핀을_유지한다() throws Exception { + mvc.perform(post("/api/v1/admin/restaurants") + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isUnauthorized()); + sessionProperties.setEnabled(true); + jdbc.update("update restaurant_geocoding_budget set enabled=true,daily_limit=2,max_concurrent=1,reserved_calls=0,budget_day=null,blocked_until=null where id=1"); + String token = jwt.createAccessToken(1L, "ROLE_ADMIN"); + JsonNode saved = body(mvc.perform(post("/api/v1/admin/restaurants") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value("ADMIN-204")) + .andExpect(jsonPath("$.data.address") + .value("東京都新宿区西新宿2丁目8番1号 架空ビル1F")) + .andExpect(jsonPath("$.data.geocodingAddress") + .value("東京都新宿区西新宿2丁目8番1号")) + .andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.addressRevision").value(1)) + .andReturn().getResponse().getContentAsString()); + long id = saved.path("data").path("restaurantId").asLong(); + assertThat(jdbc.queryForObject("select count(*) from restaurant_location_job where restaurant_id=?", + Integer.class, id)).isEqualTo(1); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("RESTAURANT-018")); + + worker.runOnce(); + String jobState = jdbc.queryForObject("select state from restaurant_location_job where restaurant_id=?", String.class, id); + String failureCode = jdbc.queryForObject("select failure_code from restaurant_location_job where restaurant_id=?", String.class, id); + assertThat(jobState).as("Real provider/adoption outcome; failureCode=%s", failureCode).isEqualTo("SUCCEEDED"); + sessionProperties.setSigningKey(Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes())); + + mvc.perform(get("/api/v1/admin/restaurants/{id}/location", id) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.locationStatus").value("READY")); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.restaurantId").value(id)); + JsonNode firstPage = body(mvc.perform(get("/api/v1/restaurants/map") + .param("south", "35").param("north", "36").param("west", "139").param("east", "140")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(id)) + .andReturn().getResponse().getContentAsString()).path("data"); + User mapUser = users.saveAndFlush(User.onboard( + "지도실호출회원", "HASHI", LocalDate.of(1998, 1, 1), + "01011110007", "map-live@hashi.test", null)); + String userToken = jwt.createAccessToken(mapUser.getId(), "ROLE_USER"); + long collectionId = body(mvc.perform(post("/api/v1/collections") + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"지도 통합\",\"color\":\"red\",\"visibility\":\"public\"}")) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString()).path("data").path("collectionId").asLong(); + mvc.perform(post("/api/v1/collections/{collectionId}/restaurants", collectionId) + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"restaurantId\":" + id + "}")) + .andExpect(status().isCreated()); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.locationUnavailableCount").value(0)) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(id)); + mvc.perform(get("/api/v1/restaurants/save-counts").param("restaurantIds", Long.toString(id))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.restaurants[0].saveCount").value(1)); + mvc.perform(get("/api/v1/users/me/restaurant-saves").param("restaurantIds", Long.toString(id)) + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.restaurants[0].saved").value(true)); + + assertThat(jdbc.queryForObject("select reserved_calls from restaurant_geocoding_budget where id=1", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("select count(*) from restaurant_location_job where restaurant_id=? and state='SUCCEEDED'", Integer.class, id)).isEqualTo(1); + assertThat(liveRequests.count()).isEqualTo(1); + + mvc.perform(patch("/api/v1/admin/restaurants/{id}", id) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"합성 식당","localName":"試験", + "address":"東京都新宿区西新宿2丁目8番1号 架空ビル1F", + "geocodingAddress":"東京都新宿区西新宿2丁目8番1号"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("ADMIN-205")) + .andExpect(jsonPath("$.data.address") + .value("東京都新宿区西新宿2丁目8番1号 架空ビル1F")) + .andExpect(jsonPath("$.data.geocodingAddress") + .value("東京都新宿区西新宿2丁目8番1号")) + .andExpect(jsonPath("$.data.locationStatus").value("READY")) + .andExpect(jsonPath("$.data.addressRevision").value(1)); + assertThat(jdbc.queryForObject("select count(*) from restaurant_location_job where restaurant_id=?", + Integer.class, id)).isEqualTo(1); + assertThat(liveRequests.count()).isEqualTo(1); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.locationUnavailableCount").value(0)) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(id)); + + LocationSnapshot initial = location(id); + assertThat(Duration.between(initial.obtainedAt(), initial.validUntil())).isEqualTo(Duration.ofDays(30)); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id + SET l.obtained_at=UTC_TIMESTAMP(6)-INTERVAL 29 DAY, + l.valid_until=UTC_TIMESTAMP(6)+INTERVAL 1 DAY + WHERE r.id=? + """, id); + LocationSnapshot aged = location(id); + assertThat(Duration.between(aged.obtainedAt(), aged.validUntil())).isEqualTo(Duration.ofDays(30)); + + assertThat(retentionOptions.retentionEnabled()).isFalse(); + assertThat(retention.refresh(retentionOptions)).isEqualTo(1); + LocationSnapshot pending = location(id); + assertThat(pending.status()).isEqualTo("PENDING"); + assertThat(pending.latitude()).isEqualByComparingTo(aged.latitude()); + assertThat(pending.longitude()).isEqualByComparingTo(aged.longitude()); + assertThat(pending.obtainedAt()).isEqualTo(aged.obtainedAt()); + assertThat(pending.validUntil()).isEqualTo(aged.validUntil()); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.restaurantId").value(id)); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(id)); + + worker.runOnce(); + LocationSnapshot refreshed = location(id); + assertThat(refreshed.status()).isEqualTo("READY"); + assertThat(refreshed.latitude()).isNotNull(); + assertThat(refreshed.longitude()).isNotNull(); + assertThat(refreshed.obtainedAt()).isAfter(aged.obtainedAt()); + assertThat(refreshed.validUntil()).isAfter(aged.validUntil()); + assertThat(Duration.between(refreshed.obtainedAt(), refreshed.validUntil())) + .isEqualTo(Duration.ofDays(30)); + mvc.perform(get("/api/v1/restaurants/map") + .param("south", "35").param("north", "36").param("west", "139").param("east", "140")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content[0].restaurantId").value(id)); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.locationUnavailableCount").value(0)) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(id)); + assertThat(liveRequests.count()).isEqualTo(2); + assertThat(jdbc.queryForObject("select reserved_calls from restaurant_geocoding_budget where id=1", Integer.class)).isEqualTo(2); + assertThat(jdbc.queryForObject("select count(*) from restaurant_location_job where restaurant_id=? and state='SUCCEEDED'", Integer.class, id)).isEqualTo(2); + System.out.println("Map live flow: providerRequests=2 refreshRegistered=true refreshed=true readPins=true"); + } + private LocationSnapshot location(long restaurantId) { + return jdbc.queryForObject(""" + SELECT l.status, l.latitude, l.longitude, l.obtained_at, l.valid_until + FROM restaurant r JOIN restaurant_location l ON l.id=r.location_id + WHERE r.id=? + """, (rs, row) -> new LocationSnapshot(rs.getString("status"), rs.getBigDecimal("latitude"), + rs.getBigDecimal("longitude"), rs.getObject("obtained_at", LocalDateTime.class), + rs.getObject("valid_until", LocalDateTime.class)), restaurantId); + } + private record LocationSnapshot(String status, BigDecimal latitude, BigDecimal longitude, + LocalDateTime obtainedAt, LocalDateTime validUntil) { } + private JsonNode body(String value) throws Exception { return json.readTree(value); } + private String createBody() { + return """ + {"name":"합성 식당","localName":"試験","summary":"합성 요약","description":"합성 설명", + "address":"東京都新宿区西新宿2丁目8番1号 架空ビル1F", + "geocodingAddress":"東京都新宿区西新宿2丁目8番1号", + "area":"합성 지역","genre":"sushi", + "foodCategory":"초밥","placeType":"restaurant","priceCurrency":"JPY", + "minPrice":1,"maxPrice":10,"imageKeys":["restaurants/synthetic.jpg"], + "hashtags":["합성"],"curationTypes":[],"businessHours":[ + {"dayOfWeek":"MONDAY","closed":true},{"dayOfWeek":"TUESDAY","closed":true}, + {"dayOfWeek":"WEDNESDAY","closed":true},{"dayOfWeek":"THURSDAY","closed":true}, + {"dayOfWeek":"FRIDAY","closed":true},{"dayOfWeek":"SATURDAY","closed":true}, + {"dayOfWeek":"SUNDAY","closed":true}]} + """; + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapLiveProviderConfiguration.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapLiveProviderConfiguration.java new file mode 100644 index 00000000..495003e5 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapLiveProviderConfiguration.java @@ -0,0 +1,100 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.UnaryOperator; +import org.apache.hc.client5.http.DnsResolver; +import org.sopt.hashi.restaurant.internal.map.GeocodingProvider; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.AddressComponent; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Candidates; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +/** Test-only TCP tunnel: the HTTPS URI, SNI and normal certificate verification stay unchanged. */ +@TestConfiguration(proxyBeanMethods = false) +class MapLiveProviderConfiguration { + @Bean @Primary + GeocodingProvider liveGeocodingProvider(LiveRequestCounter requests) { + if (!Boolean.getBoolean("map.live.enabled")) { + throw new IllegalStateException("Live provider requires explicit opt-in"); + } + var properties = new GoogleGeocodingProperties(true, + System.getenv("HASHI_MAP_GOOGLEGEOCODING_APIKEY"), + Duration.ofSeconds(5), Duration.ofSeconds(15), 65536); + var delegate = new GoogleGeocodingProvider(properties, UnaryOperator.identity(), new TunnelDnsResolver()); + return address -> { + requests.acquire(); + var result = delegate.geocode(address); + if (result instanceof Candidates candidates) { + for (int index = 0; index < candidates.candidates().size(); index++) { + var components = candidates.candidates().get(index).addressComponents(); + System.out.println("Live diagnostic candidate=" + index + " componentCount=" + components.size()); + components.forEach(component -> System.out.println(componentSummary(component))); + } + } + return result; + }; + } + + @Bean + LiveRequestCounter liveRequestCounter() { + return new LiveRequestCounter(2); + } + + // Never print provider strings, except names in this fixed type allowlist. + static String componentSummary(AddressComponent component) { + Set known = Set.of("country", "postal_code", "political", "sublocality", "locality", + "administrative_area_level_1", "administrative_area_level_2", "sublocality_level_1", + "sublocality_level_2", "sublocality_level_3", "sublocality_level_4", "sublocality_level_5", + "route", "street_number", "premise", "subpremise", "street_address", "point_of_interest"); + String text = component.longText() == null ? "" : component.longText(); + return "Live diagnostic types=" + component.types().stream().map(type -> known.contains(type) ? type : "UNKNOWN").toList() + + " empty=" + text.isBlank() + " digitsOnly=" + text.matches("[0-90-9]+") + + " containsKanjiNumeral=" + text.matches(".*[〇零一二三四五六七八九十百千万壱弐参].*"); + } + + static final class LiveRequestCounter { + private final int maximum; + private final AtomicInteger requests = new AtomicInteger(); + + LiveRequestCounter(int maximum) { + this.maximum = maximum; + } + + int acquire() { + while (true) { + int current = requests.get(); + if (current >= maximum) { + throw new IllegalStateException("Only two live provider requests are allowed"); + } + if (requests.compareAndSet(current, current + 1)) { + return current + 1; + } + } + } + + int count() { + return requests.get(); + } + } + + static final class TunnelDnsResolver implements DnsResolver { + private void checkHost(String host) throws UnknownHostException { + if (!"geocode.googleapis.com".equals(host)) { + throw new UnknownHostException("Unexpected live-test host"); + } + } + public InetAddress[] resolve(String host) throws UnknownHostException { + checkHost(host); + return new InetAddress[]{InetAddress.getByAddress(new byte[]{127, 0, 0, 1})}; + } + public String resolveCanonicalHostname(String host) throws UnknownHostException { + checkHost(host); + return host; + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapLiveProviderConfigurationTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapLiveProviderConfigurationTest.java new file mode 100644 index 00000000..31c6fc95 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/google/MapLiveProviderConfigurationTest.java @@ -0,0 +1,54 @@ +package org.sopt.hashi.restaurant.internal.map.google; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; + +@Isolated("Changes the live-test opt-in property without invoking Google") +class MapLiveProviderConfigurationTest { + @Test + void 진단은_원문과_알수없는_타입을_출력하지_않는다() { + var component = new org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.AddressComponent( + "비공개주소二丁目", "비공개약어", java.util.List.of("premise", "비공개타입")); + assertThat(MapLiveProviderConfiguration.componentSummary(component)) + .isEqualTo("Live diagnostic types=[premise, UNKNOWN] empty=false digitsOnly=false containsKanjiNumeral=true"); + } + + @Test + void 명시적_실행_동의_없이는_실제_provider를_생성하지_않는다() { + String previous = System.getProperty("map.live.enabled"); + try { + System.clearProperty("map.live.enabled"); + assertThatThrownBy(() -> new MapLiveProviderConfiguration().liveGeocodingProvider( + new MapLiveProviderConfiguration.LiveRequestCounter(2))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Live provider requires explicit opt-in"); + } finally { + if (previous == null) System.clearProperty("map.live.enabled"); + else System.setProperty("map.live.enabled", previous); + } + } + + @Test + void 실제_provider_호출은_두번만_허용한다() { + var requests = new MapLiveProviderConfiguration.LiveRequestCounter(2); + + assertThat(requests.acquire()).isEqualTo(1); + assertThat(requests.acquire()).isEqualTo(2); + assertThatThrownBy(requests::acquire) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Only two live provider requests are allowed"); + assertThat(requests.count()).isEqualTo(2); + } + + @Test + void 테스트_DNS는_Google호스트만_loopback으로_연결한다() throws Exception { + var resolver = new MapLiveProviderConfiguration.TunnelDnsResolver(); + assertThat(resolver.resolve("geocode.googleapis.com")[0].getHostAddress()).isEqualTo("127.0.0.1"); + assertThat(resolver.resolveCanonicalHostname("geocode.googleapis.com")).isEqualTo("geocode.googleapis.com"); + assertThatThrownBy(() -> resolver.resolve("other.example")).isInstanceOf(java.net.UnknownHostException.class); + assertThatThrownBy(() -> resolver.resolveCanonicalHostname("other.example")).isInstanceOf(java.net.UnknownHostException.class); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesConfigTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesConfigTest.java new file mode 100644 index 00000000..592740e7 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesConfigTest.java @@ -0,0 +1,107 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; + +@ExtendWith(OutputCaptureExtension.class) +class GooglePlacesConfigTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(GooglePlacesConfig.class); + + @Test + void 기본_비활성에서는_키없이_부팅하고_검색과_상세호출을_거부한다() { + runner.run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(PlacesProvider.class); + PlacesProvider provider = context.getBean(PlacesProvider.class); + assertThat(provider.search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.DISABLED, null)); + assertThat(provider.details(PlacesFixtures.PLACE_ID)) + .isEqualTo(new PlaceDetailsResult.Failure(FailureKind.DISABLED, null)); + }); + } + + @Test + void 활성설정은_HTTP_호출없이_Google_adapter를_생성한다() { + enabled().run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(PlacesProvider.class)).isInstanceOf(GooglePlacesProvider.class); + }); + } + + @Test + void 활성인데_키가_없거나_빈_키이면_부팅중에_실패한다() { + runner.withPropertyValues("hashi.map.google-places.enabled=true").run(context -> + assertThat(context.getStartupFailure()) + .hasRootCauseMessage("Google Places requires a valid server API key")); + runner.withPropertyValues("hashi.map.google-places.enabled=true", "hashi.map.google-places.api-key=") + .run(context -> assertThat(context.getStartupFailure()) + .hasRootCauseMessage("Google Places requires a valid server API key")); + } + + @Test + void 잘못된_키를_설정문자열_예외_로그에_노출하지_않는다(CapturedOutput output) { + String secret = "synthetic private key!"; + enabled().withPropertyValues("hashi.map.google-places.api-key=" + secret).run(context -> { + assertThat(context).hasFailed(); + StringWriter trace = new StringWriter(); + context.getStartupFailure().printStackTrace(new PrintWriter(trace)); + assertThat(trace.toString()).doesNotContain(secret); + }); + assertThat(new GooglePlacesProperties(true, secret, null, null, null).toString()).doesNotContain(secret); + assertThat(output.getAll()).doesNotContain(secret); + } + + @ParameterizedTest + @ValueSource(strings = {"connect-timeout=0ms", "connect-timeout=-1ms", "connect-timeout=11s", + "response-timeout=0ms", "response-timeout=31s", "response-timeout=1s", + "max-response-bytes=0", "max-response-bytes=1023", "max-response-bytes=1048577"}) + void 활성설정의_무제한이나_과도한_제한값을_거부한다(String property) { + enabled().withPropertyValues("hashi.map.google-places." + property) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void 양의_밀리초_최솟값과_허용_최댓값을_검증한다() { + new GooglePlacesProperties(true, PlacesFixtures.API_KEY, Duration.ofMillis(1), + Duration.ofMillis(1), 1024).validateEnabled(); + new GooglePlacesProperties(true, PlacesFixtures.API_KEY, Duration.ofSeconds(10), + Duration.ofSeconds(30), 1_048_576).validateEnabled(); + assertThatThrownBy(() -> new GooglePlacesProperties(true, PlacesFixtures.API_KEY, + Duration.ofNanos(1), Duration.ofSeconds(1), 1024).validateEnabled()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void HTTP_wire_logging_활성환경에서는_외부호출을_허용하지_않는다() { + Logger logger = (Logger) LoggerFactory.getLogger("org.apache.hc.client5.http.wire"); + Level previous = logger.getLevel(); + try { + logger.setLevel(Level.DEBUG); + enabled().run(context -> assertThat(context.getStartupFailure()) + .hasRootCauseMessage("Google Places requires HTTP wire logging to be disabled")); + } finally { + logger.setLevel(previous); + } + } + + private ApplicationContextRunner enabled() { + return runner.withPropertyValues("hashi.map.google-places.enabled=true", + "hashi.map.google-places.api-key=" + PlacesFixtures.API_KEY); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesDeadlineTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesDeadlineTest.java new file mode 100644 index 00000000..f467742a --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesDeadlineTest.java @@ -0,0 +1,131 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.hc.client5.http.DnsResolver; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; + +class GooglePlacesDeadlineTest { + + private static final int AWAIT_SECONDS = 15; + + private final AtomicInteger requests = new AtomicInteger(); + private HttpServer server; + private ExecutorService callers; + + @BeforeEach + void setUp() throws IOException { + callers = Executors.newVirtualThreadPerTaskExecutor(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setExecutor(callers); + server.createContext("/v1/places:searchText", exchange -> { + requests.incrementAndGet(); + byte[] body = "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8); + try (exchange) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + }); + server.start(); + } + + @AfterEach + void tearDown() { + server.stop(0); + callers.shutdownNow(); + } + + @Test + void 취소를_무시하는_DNS가_있어도_호출수와_대기열이_계속_늘지_않는다() throws Exception { + int maximum = GooglePlacesProvider.MAX_CONCURRENT_CALLS; + DelayedResolver resolver = new DelayedResolver(maximum); + GooglePlacesProvider provider = provider(resolver); + ArrayList> results = new ArrayList<>(); + for (int index = 0; index < maximum; index++) { + results.add(callers.submit(() -> provider.search(PlacesFixtures.QUERY))); + } + try { + assertThat(resolver.entered.await(AWAIT_SECONDS, TimeUnit.SECONDS)).isTrue(); + for (Future result : results) { + assertThat(result.get(AWAIT_SECONDS, TimeUnit.SECONDS)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.TIMEOUT, null)); + } + for (int index = 0; index < 20; index++) { + assertThat(provider.search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.CAPACITY_EXCEEDED, null)); + } + assertThat(resolver.calls).hasValue(maximum); + assertThat(resolver.threads).hasSize(maximum).allMatch(Thread::isAlive); + assertThat(requests).hasValue(0); + } finally { + resolver.release.countDown(); + } + await().atMost(Duration.ofSeconds(AWAIT_SECONDS)) + .until(() -> resolver.threads.stream().noneMatch(Thread::isAlive)); + assertThat(requests).hasValue(0); + assertThat(provider.search(PlacesFixtures.QUERY)).isEqualTo(new PlacesSearchResult.NoResults()); + assertThat(requests).hasValue(1); + } + + private GooglePlacesProvider provider(DnsResolver resolver) { + GooglePlacesProperties properties = new GooglePlacesProperties(true, PlacesFixtures.API_KEY, + Duration.ofMillis(100), Duration.ofSeconds(2), 1024); + int port = server.getAddress().getPort(); + return new GooglePlacesProvider(properties, factory -> (uri, method) -> factory.createRequest( + URI.create("http://synthetic.invalid:" + port + uri.getRawPath()), method), resolver); + } + + private static final class DelayedResolver implements DnsResolver { + private final CountDownLatch entered; + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicInteger calls = new AtomicInteger(); + private final Set threads = ConcurrentHashMap.newKeySet(); + + private DelayedResolver(int expectedCalls) { + entered = new CountDownLatch(expectedCalls); + } + + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + calls.incrementAndGet(); + threads.add(Thread.currentThread()); + entered.countDown(); + boolean released = false; + while (!released) { + try { + release.await(); + released = true; + } catch (InterruptedException ignored) { + // Model a resolver that cannot be interrupted. + } + } + return new InetAddress[]{InetAddress.getByAddress(new byte[]{127, 0, 0, 1})}; + } + + @Override + public String resolveCanonicalHostname(String host) { + return host; + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProviderTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProviderTest.java new file mode 100644 index 00000000..56ea3622 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesProviderTest.java @@ -0,0 +1,216 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +@ExtendWith(OutputCaptureExtension.class) +class GooglePlacesProviderTest { + + private final AtomicInteger attempts = new AtomicInteger(); + + @ParameterizedTest + @CsvSource({"400,INVALID_REQUEST", "401,ACCESS_DENIED", "403,ACCESS_DENIED", + "404,CONFIGURATION_ERROR", "408,TIMEOUT", "429,QUOTA_EXCEEDED", + "500,TRANSIENT_ERROR", "503,TRANSIENT_ERROR", "302,REDIRECT_REJECTED", + "204,INVALID_RESPONSE", "418,INVALID_RESPONSE"}) + void 검색_HTTP_오류는_원문을_읽거나_재시도하지_않고_분류한다(int status, FailureKind expected) { + MockClientHttpResponse response = unreadable(status); + assertThat(provider(response).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(expected, status)); + assertThat(attempts).hasValue(1); + } + + @Test + void 상세_404는_검색결과_없음이고_다른_오류는_실패다() { + assertThat(provider(unreadable(404)).details(PlacesFixtures.PLACE_ID)) + .isEqualTo(new PlaceDetailsResult.NoResults()); + assertThat(provider(unreadable(429)).details(PlacesFixtures.PLACE_ID)) + .isEqualTo(new PlaceDetailsResult.Failure(FailureKind.QUOTA_EXCEEDED, 429)); + } + + @ParameterizedTest + @CsvSource({"RESOURCE_EXHAUSTED,QUOTA_EXCEEDED", "PERMISSION_DENIED,ACCESS_DENIED", + "UNKNOWN,ACCESS_DENIED"}) + void HTTP_403은_제한된_상태값으로_quota와_권한오류를_구분한다( + String rpcStatus, FailureKind expected, CapturedOutput output) { + String body = "{\"error\":{\"message\":\"" + PlacesFixtures.API_KEY + + "\",\"status\":\"" + rpcStatus + "\"}}"; + assertThat(provider(json(body, 403)).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(expected, 403)); + assertThat(output.getAll()).doesNotContain(PlacesFixtures.API_KEY, PlacesFixtures.QUERY); + assertThat(attempts).hasValue(1); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + void 빈_검색어는_HTTP_호출전에_거부한다(String query) { + assertThat(provider(json("{}", 200)).search(query)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.INVALID_REQUEST, null)); + assertThat(attempts).hasValue(0); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "../places:searchText", "id/next", "id?key=leak", "id#fragment", "한글"}) + void 안전하지_않은_placeId는_경로를_만들기전에_거부한다(String placeId) { + assertThat(provider(json(PlacesFixtures.CANDIDATE, 200)).details(placeId)) + .isEqualTo(new PlaceDetailsResult.Failure(FailureKind.INVALID_REQUEST, null)); + assertThat(attempts).hasValue(0); + } + + @Test + void 너무_긴_검색어와_placeId는_HTTP_호출전에_거부한다() { + GooglePlacesProvider provider = provider(json("{}", 200)); + assertThat(provider.search("a".repeat(PlacesProvider.MAX_SEARCH_QUERY_LENGTH + 1))) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.INVALID_REQUEST, null)); + assertThat(provider.details("a".repeat(256))) + .isEqualTo(new PlaceDetailsResult.Failure(FailureKind.INVALID_REQUEST, null)); + assertThat(attempts).hasValue(0); + } + + @Test + void 관리자_현지명과_주소의_최대_길이를_합친_검색어도_전송한다() { + String query = "가".repeat(100) + " " + "東".repeat(255); + assertThat(provider(json("{}", 200)).search(query)).isInstanceOf(PlacesSearchResult.NoResults.class); + assertThat(attempts).hasValue(1); + } + + @Test + void 잘못된_JSON과_초과응답을_안전하게_분류한다() { + assertThat(provider(json("{\"places\":", 200)).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.INVALID_RESPONSE, 200)); + assertThat(provider(json("{" + " ".repeat(2048) + "}", 200)).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.RESPONSE_TOO_LARGE, 200)); + } + + @Test + void 비_JSON과_압축된_응답을_거부한다() { + MockClientHttpResponse html = json("private", 200); + html.getHeaders().setContentType(MediaType.TEXT_HTML); + assertThat(provider(html).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.INVALID_RESPONSE, 200)); + + MockClientHttpResponse compressed = json("{}", 200); + compressed.getHeaders().set("Content-Encoding", "gzip"); + assertThat(provider(compressed).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.INVALID_RESPONSE, 200)); + } + + @Test + void 연결과_timeout_예외는_원문없이_분류한다(CapturedOutput output) { + String privateText = PlacesFixtures.API_KEY + PlacesFixtures.QUERY + "https://private.invalid/full"; + GooglePlacesProvider connection = new GooglePlacesProvider(properties(), ignored -> (uri, method) -> { + attempts.incrementAndGet(); + throw new IOException(privateText, new IllegalArgumentException(privateText)); + }); + assertThat(connection.search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.CONNECTION_ERROR, null)); + + GooglePlacesProvider timeout = new GooglePlacesProvider(properties(), ignored -> (uri, method) -> { + attempts.incrementAndGet(); + throw new SocketTimeoutException(privateText); + }); + assertThat(timeout.details(PlacesFixtures.PLACE_ID)) + .isEqualTo(new PlaceDetailsResult.Failure(FailureKind.TIMEOUT, null)); + assertThat(output.getAll()).doesNotContain(privateText, PlacesFixtures.API_KEY, PlacesFixtures.QUERY); + } + + @Test + void 예상하지_못한_실패는_단계와_클래스명만_기록한다(CapturedOutput output) { + String privateText = PlacesFixtures.API_KEY + PlacesFixtures.QUERY; + GooglePlacesProvider provider = new GooglePlacesProvider(properties(), ignored -> (uri, method) -> { + attempts.incrementAndGet(); + throw new IllegalStateException(privateText, new IllegalArgumentException(privateText)); + }); + Logger logger = (Logger) LoggerFactory.getLogger(GooglePlacesProvider.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + assertThat(provider.search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.INVALID_RESPONSE, null)); + assertThat(appender.list).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).isEqualTo( + "Google Places failure phase=execute exceptionType=java.lang.IllegalStateException"); + assertThat(event.getThrowableProxy()).isNull(); + }); + assertThat(output.getAll()).doesNotContain(privateText, PlacesFixtures.API_KEY, PlacesFixtures.QUERY, + "java.lang.IllegalArgumentException", "Caused by:"); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + + @Test + void 고정_endpoint만_사용하고_키는_query에_넣지_않는다() { + GooglePlacesProvider provider = new GooglePlacesProvider(properties(), ignored -> (uri, method) -> { + assertThat(uri.getScheme()).isEqualTo("https"); + assertThat(uri.getHost()).isEqualTo("places.googleapis.com"); + assertThat(uri.getRawQuery()).isNull(); + assertThat(uri.getPath()).isIn("/v1/places:searchText", "/v1/places/" + PlacesFixtures.PLACE_ID); + return request(uri, json(method.name().equals("POST") ? "{}" : PlacesFixtures.CANDIDATE, 200)); + }); + provider.search("https://attacker.invalid/x?key=bad#fragment"); + provider.details(PlacesFixtures.PLACE_ID); + assertThat(attempts).hasValue(2); + } + + private GooglePlacesProvider provider(MockClientHttpResponse response) { + return new GooglePlacesProvider(properties(), ignored -> (uri, method) -> request(uri, response)); + } + + private MockClientHttpRequest request(URI uri, MockClientHttpResponse response) { + attempts.incrementAndGet(); + MockClientHttpRequest request = new MockClientHttpRequest(); + request.setURI(uri); + request.setResponse(response); + return request; + } + + private MockClientHttpResponse unreadable(int status) { + return new MockClientHttpResponse(new ByteArrayInputStream(new byte[0]) { + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + throw new AssertionError("Error body must not be read"); + } + }, HttpStatusCode.valueOf(status)); + } + + private MockClientHttpResponse json(String body, int status) { + MockClientHttpResponse response = new MockClientHttpResponse(body.getBytes(StandardCharsets.UTF_8), + HttpStatusCode.valueOf(status)); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + return response; + } + + private GooglePlacesProperties properties() { + return new GooglePlacesProperties(true, PlacesFixtures.API_KEY, null, null, 1024); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesResponseParserTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesResponseParserTest.java new file mode 100644 index 00000000..12b95eaa --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesResponseParserTest.java @@ -0,0 +1,114 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; + +class GooglePlacesResponseParserTest { + + private final GooglePlacesResponseParser parser = new GooglePlacesResponseParser(); + + @Test + void 검색과_상세에서_장소_식별정보와_공식_attribution을_보존한다() { + PlacesSearchResult.Candidates search = (PlacesSearchResult.Candidates) parseSearch( + PlacesFixtures.SEARCH_SUCCESS); + PlaceDetailsResult.Place details = (PlaceDetailsResult.Place) parseDetails(PlacesFixtures.CANDIDATE); + + PlacesCandidate candidate = search.candidates().getFirst(); + assertThat(details.candidate()).isEqualTo(candidate); + assertThat(candidate.placeId()).isEqualTo(PlacesFixtures.PLACE_ID); + assertThat(candidate.displayName()).isEqualTo("Synthetic Restaurant"); + assertThat(candidate.address()).isEqualTo("1-2-3 Test, Chiyoda City, Tokyo"); + assertThat(candidate.latitude()).isEqualByComparingTo("35.12345678901234567"); + assertThat(candidate.longitude()).isEqualByComparingTo("139.76543210987654321"); + assertThat(candidate.countryCode()).isEqualTo("JP"); + assertThat(candidate.administrativeArea()).isEqualTo("Tokyo"); + assertThat(candidate.types()).containsExactly("restaurant", "food"); + assertThat(candidate.businessStatus()).isEqualTo("OPERATIONAL"); + assertThat(candidate.attributions()).containsExactly( + new PlacesAttribution("Synthetic Provider", "https://provider.invalid/info")); + assertThat(candidate.googleMapsUri()).isEqualTo("https://maps.google.com/?cid=synthetic"); + } + + @Test + void 생략되거나_빈_places는_검색결과_없음이다() { + assertThat(parseSearch("{}")).isEqualTo(new PlacesSearchResult.NoResults()); + assertThat(parseSearch("{\"places\":[]}")) + .isEqualTo(new PlacesSearchResult.NoResults()); + } + + @ParameterizedTest + @MethodSource("invalidSearchBodies") + void 잘못된_검색_JSON은_원문없이_실패한다(String body) { + assertThat(parseSearch(body)).isEqualTo( + new PlacesSearchResult.Failure(FailureKind.INVALID_RESPONSE, 200)); + } + + @ParameterizedTest + @MethodSource("invalidDetailsBodies") + void 잘못된_상세_JSON은_원문없이_실패한다(String body) { + assertThat(parseDetails(body)).isEqualTo( + new PlaceDetailsResult.Failure(FailureKind.INVALID_RESPONSE, 200)); + } + + @Test + void 결과의_목록은_방어적으로_복사되고_toString은_장소정보를_숨긴다() { + PlacesSearchResult.Candidates result = (PlacesSearchResult.Candidates) parseSearch( + PlacesFixtures.SEARCH_SUCCESS); + PlacesCandidate candidate = result.candidates().getFirst(); + assertThatThrownBy(() -> result.candidates().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> candidate.types().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> candidate.attributions().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThat(result.toString()).isEqualTo("PlacesSearchCandidates[redacted]"); + assertThat(candidate.toString()).isEqualTo("PlacesCandidate[redacted]"); + assertThat(candidate.attributions().getFirst().toString()).isEqualTo("PlacesAttribution[redacted]"); + assertThat(new PlaceDetailsResult.Place(candidate).toString()).isEqualTo("PlaceDetails[redacted]"); + } + + @Test + void HTTP_403은_canonical_quota_상태만_구분한다() { + assertThat(parser.classifyForbidden(bytes("{\"error\":{\"status\":\"RESOURCE_EXHAUSTED\"}}"))) + .isEqualTo(FailureKind.QUOTA_EXCEEDED); + assertThat(parser.classifyForbidden(bytes("{\"error\":{\"status\":\"PERMISSION_DENIED\"}}"))) + .isEqualTo(FailureKind.ACCESS_DENIED); + assertThat(parser.classifyForbidden(bytes("{\"error\":"))) + .isEqualTo(FailureKind.ACCESS_DENIED); + } + + static Stream invalidSearchBodies() { + String sixPlaces = "{\"places\":[" + String.join(",", java.util.Collections.nCopies(6, + PlacesFixtures.CANDIDATE)) + "]}"; + return Stream.of("", "null", "[]", "{}{}", "{\"places\":null}", "{\"places\":{}}", + "{\"places\":[{}]}", "{\"error\":{\"message\":\"private\"}}", sixPlaces, + PlacesFixtures.SEARCH_SUCCESS.replace("35.12345678901234567", "90.000001"), + PlacesFixtures.SEARCH_SUCCESS.replace("\"provider\":\"Synthetic Provider\"", "\"provider\":null"), + "{\"nested\":" + "[".repeat(25) + "0" + "]".repeat(25) + "}", + "{\"text\":\"" + "a".repeat(10_000) + "\"}"); + } + + static Stream invalidDetailsBodies() { + return Stream.of("", "{}", "null", "[]", "{}{}", "{\"error\":{}}", + PlacesFixtures.CANDIDATE.replace("\"id\":\"ChIJ_synthetic-place-1\"", "\"id\":null"), + PlacesFixtures.CANDIDATE.replace("139.76543210987654321", "180.000001"), + PlacesFixtures.CANDIDATE.replace("\"formattedAddress\":\"1-2-3 Test, Chiyoda City, Tokyo\"", + "\"formattedAddress\":{}")); + } + + private PlacesSearchResult parseSearch(String body) { + return parser.parseSearch(bytes(body)); + } + + private PlaceDetailsResult parseDetails(String body) { + return parser.parseDetails(bytes(body)); + } + + private static byte[] bytes(String body) { + return body.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesWireTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesWireTest.java new file mode 100644 index 00000000..36f305d7 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/GooglePlacesWireTest.java @@ -0,0 +1,214 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; + +class GooglePlacesWireTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final AtomicInteger requests = new AtomicInteger(); + private HttpServer server; + private ExecutorService executor; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + executor = Executors.newVirtualThreadPerTaskExecutor(); + server.setExecutor(executor); + server.start(); + } + + @AfterEach + void tearDown() { + server.stop(0); + executor.shutdownNow(); + } + + @Test + void 검색과_상세는_공식_method_body_key_fieldMask만_전송한다() throws Exception { + List captured = java.util.Collections.synchronizedList(new ArrayList<>()); + server.createContext("/v1/", exchange -> { + requests.incrementAndGet(); + byte[] body = exchange.getRequestBody().readAllBytes(); + captured.add(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI().getRawPath(), + exchange.getRequestHeaders().getFirst("Content-Type"), + exchange.getRequestHeaders().getFirst("X-Goog-Api-Key"), + exchange.getRequestHeaders().getFirst("X-Goog-FieldMask"), body)); + String response = exchange.getRequestURI().getRawPath().endsWith(":searchText") + ? PlacesFixtures.SEARCH_SUCCESS : PlacesFixtures.CANDIDATE; + respond(exchange, 200, response, false); + }); + + GooglePlacesProvider provider = provider(2000, 4096); + assertThat(provider.search(PlacesFixtures.QUERY)).isInstanceOf(PlacesSearchResult.Candidates.class); + assertThat(provider.details(PlacesFixtures.PLACE_ID)).isInstanceOf(PlaceDetailsResult.Place.class); + + assertThat(requests).hasValue(2); + CapturedRequest search = captured.get(0); + assertThat(search.method()).isEqualTo("POST"); + assertThat(search.path()).isEqualTo("/v1/places:searchText"); + assertThat(search.contentType()).startsWith("application/json"); + assertThat(search.apiKey()).isEqualTo(PlacesFixtures.API_KEY); + assertThat(search.fieldMask()).isEqualTo(GooglePlacesProvider.SEARCH_FIELD_MASK).doesNotContain("*"); + JsonNode body = mapper.readTree(search.body()); + List fieldNames = new ArrayList<>(); + body.fieldNames().forEachRemaining(fieldNames::add); + assertThat(fieldNames).containsExactlyInAnyOrder("textQuery", "languageCode", + "regionCode", "pageSize", "includePureServiceAreaBusinesses"); + assertThat(body.path("textQuery").textValue()).isEqualTo(PlacesFixtures.QUERY); + assertThat(body.path("languageCode").textValue()).isEqualTo("en"); + assertThat(body.path("regionCode").textValue()).isEqualTo("JP"); + assertThat(body.path("pageSize").intValue()).isEqualTo(5); + assertThat(body.path("includePureServiceAreaBusinesses").booleanValue()).isFalse(); + + CapturedRequest details = captured.get(1); + assertThat(details.method()).isEqualTo("GET"); + assertThat(details.path()).isEqualTo("/v1/places/" + PlacesFixtures.PLACE_ID); + assertThat(details.apiKey()).isEqualTo(PlacesFixtures.API_KEY); + assertThat(details.fieldMask()).isEqualTo(GooglePlacesProvider.DETAILS_FIELD_MASK).doesNotContain("*"); + assertThat(details.body()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {301, 302, 303, 307, 308}) + void redirect를_따라가지_않아_키를_다른_경로로_전달하지_않는다(int status) { + AtomicInteger redirected = new AtomicInteger(); + server.createContext("/v1/places:searchText", exchange -> { + requests.incrementAndGet(); + exchange.getResponseHeaders().set("Location", + "http://127.0.0.1:" + server.getAddress().getPort() + "/leak"); + respond(exchange, status, "private redirect body", false); + }); + server.createContext("/leak", exchange -> { + redirected.incrementAndGet(); + respond(exchange, 200, "{}", false); + }); + + assertThat(provider(2000, 1024).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.REDIRECT_REJECTED, status)); + assertThat(requests).hasValue(1); + assertThat(redirected).hasValue(0); + } + + @ParameterizedTest + @ValueSource(ints = {429, 500, 503}) + void 제한과_일시실패를_HTTP에서_재시도하지_않는다(int status) { + server.createContext("/v1/places:searchText", exchange -> { + requests.incrementAndGet(); + respond(exchange, status, "{\"error\":{\"message\":\"private\"}}", false); + }); + assertThat(provider(2000, 1024).search(PlacesFixtures.QUERY)).isInstanceOf( + PlacesSearchResult.Failure.class); + assertThat(requests).hasValue(1); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void ContentLength와_chunked_응답을_모두_제한한다(boolean chunked) { + server.createContext("/v1/places:searchText", exchange -> { + requests.incrementAndGet(); + respond(exchange, 200, " ".repeat(2048), chunked); + }); + assertThat(provider(2000, 1024).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.RESPONSE_TOO_LARGE, 200)); + assertThat(requests).hasValue(1); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 헤더와_본문_지연을_전체_timeout으로_중단한다(boolean bodyDelay) { + server.createContext("/v1/places:searchText", exchange -> { + requests.incrementAndGet(); + try (exchange) { + if (bodyDelay) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + for (int i = 0; i < 20; i++) { + exchange.getResponseBody().write(' '); + exchange.getResponseBody().flush(); + Thread.sleep(50); + } + } else { + Thread.sleep(1000); + respond(exchange, 200, "{}", false); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { + // The provider aborts the call at its deadline. + } + }); + + long started = System.nanoTime(); + assertThat(provider(200, 1024).search(PlacesFixtures.QUERY)) + .isEqualTo(new PlacesSearchResult.Failure(FailureKind.TIMEOUT, null)); + assertThat(Duration.ofNanos(System.nanoTime() - started)).isLessThan(Duration.ofSeconds(2)); + assertThat(requests).hasValue(1); + } + + @Test + void placeId_경로주입은_HTTP_요청을_만들지_않는다() { + AtomicReference path = new AtomicReference<>(); + server.createContext("/v1/", exchange -> { + requests.incrementAndGet(); + path.set(exchange.getRequestURI().getRawPath()); + respond(exchange, 200, PlacesFixtures.CANDIDATE, false); + }); + GooglePlacesProvider provider = provider(2000, 4096); + assertThat(provider.details("../places:searchText?key=leak")) + .isEqualTo(new PlaceDetailsResult.Failure(FailureKind.INVALID_REQUEST, null)); + assertThat(requests).hasValue(0); + assertThat(path.get()).isNull(); + } + + private GooglePlacesProvider provider(int timeoutMillis, int maxResponseBytes) { + GooglePlacesProperties properties = new GooglePlacesProperties(true, PlacesFixtures.API_KEY, + Duration.ofMillis(100), Duration.ofMillis(timeoutMillis), maxResponseBytes); + int port = server.getAddress().getPort(); + return new GooglePlacesProvider(properties, factory -> (uri, method) -> { + assertThat(uri.getScheme()).isEqualTo("https"); + assertThat(uri.getHost()).isEqualTo("places.googleapis.com"); + return factory.createRequest(URI.create("http://127.0.0.1:" + port + uri.getRawPath()), method); + }); + } + + private void respond(HttpExchange exchange, int status, String body, boolean chunked) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + try (exchange) { + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, chunked ? 0 : bytes.length); + exchange.getResponseBody().write(bytes); + } + } + + private record CapturedRequest( + String method, + String path, + String contentType, + String apiKey, + String fieldMask, + byte[] body + ) { + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveFlowTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveFlowTest.java new file mode 100644 index 00000000..3345af13 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveFlowTest.java @@ -0,0 +1,355 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Locale; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.internal.map.GeocodingProvider; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult; +import org.sopt.hashi.restaurant.internal.map.LocationJobScheduler; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionService; +import org.sopt.hashi.restaurant.internal.map.MapSessionProperties; +import org.sopt.hashi.restaurant.internal.map.RestaurantLocationWorker; +import org.sopt.hashi.shared.storage.FileStorage; +import org.sopt.hashi.user.domain.User; +import org.sopt.hashi.user.domain.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Tag("map-live") +@Testcontainers +@SpringBootTest(properties = { + "spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test", + "hashi.map.google-geocoding.enabled=false", "hashi.map.google-places.enabled=false", + "hashi.map.places-selection.enabled=true", + "hashi.map.places-selection.signing-key=c3ludGhldGljLXBsYWNlcy1zaWduaW5nLWtleS0zMmI=", + "hashi.map.places-selection.token-ttl=10m", + "hashi.map.maintenance.retention-enabled=false", "hashi.map.maintenance.refresh-ahead=3d", + "hashi.map.location-job.max-attempts=1", "hashi.map.location-job.enabled=true", + "hashi.map.location-job.retention=30d", + "hashi.map.location-job.south=35", "hashi.map.location-job.north=36", + "hashi.map.location-job.west=139", "hashi.map.location-job.east=140", + "hashi.restaurant.map.initial-bounds.south=35", "hashi.restaurant.map.initial-bounds.north=36", + "hashi.restaurant.map.initial-bounds.west=139", "hashi.restaurant.map.initial-bounds.east=140", + "hashi.restaurant.map.supported-bounds.south=35", "hashi.restaurant.map.supported-bounds.north=36", + "hashi.restaurant.map.supported-bounds.west=139", "hashi.restaurant.map.supported-bounds.east=140", + "hashi.restaurant.map.session.limits.requests-per-caller=600", + "hashi.restaurant.map.session.limits.new-queries-per-caller=100", + "hashi.restaurant.map.session.limits.requests-per-minute=3000" +}) +@Import(MapPlacesLiveProviderConfiguration.class) +@AutoConfigureMockMvc(print = org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class MapPlacesLiveFlowTest { + + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("map_places_flow").withUsername("hashi").withPassword("hashi") + .withUrlParam("connectionTimeZone", "Asia/Seoul") + .withUrlParam("forceConnectionTimeZoneToSession", "true"); + + @Container + @ServiceConnection(name = "redis") + static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse( + "redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499")) + .withExposedPorts(6379) + .withCommand("redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "noeviction"); + + @DynamicPropertySource + static void mapRedis(DynamicPropertyRegistry registry) { + registry.add("hashi.restaurant.map.redis.host", REDIS::getHost); + registry.add("hashi.restaurant.map.redis.port", () -> REDIS.getMappedPort(6379)); + } + @Autowired MockMvc mvc; + @Autowired ObjectMapper json; + @Autowired JwtProvider jwt; + @Autowired JdbcTemplate jdbc; + @Autowired UserRepository users; + @Autowired RestaurantLocationWorker worker; + @Autowired LocationRetentionService retention; + @Autowired LocationRetentionProperties retentionOptions; + @Autowired MapSessionProperties sessionProperties; + @Autowired MapPlacesLiveProviderConfiguration.LiveRequestCounter liveRequests; + @MockitoBean GeocodingProvider geocodingProvider; + @MockitoBean LocationJobScheduler scheduler; + @MockitoBean MediaPort mediaPort; + @MockitoBean FileStorage fileStorage; + + @Test + void 관리자_Places선택과_상세갱신후에도_지도와_컬렉션핀을_유지한다() throws Exception { + given(geocodingProvider.geocode(anyString())).willReturn(new GeocodingResult.NoResults()); + enableBudgets(); + sessionProperties.setEnabled(true); + sessionProperties.setSigningKey(Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes())); + String adminToken = jwt.createAccessToken(1L, "ROLE_ADMIN"); + + JsonNode created = body(mvc.perform(post("/api/v1/admin/restaurants") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.addressRevision").value(1)) + .andReturn().getResponse().getContentAsString()); + long restaurantId = created.path("data").path("restaurantId").asLong(); + + worker.runOnce(); + verify(geocodingProvider, times(1)).geocode(anyString()); + mvc.perform(get("/api/v1/admin/restaurants/{restaurantId}/location", restaurantId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.locationStatus").value("REVIEW_REQUIRED")); + + JsonNode searchData = body(mvc.perform(post( + "/api/v1/admin/restaurants/{restaurantId}/location/place-candidates", restaurantId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"expectedAddressRevision\":1}")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andReturn().getResponse().getContentAsString()).path("data"); + assertThat(searchData.path("restaurantId").asLong() == restaurantId).isTrue(); + assertThat(searchData.path("addressRevision").asLong() == 1L).isTrue(); + JsonNode candidates = searchData.path("candidates"); + assertThat(candidates.isArray()).isTrue(); + + int matchCount = 0; + JsonNode selected = null; + for (JsonNode candidate : candidates) { + String displayName = candidate.path("displayName").asText(""); + boolean ikaruga = displayName.toLowerCase(Locale.ROOT).contains("ikaruga") + || displayName.contains("斑鳩"); + if (ikaruga) { + matchCount++; + selected = candidate; + } + } + assertThat(matchCount).isEqualTo(1); + assertThat(selected != null && selected.path("displayName").isTextual()).isTrue(); + assertThat(selected != null && selected.path("address").isTextual()).isTrue(); + assertThat(selected != null && selected.path("latitude").isNumber()).isTrue(); + assertThat(selected != null && selected.path("longitude").isNumber()).isTrue(); + assertThat(selected != null && "JP".equals(selected.path("countryCode").asText())).isTrue(); + assertThat(selected != null && selected.path("administrativeArea").isTextual()).isTrue(); + assertThat(selected != null && selected.path("types").isArray()).isTrue(); + assertThat(selected != null && selected.path("businessStatus").isTextual()).isTrue(); + assertThat(selected != null && selected.path("attributions").isArray()).isTrue(); + assertThat(selected != null && selected.path("googleMapsUri").isTextual()).isTrue(); + assertThat(selected != null && selected.path("selectionToken").isTextual() + && !selected.path("selectionToken").asText().isBlank()).isTrue(); + assertThat(selected != null && selected.path("selectionExpiresAt").isTextual()).isTrue(); + + BigDecimal searchedLatitude = selected.path("latitude").decimalValue(); + BigDecimal searchedLongitude = selected.path("longitude").decimalValue(); + String selectionToken = selected.path("selectionToken").asText(); + String selectionBody = json.createObjectNode() + .put("expectedAddressRevision", 1) + .put("selectionToken", selectionToken).toString(); + mvc.perform(post("/api/v1/admin/restaurants/{restaurantId}/location/place-selection", restaurantId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON).content(selectionBody)) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.addressRevision").value(1)); + + worker.runOnce(); + LocationSnapshot initial = location(restaurantId); + assertThat(initial.status()).isEqualTo("READY"); + assertThat(initial.source()).isEqualTo("GOOGLE_PLACES"); + assertThat(round6(initial.latitude()).compareTo(round6(searchedLatitude)) == 0).isTrue(); + assertThat(round6(initial.longitude()).compareTo(round6(searchedLongitude)) == 0).isTrue(); + assertThat(jdbc.queryForObject(""" + SELECT COUNT(*) FROM restaurant r JOIN restaurant_location l ON l.id=r.location_id + WHERE r.id=? AND l.google_place_id IS NOT NULL AND l.google_place_id<>'' + """, Integer.class, restaurantId)).isEqualTo(1); + + User mapUser = users.saveAndFlush(User.onboard( + "Places실호출회원", "HASHI", LocalDate.of(1998, 1, 1), + "01011110008", "places-live@hashi.test", null)); + String userToken = jwt.createAccessToken(mapUser.getId(), "ROLE_USER"); + long collectionId = body(mvc.perform(post("/api/v1/collections") + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Places 통합\",\"color\":\"red\",\"visibility\":\"public\"}")) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString()).path("data").path("collectionId").asLong(); + mvc.perform(post("/api/v1/collections/{collectionId}/restaurants", collectionId) + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"restaurantId\":" + restaurantId + "}")) + .andExpect(status().isCreated()); + assertPins(restaurantId, collectionId); + + assertThat(Duration.between(initial.obtainedAt(), initial.validUntil())).isEqualTo(Duration.ofDays(30)); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id + SET l.obtained_at=UTC_TIMESTAMP(6)-INTERVAL 29 DAY, + l.valid_until=UTC_TIMESTAMP(6)+INTERVAL 1 DAY + WHERE r.id=? + """, restaurantId); + LocationSnapshot aged = location(restaurantId); + assertThat(Duration.between(aged.obtainedAt(), aged.validUntil())).isEqualTo(Duration.ofDays(30)); + + assertThat(retentionOptions.retentionEnabled()).isFalse(); + assertThat(retention.refresh(retentionOptions)).isEqualTo(1); + LocationSnapshot pending = location(restaurantId); + assertThat(pending.status()).isEqualTo("PENDING"); + assertThat(round6(pending.latitude()).compareTo(round6(aged.latitude())) == 0).isTrue(); + assertThat(round6(pending.longitude()).compareTo(round6(aged.longitude())) == 0).isTrue(); + assertThat(pending.obtainedAt()).isEqualTo(aged.obtainedAt()); + assertThat(pending.validUntil()).isEqualTo(aged.validUntil()); + assertPins(restaurantId, collectionId); + + worker.runOnce(); + LocationSnapshot refreshed = location(restaurantId); + assertThat(refreshed.status()).isEqualTo("READY"); + assertThat(refreshed.source()).isEqualTo("GOOGLE_PLACES"); + assertThat(refreshed.obtainedAt().isAfter(aged.obtainedAt())).isTrue(); + assertThat(refreshed.validUntil().isAfter(aged.validUntil())).isTrue(); + assertThat(Duration.between(refreshed.obtainedAt(), refreshed.validUntil())) + .isEqualTo(Duration.ofDays(30)); + assertPins(restaurantId, collectionId); + + assertThat(liveRequests.searchCount()).isEqualTo(1); + assertThat(liveRequests.detailCount()).isEqualTo(2); + assertThat(liveRequests.totalCount()).isEqualTo(3); + assertThat(placeBudgetUsed("SEARCH")).isEqualTo(1); + assertThat(placeBudgetUsed("DETAILS")).isEqualTo(2); + assertThat(jdbc.queryForObject("select reserved_calls from restaurant_geocoding_budget where id=1", + Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject(""" + SELECT COUNT(*) FROM restaurant_location_job + WHERE restaurant_id=? AND operation='PLACE_DETAILS' AND state='SUCCEEDED' + """, Integer.class, restaurantId)).isEqualTo(2); + System.out.println( + "Map Places live flow: providerRequests=3 selectionReady=true refreshReady=true readPins=true"); + } + + private void enableBudgets() { + jdbc.update(""" + UPDATE restaurant_geocoding_budget + SET enabled=true,daily_limit=1,max_concurrent=1,reserved_calls=0, + budget_day=null,blocked_until=null + WHERE id=1 + """); + jdbc.update(""" + UPDATE restaurant_places_budget + SET enabled=true,daily_limit=1,minute_limit=1,daily_used=0,minute_used=0, + budget_day=null,minute_window_start=null,blocked_until=null + WHERE operation='SEARCH' + """); + jdbc.update(""" + UPDATE restaurant_places_budget + SET enabled=true,daily_limit=2,minute_limit=2,daily_used=0,minute_used=0, + budget_day=null,minute_window_start=null,blocked_until=null + WHERE operation='DETAILS' + """); + } + + private int placeBudgetUsed(String operation) { + return jdbc.queryForObject("select daily_used from restaurant_places_budget where operation=?", + Integer.class, operation); + } + + private void assertPins(long restaurantId, long collectionId) throws Exception { + mvc.perform(get("/api/v1/restaurants/map") + .param("south", "35").param("north", "36").param("west", "139").param("east", "140")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(restaurantId)); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.locationUnavailableCount").value(0)) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(restaurantId)); + } + + private LocationSnapshot location(long restaurantId) { + return jdbc.queryForObject(""" + SELECT l.status,l.source,l.latitude,l.longitude,l.obtained_at,l.valid_until + FROM restaurant r JOIN restaurant_location l ON l.id=r.location_id + WHERE r.id=? + """, (rs, row) -> new LocationSnapshot(rs.getString("status"), rs.getString("source"), + rs.getBigDecimal("latitude"), rs.getBigDecimal("longitude"), + rs.getObject("obtained_at", LocalDateTime.class), + rs.getObject("valid_until", LocalDateTime.class)), restaurantId); + } + + private BigDecimal round6(BigDecimal value) { + return value.setScale(6, RoundingMode.HALF_UP); + } + + private JsonNode body(String value) throws Exception { + return json.readTree(value); + } + + private String createBody() { + return """ + {"name":"Ikaruga","localName":"東京駅 斑鳩","summary":"실호출 검증 요약", + "description":"실호출 검증 설명", + "address":"東京都千代田区丸の内1-9-1 東京駅一番街 B1F", + "geocodingAddress":"東京都千代田区丸の内1-9-1 東京駅一番街 B1F", + "area":"도쿄역","genre":"noodle","foodCategory":"라멘", + "placeType":"restaurant","priceCurrency":"JPY","minPrice":1,"maxPrice":10, + "imageKeys":["restaurants/synthetic-places.jpg"],"hashtags":["실호출"], + "curationTypes":[],"businessHours":[ + {"dayOfWeek":"MONDAY","closed":true},{"dayOfWeek":"TUESDAY","closed":true}, + {"dayOfWeek":"WEDNESDAY","closed":true},{"dayOfWeek":"THURSDAY","closed":true}, + {"dayOfWeek":"FRIDAY","closed":true},{"dayOfWeek":"SATURDAY","closed":true}, + {"dayOfWeek":"SUNDAY","closed":true}]} + """; + } + + private record LocationSnapshot( + String status, + String source, + BigDecimal latitude, + BigDecimal longitude, + LocalDateTime obtainedAt, + LocalDateTime validUntil + ) { + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveProviderConfiguration.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveProviderConfiguration.java new file mode 100644 index 00000000..d093a7b8 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveProviderConfiguration.java @@ -0,0 +1,127 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; +import org.apache.hc.client5.http.DnsResolver; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +/** Test-only TCP tunnel: the HTTPS URI, SNI and normal certificate verification stay unchanged. */ +@TestConfiguration(proxyBeanMethods = false) +class MapPlacesLiveProviderConfiguration { + + @Bean + @Primary + GooglePlacesProperties liveGooglePlacesProperties() { + if (!Boolean.getBoolean("map.live.enabled")) { + throw new IllegalStateException("Live Places provider requires explicit opt-in"); + } + return new GooglePlacesProperties(true, + System.getenv("HASHI_MAP_GOOGLEGEOCODING_APIKEY"), + Duration.ofSeconds(5), Duration.ofSeconds(15), 65_536); + } + + @Bean + @Primary + PlacesProvider livePlacesProvider( + @Qualifier("liveGooglePlacesProperties") GooglePlacesProperties properties, + LiveRequestCounter requests) { + var delegate = new GooglePlacesProvider(properties, UnaryOperator.identity(), new TunnelDnsResolver()); + return new PlacesProvider() { + @Override + public PlacesSearchResult search(String query) { + requests.acquireSearch(); + return delegate.search(query); + } + + @Override + public PlaceDetailsResult details(String placeId) { + requests.acquireDetails(placeId); + return delegate.details(placeId); + } + }; + } + + @Bean + LiveRequestCounter livePlacesRequestCounter() { + return new LiveRequestCounter(); + } + + static final class LiveRequestCounter { + private final AtomicInteger searchRequests = new AtomicInteger(); + private final AtomicInteger detailRequests = new AtomicInteger(); + private final AtomicReference selectedPlaceId = new AtomicReference<>(); + + int acquireSearch() { + if (!searchRequests.compareAndSet(0, 1)) { + throw new IllegalStateException("Only one live Places search request is allowed"); + } + return totalCount(); + } + + int acquireDetails(String placeId) { + if (searchRequests.get() != 1) { + throw new IllegalStateException("Live Places details requires the search request first"); + } + if (placeId == null || placeId.isBlank()) { + throw new IllegalStateException("Live Places details requires a selected place ID"); + } + String selected = selectedPlaceId.get(); + if (selected == null) { + if (!selectedPlaceId.compareAndSet(null, placeId)) { + selected = selectedPlaceId.get(); + } + } + if (selected != null && !selected.equals(placeId)) { + throw new IllegalStateException("Live Places refresh must use the selected place ID"); + } + while (true) { + int current = detailRequests.get(); + if (current >= 2) { + throw new IllegalStateException("Only two live Places details requests are allowed"); + } + if (detailRequests.compareAndSet(current, current + 1)) { + return totalCount(); + } + } + } + + int searchCount() { + return searchRequests.get(); + } + + int detailCount() { + return detailRequests.get(); + } + + int totalCount() { + return searchCount() + detailCount(); + } + } + + static final class TunnelDnsResolver implements DnsResolver { + private void checkHost(String host) throws UnknownHostException { + if (!"places.googleapis.com".equals(host)) { + throw new UnknownHostException("Unexpected live-test host"); + } + } + + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + checkHost(host); + return new InetAddress[]{InetAddress.getByAddress(new byte[]{127, 0, 0, 1})}; + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + checkHost(host); + return host; + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveProviderConfigurationTest.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveProviderConfigurationTest.java new file mode 100644 index 00000000..343291a5 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/MapPlacesLiveProviderConfigurationTest.java @@ -0,0 +1,80 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.UnknownHostException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; + +@Isolated("Changes the live-test opt-in property without invoking Google") +class MapPlacesLiveProviderConfigurationTest { + + @Test + void 명시적_실행동의_없이는_실제_provider를_생성하지_않는다() { + String previous = System.getProperty("map.live.enabled"); + try { + System.clearProperty("map.live.enabled"); + assertThatThrownBy(() -> new MapPlacesLiveProviderConfiguration().liveGooglePlacesProperties()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Live Places provider requires explicit opt-in"); + } finally { + if (previous == null) { + System.clearProperty("map.live.enabled"); + } else { + System.setProperty("map.live.enabled", previous); + } + } + } + + @Test + void 실제_provider_호출은_검색한번과_같은장소_상세두번만_허용한다() { + var requests = new MapPlacesLiveProviderConfiguration.LiveRequestCounter(); + + assertThat(requests.acquireSearch()).isEqualTo(1); + assertThat(requests.acquireDetails("place-one")).isEqualTo(2); + assertThat(requests.acquireDetails("place-one")).isEqualTo(3); + assertThat(requests.searchCount()).isEqualTo(1); + assertThat(requests.detailCount()).isEqualTo(2); + assertThat(requests.totalCount()).isEqualTo(3); + assertThatThrownBy(requests::acquireSearch) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Only one live Places search request is allowed"); + assertThatThrownBy(() -> requests.acquireDetails("place-one")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Only two live Places details requests are allowed"); + } + + @Test + void 검색에서_고른장소와_다른_ID로_갱신하지_않는다() { + var requests = new MapPlacesLiveProviderConfiguration.LiveRequestCounter(); + + requests.acquireSearch(); + requests.acquireDetails("place-one"); + assertThatThrownBy(() -> requests.acquireDetails("place-two")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Live Places refresh must use the selected place ID"); + assertThat(requests.totalCount()).isEqualTo(2); + } + + @Test + void 검색전에_상세호출을_허용하지_않는다() { + var requests = new MapPlacesLiveProviderConfiguration.LiveRequestCounter(); + + assertThatThrownBy(() -> requests.acquireDetails("place-one")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Live Places details requires the search request first"); + assertThat(requests.totalCount()).isZero(); + } + + @Test + void 테스트_DNS는_Places_Google호스트만_loopback으로_연결한다() throws Exception { + var resolver = new MapPlacesLiveProviderConfiguration.TunnelDnsResolver(); + + assertThat(resolver.resolve("places.googleapis.com")[0].getHostAddress()).isEqualTo("127.0.0.1"); + assertThat(resolver.resolveCanonicalHostname("places.googleapis.com")).isEqualTo("places.googleapis.com"); + assertThatThrownBy(() -> resolver.resolve("other.example")).isInstanceOf(UnknownHostException.class); + assertThatThrownBy(() -> resolver.resolveCanonicalHostname("other.example")) + .isInstanceOf(UnknownHostException.class); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/internal/map/places/PlacesFixtures.java b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/PlacesFixtures.java new file mode 100644 index 00000000..b8fd6d6d --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/internal/map/places/PlacesFixtures.java @@ -0,0 +1,25 @@ +package org.sopt.hashi.restaurant.internal.map.places; + +final class PlacesFixtures { + + static final String API_KEY = "synthetic-server-key"; + static final String QUERY = "東京都 テスト + & # / % ?= 店"; + static final String PLACE_ID = "ChIJ_synthetic-place-1"; + static final String CANDIDATE = """ + {"id":"ChIJ_synthetic-place-1", + "displayName":{"text":"Synthetic Restaurant","languageCode":"en"}, + "formattedAddress":"1-2-3 Test, Chiyoda City, Tokyo", + "location":{"latitude":35.12345678901234567,"longitude":139.76543210987654321}, + "addressComponents":[ + {"longText":"Tokyo","shortText":"Tokyo","types":["administrative_area_level_1","political"]}, + {"longText":"Japan","shortText":"JP","types":["country","political"]}], + "types":["restaurant","food"], + "businessStatus":"OPERATIONAL", + "attributions":[{"provider":"Synthetic Provider","providerUri":"https://provider.invalid/info"}], + "googleMapsUri":"https://maps.google.com/?cid=synthetic"} + """; + static final String SEARCH_SUCCESS = "{\"places\":[" + CANDIDATE + "]}"; + + private PlacesFixtures() { + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceConfigurationTest.java b/src/test/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceConfigurationTest.java new file mode 100644 index 00000000..457be4f8 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceConfigurationTest.java @@ -0,0 +1,88 @@ +package org.sopt.hashi.restaurant.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.internal.map.LocationJobProperties; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionService; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class LocationMaintenanceConfigurationTest { + @Test + void 기본설정은_확인모드이고_모든변경명령은_명시동의를_요구한다() { + new ApplicationContextRunner().withUserConfiguration(LocationMaintenanceConfiguration.class) + .run(context -> { + var options = context.getBean(LocationMaintenanceProperties.class); + assertThat(options.command()).isEqualTo(LocationMaintenanceProperties.Command.DRY_RUN); + assertThat(options.retentionEnabled()).isFalse(); + assertThat(options.execute()).isFalse(); + var runner = new LocationMaintenanceRunner(null, null, null); + for (var command : LocationMaintenanceProperties.Command.values()) { + if (command == LocationMaintenanceProperties.Command.DRY_RUN + || command == LocationMaintenanceProperties.Command.STATUS) { continue; } + assertThatThrownBy(() -> runner.execute(options(command, false, null, null, null))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("execute=true"); + } + }); + } + + @Test + void 호출상한계산의_전제인_worker하드최대여덟시도를_검증한다() { + assertThat(LocationMaintenanceProperties.CALLS_PER_JOB).isEqualTo(8); + assertThatThrownBy(() -> new LocationJobProperties(false, null, null, null, null, null, 9)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 기한전여유시간과_한도와_고정범위를_검증한다() { + assertThatThrownBy(() -> options(LocationMaintenanceProperties.Command.DRY_RUN, false, + Duration.ofMinutes(1), Duration.ofMinutes(1), Duration.ofMinutes(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> options(LocationMaintenanceProperties.Command.DRY_RUN, false, + Duration.ofDays(31), null, null)).isInstanceOf(IllegalArgumentException.class); + var start = options(LocationMaintenanceProperties.Command.START, true, null, null, null); + assertThatThrownBy(start::requiredUpperId).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 갱신기간의_마이크로초미만_입력은_설정바인딩에서_거부한다() { + new ApplicationContextRunner().withUserConfiguration(LocationMaintenanceConfiguration.class) + .withPropertyValues("hashi.map.maintenance.command=START", + "hashi.map.maintenance.refresh-ahead=PT3.000000001S", + "hashi.map.maintenance.purge-ahead=2s", "hashi.map.maintenance.poll-delay=1s") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseMessage("refresh-ahead must use whole microseconds"); + }); + } + + @Test + void 정리명령은_동의확인후_영구정리옵션으로_전달한다() { + var service = mock(LocationRetentionService.class); + var expected = new LocationRetentionProperties(25, 3, Duration.ofDays(2), Duration.ofHours(2), + true, Duration.ofMinutes(2)); + var report = new LocationRetentionService.Report(Instant.now(), 0, 0, 0, 0); + when(service.purge(expected)).thenReturn(report); + var options = new LocationMaintenanceProperties(LocationMaintenanceProperties.Command.PURGE, null, + true, null, 99, 100L, 25, 3, null, null, Duration.ofDays(2), Duration.ofHours(2), + true, Duration.ofMinutes(2)); + + assertThat(new LocationMaintenanceRunner(null, null, service).execute(options)).isSameAs(report); + verify(service).purge(expected); + } + + private static LocationMaintenanceProperties options(LocationMaintenanceProperties.Command command, + boolean execute, Duration refresh, Duration purge, Duration poll) { + return new LocationMaintenanceProperties(command, null, execute, UUID.randomUUID(), 0, null, + null, null, null, null, refresh, purge, false, poll); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceMySqlTest.java b/src/test/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceMySqlTest.java new file mode 100644 index 00000000..39dd6558 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/migration/LocationMaintenanceMySqlTest.java @@ -0,0 +1,1124 @@ +package org.sopt.hashi.restaurant.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Logger; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManagerFactory; +import java.math.BigDecimal; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; +import org.slf4j.LoggerFactory; +import org.sopt.hashi.config.JpaAuditingConfig; +import org.sopt.hashi.config.TimeConfig; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.domain.PriceCurrency; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantLocationAttribution; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJob; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantLocationStatus; +import org.sopt.hashi.restaurant.domain.RestaurantPlaceType; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate; +import org.sopt.hashi.restaurant.internal.map.GeocodingProvider; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult; +import org.sopt.hashi.restaurant.internal.map.LocationJobProperties; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionReader; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionScheduler; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionService; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionTransactions; +import org.sopt.hashi.restaurant.internal.map.RestaurantLocationWorker; +import org.sopt.hashi.restaurant.internal.map.places.GooglePlacesProperties; +import org.sopt.hashi.restaurant.internal.map.places.PlaceDetailsResult; +import org.sopt.hashi.restaurant.internal.map.places.PlacesProvider; +import org.sopt.hashi.restaurant.internal.map.places.PlacesSearchResult; +import org.sopt.hashi.restaurant.migration.LocationMaintenanceProperties.Command; +import org.sopt.hashi.restaurant.migration.LocationMaintenanceProperties.Mode; +import org.sopt.hashi.restaurant.service.LocationAdoptionPolicy; +import org.sopt.hashi.restaurant.service.LocationJobTransactions; +import org.sopt.hashi.restaurant.service.LocationRetryPolicy; +import org.sopt.hashi.restaurant.service.PlacesCandidatePolicy; +import org.sopt.hashi.restaurant.service.RestaurantLocationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({JpaAuditingConfig.class, LocationRetentionReader.class, + LocationMaintenanceReader.class, LocationMaintenanceStore.class, LocationMaintenanceTransactions.class, + LocationMaintenanceInspection.class, LocationMaintenanceRunner.class, LocationRetentionService.class, + LocationRetentionTransactions.class, RestaurantLocationService.class, LocationJobTransactions.class, + RestaurantLocationWorker.class, LocationRetryPolicy.class, LocationAdoptionPolicy.class, + PlacesCandidatePolicy.class, + TimeConfig.class, LocationMaintenanceMySqlTest.Fixtures.class}) +@TestPropertySource(properties = {"spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true", + "spring.jpa.properties.hibernate.generate_statistics=true"}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Isolated("Verifies JVM UTC against a JDBC Asia/Seoul session") +class LocationMaintenanceMySqlTest { + private static final String ADDRESS = "東京都試験区架空町1丁目2番3号"; + private static TimeZone originalZone; + + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("location_maintenance").withUsername("hashi").withPassword("hashi") + .withCommand("--log-bin-trust-function-creators=1") + .withUrlParam("connectionTimeZone", "Asia/Seoul") + .withUrlParam("forceConnectionTimeZoneToSession", "true"); + + @Autowired RestaurantRepository restaurants; + @Autowired RestaurantLocationJobRepository jobs; + @Autowired RestaurantLocationService locations; + @Autowired LocationMaintenanceReader reader; + @Autowired LocationRetentionReader retentionReader; + @Autowired LocationMaintenanceStore store; + @Autowired LocationMaintenanceInspection inspection; + @Autowired LocationMaintenanceTransactions maintenance; + @Autowired LocationMaintenanceRunner runner; + @Autowired LocationRetentionService retention; + @Autowired LocationRetentionTransactions purge; + @Autowired LocationJobTransactions workerTransactions; + @Autowired RestaurantLocationWorker worker; + @Autowired JdbcTemplate jdbc; + @Autowired TransactionTemplate tx; + @Autowired FakeProvider provider; + @Autowired EntityManagerFactory factory; + long after; + + @BeforeAll + static void utcJvm() { + originalZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + + @AfterAll + static void restoreJvm() { + TimeZone.setDefault(originalZone); + } + + @BeforeEach + void reset() { + jdbc.execute("DROP TRIGGER IF EXISTS fail_location_checkpoint"); + jdbc.update("DELETE FROM restaurant_location_maintenance_job"); + jdbc.update("DELETE FROM restaurant_location_maintenance_run"); + jdbc.update("DELETE FROM restaurant_location_job"); + // Keep prior parent/child fixture rows; make them ineligible for this case's retention scan. + jdbc.update(""" + UPDATE restaurant_location SET source='ADMIN', google_place_id=NULL, places_attributions=NULL + WHERE source IS NOT NULL + """); + jdbc.update(""" + UPDATE restaurant_geocoding_budget SET enabled=true, daily_limit=1000, max_concurrent=4, + reserved_calls=0, budget_day=NULL, blocked_until=NULL WHERE id=1 + """); + jdbc.update(""" + UPDATE restaurant_places_budget SET enabled=false, daily_limit=100, minute_limit=10, + budget_day=NULL, daily_used=0, minute_window_start=NULL, minute_used=0, + blocked_until=NULL + """); + after = reader.upperId(); + provider.calls.set(0); + provider.answer.set(address -> new GeocodingResult.Candidates(List.of(candidate()))); + } + + @Test + void 확인모드는_범위와_부분집계를_명시하고_쓰기와_provider호출과_N플러스1이_없다() { + long missing = original(); + long valid = ready(Duration.ofDays(3), false); + ready(Duration.ofHours(3), false); + ready(Duration.ofSeconds(-1), false); + long pending = original(); + enqueue(pending); + long review = original(); + enqueue(review); + provider.answer.set(address -> new GeocodingResult.NoResults()); + worker.process(target(review)); + long failed = original(); + enqueue(failed); + provider.answer.set(address -> new GeocodingResult.Failure(GeocodingResult.FailureKind.ACCESS_DENIED, null)); + worker.process(target(failed)); + long retry = original(); + enqueue(retry); + provider.answer.set(address -> new GeocodingResult.Failure(GeocodingResult.FailureKind.TRANSIENT_ERROR, null)); + worker.process(target(retry)); + ready(Duration.ofMinutes(20), true); + var stats = factory.unwrap(SessionFactory.class).getStatistics(); + stats.clear(); + int callsBefore = provider.calls.get(); + var report = inspection.inspect(options(Command.DRY_RUN, Mode.BACKFILL, false, null, reader.upperId(), 100, 800)); + assertThat(report.categories()).containsEntry("UNRESOLVED", 1L).containsEntry("VALID", 1L) + .containsEntry("REFRESH_DUE", 1L).containsEntry("EXPIRED", 1L) + .containsEntry("PENDING", 1L).containsEntry("REVIEW_REQUIRED", 1L) + .containsEntry("FAILED", 1L).containsEntry("DELETED", 1L).containsEntry("RETRY_WAIT", 1L); + assertThat(report.partial()).isFalse(); + assertThat(report.inspected()).isEqualTo(9); + assertThat(report.googlePurgeDueInInspectedRows()).isEqualTo(2); + assertThat(report.deletedGoogleInInspectedRows()).isEqualTo(1); + assertThat(report.registrationEstimateInInspectedRows()).isEqualTo(1); + assertThat(report.reservedCallCeilingForEstimate()).isEqualTo(8); + assertThat(provider.calls.get()).isEqualTo(callsBefore); + assertThat(stats.getEntityInsertCount() + stats.getEntityUpdateCount() + stats.getEntityDeleteCount()).isZero(); + assertThat(stats.getEntityLoadCount()).isZero(); + assertThat(count("restaurant_location_maintenance_run")).isZero(); + assertThat(report.toString()).doesNotContain(ADDRESS, "latitude", "longitude"); + var limited = new LocationMaintenanceProperties(Command.DRY_RUN, Mode.BACKFILL, false, null, after, + reader.upperId(), 2, 1, 10, 80, null, null, false, null); + var partial = inspection.inspect(limited); + assertThat(partial.partial()).isTrue(); + assertThat(partial.inspected()).isEqualTo(2); + assertThat(partial.lastInspectedId()).isEqualTo(valid).isGreaterThan(missing); + } + + @Test + void 고정범위와_checkpoint는_같은run의_재시작과_새로생긴식당에도_유지된다() { + original(); + long upper = original(); + var options = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), upper, 10, 80); + maintenance.start(options); + assertThat(maintenance.advance(options.runId())).isTrue(); + original(); + maintenance.start(options); + drain(options.runId()); + var status = maintenance.status(options.runId()); + assertThat(status.registration().state()).isEqualTo("SCANNED"); + assertThat(status.registration().cursorId()).isEqualTo(upper); + assertThat(status.registration().enqueued()).isEqualTo(2); + assertThat(status.completion().jobStates()).containsEntry("PENDING", 2L); + assertThat(status.completion().currentlyUsableLocations()).isZero(); + worker.runOnce(); + assertThat(maintenance.status(options.runId()).completion().currentlyUsableLocations()).isEqualTo(2); + assertThat(provider.calls.get()).isEqualTo(2); + var changed = options(Command.START, Mode.BACKFILL, true, options.runId(), reader.upperId(), 10, 80); + assertThatThrownBy(() -> maintenance.start(changed)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 마이크로초_갱신기간은_최초와_반복_START에서_정확하게_유지된다() { + long upper = original(); + Duration refresh = Duration.parse("PT3.000001S"); + var options = new LocationMaintenanceProperties(Command.START, Mode.BACKFILL, true, + UUID.randomUUID(), after, upper, 50, 2, 10, 80, refresh, Duration.ofSeconds(2), + false, Duration.ofSeconds(1)); + maintenance.start(options); + var initial = maintenance.status(options.runId()).registration(); + assertThat(Duration.between(initial.asOf(), initial.refreshBefore())).isEqualTo(refresh); + assertThat(maintenance.advance(options.runId())).isTrue(); + + maintenance.start(options); + var repeated = maintenance.status(options.runId()).registration(); + assertThat(repeated.asOf()).isEqualTo(initial.asOf()); + assertThat(repeated.refreshBefore()).isEqualTo(initial.refreshBefore()); + assertThat(repeated.enqueued()).isEqualTo(1); + assertThat(count("restaurant_location_job")).isEqualTo(1); + } + + @Test + void 두실행과_두진행자가_경쟁해도_현재주소작업과_누적상한은_중복되지_않는다() throws Exception { + for (int i = 0; i < 8; i++) { original(); } + long upper = reader.upperId(); + var first = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), upper, 3, 16); + var second = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), upper, 3, 16); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var a = executor.submit(() -> { await(start); runner.execute(first); }); + var b = executor.submit(() -> { await(start); runner.execute(first); }); + var c = executor.submit(() -> { await(start); runner.execute(second); }); + start.countDown(); + a.get(30, TimeUnit.SECONDS); + b.get(30, TimeUnit.SECONDS); + c.get(30, TimeUnit.SECONDS); + } + assertThat(maintenance.status(first.runId()).registration().enqueued()).isEqualTo(2); + assertThat(maintenance.status(second.runId()).registration().enqueued()).isEqualTo(2); + assertThat(count("restaurant_location_job")).isEqualTo(4); + assertThat(jdbc.queryForObject(""" + SELECT COUNT(DISTINCT restaurant_id) FROM restaurant_location_job + """, Long.class)).isEqualTo(4); + assertThat(maintenance.status(first.runId()).registration().state()).isEqualTo("LIMIT_REACHED"); + } + + @Test + void 작은예산과_등록상한을_재개로_늘릴수_없고_실제여덟번시도이후_자동재등록하지_않는다() { + long id = original(); + var small = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), id, 10, 7); + runner.execute(small); + assertThat(maintenance.status(small.runId()).registration().enqueued()).isZero(); + var one = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), id, 10, 8); + runner.execute(one); + provider.answer.set(address -> new GeocodingResult.Failure(GeocodingResult.FailureKind.TRANSIENT_ERROR, null)); + for (int attempt = 1; attempt <= 8; attempt++) { + worker.process(target(id)); + if (attempt < 8) { makeDue(id); } + } + assertThat(provider.calls.get()).isEqualTo(8); + assertThat(locations.get(id).locationStatus()).isEqualTo("FAILED"); + worker.process(target(id)); + maintenance.resume(one.runId()); + drain(one.runId()); + runner.execute(options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), id, 10, 80)); + runner.execute(options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80)); + assertThat(provider.calls.get()).isEqualTo(8); + assertThat(count("restaurant_location_job")).isEqualTo(1); + assertThat(maintenance.status(one.runId()).completion().reservedAttempts()).isEqualTo(8); + // Administrator retry is a new, unlinked job and remains subject to the shared daily budget. + locations.retry(id, 1); + assertThat(count("restaurant_location_job")).isEqualTo(2); + assertThat(maintenance.status(one.runId()).registration().enqueued()).isEqualTo(1); + assertThat(maintenance.status(one.runId()).completion().reservedAttempts()).isEqualTo(8); + } + + @Test + void checkpoint_쓰기실패는_위치와_job과_연결을_함께롤백하고_같은지점에서_재개한다() { + long id = original(); + var options = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), id, 10, 80); + maintenance.start(options); + jdbc.execute(""" + CREATE TRIGGER fail_location_checkpoint BEFORE UPDATE ON restaurant_location_maintenance_run + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic checkpoint failure' + """); + try { + assertThatThrownBy(() -> maintenance.advance(options.runId())).isInstanceOf(RuntimeException.class); + } finally { + jdbc.execute("DROP TRIGGER fail_location_checkpoint"); + } + assertThat(locations.get(id).locationStatus()).isEqualTo("UNRESOLVED"); + assertThat(count("restaurant_location_job")).isZero(); + assertThat(count("restaurant_location_maintenance_job")).isZero(); + assertThat(maintenance.status(options.runId()).registration().cursorId()).isEqualTo(after); + drain(options.runId()); + assertThat(maintenance.status(options.runId()).registration().enqueued()).isEqualTo(1); + } + + @Test + void 중단commit_뒤에는_등록하지않고_진행HTTP는_계속되며_전역disable은_새claim만_막는다() throws Exception { + long first = original(); + long second = original(); + var options = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), second, 10, 80); + maintenance.start(options); + maintenance.advance(options.runId()); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + provider.answer.set(address -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + entered.countDown(); + await(release); + return new GeocodingResult.Candidates(List.of(candidate())); + }); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var running = executor.submit(() -> worker.process(target(first))); + assertThat(entered.await(15, TimeUnit.SECONDS)).isTrue(); + try { + executor.submit(() -> maintenance.stop(options.runId())).get(5, TimeUnit.SECONDS); + jdbc.update("UPDATE restaurant_geocoding_budget SET enabled=false WHERE id=1"); + assertThat(maintenance.advance(options.runId())).isFalse(); + // START cannot accidentally undo STOP. + maintenance.start(options); + assertThat(maintenance.advance(options.runId())).isFalse(); + maintenance.resume(options.runId()); + maintenance.advance(options.runId()); + worker.process(target(second)); + assertThat(provider.calls.get()).isEqualTo(1); + } finally { + release.countDown(); + } + running.get(15, TimeUnit.SECONDS); + } + assertThat(locations.get(first).locationStatus()).isEqualTo("READY"); + assertThat(locations.get(second).locationStatus()).isEqualTo("PENDING"); + } + + @Test + void 중단은_경합중인_한건의_commit을_기다리고_그이후_등록은_차단한다() throws Exception { + long first = original(); + long upper = original(); + var options = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), upper, 10, 80); + maintenance.start(options); + CountDownLatch parentLocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var holding = executor.submit(() -> tx.executeWithoutResult(status -> { + restaurants.findByIdForUpdate(first).orElseThrow(); + parentLocked.countDown(); + await(release); + })); + assertThat(parentLocked.await(10, TimeUnit.SECONDS)).isTrue(); + var advance = executor.submit(() -> maintenance.advance(options.runId())); + // Observe the actual MySQL row-lock wait before submitting STOP. + waitForDatabaseLockWait(); + var stop = executor.submit(() -> maintenance.stop(options.runId())); + release.countDown(); + holding.get(15, TimeUnit.SECONDS); + advance.get(15, TimeUnit.SECONDS); + stop.get(15, TimeUnit.SECONDS); + } finally { + release.countDown(); + } + assertThat(maintenance.advance(options.runId())).isFalse(); + assertThat(maintenance.status(options.runId()).registration().enqueued()).isEqualTo(1); + } + + @Test + void 두worker와_lease복구도_run의_기존job과_누적attempt를_유지한다() throws Exception { + long id = original(); + var options = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), id, 10, 80); + runner.execute(options); + var target = target(id); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var a = executor.submit(() -> { await(start); return workerTransactions.claim(target); }); + var b = executor.submit(() -> { await(start); return workerTransactions.claim(target); }); + start.countDown(); + var claims = List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS)); + assertThat(claims.stream().filter(java.util.Optional::isPresent)).hasSize(1); + var old = claims.stream().flatMap(java.util.Optional::stream).findFirst().orElseThrow(); + jdbc.update(""" + UPDATE restaurant_location_job SET lease_until=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND, + reserved_until=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE id=? + """, target.jobId()); + worker.process(target); + assertThat(workerTransactions.complete(old, new LocationJobTransactions.Outcome(point(), null, null))) + .isFalse(); + } + assertThat(maintenance.status(options.runId()).completion().reservedAttempts()).isEqualTo(2); + assertThat(maintenance.status(options.runId()).completion().currentlyUsableLocations()).isEqualTo(1); + assertThat(count("restaurant_location_maintenance_job")).isEqualTo(1); + } + + @Test + void 잠금대기중_삭제와_주소수정이_일어나면_최신상태를_다시확인한다() throws Exception { + long deleted = original(); + long updated = original(); + var options = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), updated, 10, 80); + maintenance.start(options); + CountDownLatch locked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var changing = executor.submit(() -> tx.executeWithoutResult(status -> { + var restaurant = restaurants.findByIdForUpdate(deleted).orElseThrow(); + restaurant.softDelete(); + locked.countDown(); + await(release); + })); + assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue(); + var scanning = executor.submit(() -> maintenance.advance(options.runId())); + waitForDatabaseLockWait(); + release.countDown(); + changing.get(15, TimeUnit.SECONDS); + scanning.get(15, TimeUnit.SECONDS); + } finally { + release.countDown(); + } + tx.executeWithoutResult(status -> { + var restaurant = restaurants.findByIdForUpdate(updated).orElseThrow(); + restaurant.updateBasicInfo(null, null, null, null, ADDRESS + "別館", null, null, null, null, null, null, null); + locations.enqueue(restaurant); + }); + drain(options.runId()); + assertThat(maintenance.status(options.runId()).registration().enqueued()).isZero(); + assertThat(count("restaurant_location_job")).isEqualTo(1); + } + + @Test + void 갱신은_유효좌표를_유지하고_실패나_재실행으로_옛수명을_연장하지않는다() { + long id = ready(Duration.ofHours(3), false); + var options = options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80); + runner.execute(options); + var until = locations.get(id).validUntil(); + tx.executeWithoutResult(status -> assertThat(restaurants.findById(id).orElseThrow() + .hasUsableMapLocation(clock(reader.now()))).isTrue()); + provider.answer.set(address -> new GeocodingResult.NoResults()); + worker.process(target(id)); + assertThat(locations.get(id).locationStatus()).isEqualTo("REVIEW_REQUIRED"); + assertThat(locations.get(id).validUntil()).isEqualTo(until); + runner.execute(options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80)); + assertThat(count("restaurant_location_job")).isEqualTo(1); + assertThat(maintenance.status(options.runId()).completion().currentlyUsableLocations()).isEqualTo(1); + } + + @Test + void provider와_예산이_꺼져도_삭제식당까지_기한전에_물리정리하고_원본과자식을_보존한다() { + long active = ready(Duration.ofMinutes(20), false); + long deleted = ready(Duration.ofMinutes(10), true); + long future = ready(Duration.ofDays(3), false); + jdbc.update("UPDATE restaurant_geocoding_budget SET enabled=false WHERE id=1"); + var result = retention.purge( + options(Command.PURGE, Mode.BACKFILL, true, null, null, 10, 80).retentionOptions()); + assertThat(result.purged()).isEqualTo(2); + assertThat(result.dueRemaining()).isZero(); + assertCleared(active, "REVIEW_REQUIRED"); + assertCleared(deleted, "REVIEW_REQUIRED"); + assertThat(locations.get(future).locationStatus()).isEqualTo("READY"); + assertThat(provider.calls.get()).isZero(); + tx.executeWithoutResult(status -> { + for (long id : List.of(active, deleted)) { + var restaurant = restaurants.findById(id).orElseThrow(); + assertThat(restaurant.getName()).isEqualTo("합성 식당"); + assertThat(restaurant.getAddress()).isEqualTo(ADDRESS); + assertThat(restaurant.getMapRegionId()).isEqualTo(42); + assertThat(restaurant.getHashtags()).containsExactly("보존"); + } + }); + } + + @Test + void 오래된정리snapshot은_주소수정과_새결과를_지우지않고_백업의만료값도_노출하지않는다() { + long id = ready(Duration.ofMinutes(20), false); + var old = retentionReader.purgeCandidates(reader.now().plusHours(1), 100).getFirst(); + tx.executeWithoutResult(status -> { + var restaurant = restaurants.findByIdForUpdate(id).orElseThrow(); + restaurant.updateBasicInfo(null, null, null, null, ADDRESS + "別館", null, null, null, null, null, null, null); + var location = restaurant.getLocation(); + var now = reader.now(); + restaurant.completeLocation(location.getAddressRevision(), location.getRequestId(), point(), + RestaurantLocationSource.GOOGLE_GEOCODING, now, now.plusDays(2), clock(now)); + }); + assertThat(purge.purge(old, Duration.ofHours(1))).isFalse(); + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + // Synthetic backup restore: reinsert expired calendar values, while the scheduler is stopped. + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id + SET l.obtained_at=UTC_TIMESTAMP(6)-INTERVAL 2 DAY, + l.valid_until=UTC_TIMESTAMP(6)-INTERVAL 1 DAY WHERE r.id=? + """, id); + tx.executeWithoutResult(status -> assertThat(restaurants.findById(id).orElseThrow() + .hasUsableMapLocation(clock(reader.now()))).isFalse()); + assertThat(retention.purge(options(Command.PURGE, Mode.BACKFILL, true, null, null, 10, 80) + .retentionOptions()).purged()).isEqualTo(1); + assertCleared(id, "REVIEW_REQUIRED"); + } + + @Test + void JVM_UTC와_JDBC_서울에서도_기준시각과_갱신조건과_상태집계가_UTC를_유지한다() { + assertThat(TimeZone.getDefault().getID()).isEqualTo("UTC"); + assertThat(jdbc.queryForObject("SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), NOW())", Integer.class)) + .isEqualTo(9 * 3600); + long id = ready(Duration.ofHours(3), false); + var options = options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80); + maintenance.start(options); + var run = maintenance.status(options.runId()).registration(); + String raw = jdbc.queryForObject(""" + SELECT DATE_FORMAT(as_of, '%Y-%m-%d %H:%i:%s.%f') FROM restaurant_location_maintenance_run WHERE id=? + """, String.class, options.runId().toString()); + assertThat(raw).isEqualTo(LocationMaintenanceReader.sqlTime(run.asOf())); + assertThat(Duration.between(run.asOf(), reader.now()).abs()).isLessThan(Duration.ofSeconds(5)); + drain(options.runId()); + worker.process(target(id)); + assertThat(maintenance.status(options.runId()).completion().currentlyUsableLocations()).isEqualTo(1); + assertThat(retentionReader.purgeCandidates(reader.now().plusHours(1), 100)).isEmpty(); + } + + @Test + void 겹치는갱신run은_방금완료한_새결과를_다시등록하지않는다() { + long id = ready(Duration.ofHours(3), false); + var first = options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80); + var second = options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80); + maintenance.start(first); + maintenance.start(second); + drain(first.runId()); + worker.process(target(id)); + drain(second.runId()); + assertThat(maintenance.status(first.runId()).registration().enqueued()).isEqualTo(1); + assertThat(maintenance.status(second.runId()).registration().enqueued()).isZero(); + assertThat(count("restaurant_location_job")).isEqualTo(1); + // Default refresh window equals this synthetic worker's full 1-day lifetime: fail closed. + var later = options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80); + runner.execute(later); + assertThat(maintenance.status(later.runId()).registration().enqueued()).isZero(); + } + + @Test + void 정리쿼리는_UTC문자열경계의_1마이크로초를_구분한다() { + long id = ready(Duration.ofMinutes(20), false); + LocalDateTime boundary = reader.now().plusHours(1); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id + SET l.valid_until=? WHERE r.id=? + """, LocationMaintenanceReader.sqlTime(boundary), id); + assertThat(retentionReader.purgeCandidates(boundary.minusNanos(1000), 10)).isEmpty(); + assertThat(retentionReader.purgeCandidates(boundary, 10)).extracting(LocationRetentionReader.Candidate::restaurantId) + .containsExactly(id); + tx.executeWithoutResult(status -> { + var restaurant = restaurants.findById(id).orElseThrow(); + assertThat(restaurant.hasUsableMapLocation(clock(boundary.minusNanos(1000)))).isTrue(); + assertThat(restaurant.hasUsableMapLocation(clock(boundary))).isFalse(); + }); + } + + @Test + void 정리잠금대기중_새결과가저장돼도_오래된snapshot은_삭제하지않는다() throws Exception { + long id = ready(Duration.ofMinutes(20), false); + var old = retentionReader.purgeCandidates(reader.now().plusHours(1), 100).getFirst(); + CountDownLatch locked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var replacing = executor.submit(() -> tx.executeWithoutResult(status -> { + var restaurant = restaurants.findByIdForUpdate(id).orElseThrow(); + restaurant.refreshLocation(); + var now = reader.now(); + var location = restaurant.getLocation(); + restaurant.completeLocation(location.getAddressRevision(), location.getRequestId(), point(), + RestaurantLocationSource.GOOGLE_GEOCODING, now, now.plusDays(2), clock(now)); + locked.countDown(); + await(release); + })); + assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue(); + var purging = executor.submit(() -> purge.purge(old, Duration.ofHours(1))); + waitForDatabaseLockWait(); + release.countDown(); + replacing.get(15, TimeUnit.SECONDS); + assertThat(purging.get(15, TimeUnit.SECONDS)).isFalse(); + } finally { + release.countDown(); + } + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + } + + @Test + void 합성대량자료에서도_keyset조회수는_batch수에만_비례하고_EXPLAIN은_PK정렬읽기를_사용한다() throws Exception { + for (int i = 0; i < 120; i++) { original(); } + var options = new LocationMaintenanceProperties(Command.DRY_RUN, Mode.BACKFILL, false, null, after, + reader.upperId(), 25, 2, 10, 80, null, null, false, null); + inspection.inspect(options); // Warm the owned pool before counting server statements. + var root = rootJdbc(); + root.execute("SET GLOBAL general_log=OFF"); + root.execute("SET GLOBAL log_output='TABLE'"); + root.execute("TRUNCATE TABLE mysql.general_log"); + LocationMaintenanceInspection.Report report; + root.execute("SET GLOBAL general_log=ON"); + try { + report = inspection.inspect(options); + } finally { + root.execute("SET GLOBAL general_log=OFF"); + } + List selects = root.queryForList(""" + SELECT argument FROM mysql.general_log + WHERE user_host LIKE 'hashi[%' AND command_type='Query' + AND UPPER(argument) LIKE 'SELECT %' + """, String.class); + var businessSelects = selects.stream().filter(sql -> !sql.startsWith("SELECT @@")).toList(); + assertThat(report.inspected()).isEqualTo(50); + assertThat(report.partial()).isTrue(); + assertThat(businessSelects).hasSize(4); // DB clock + 2 pages + one lookahead; zero per-item SELECTs. + assertThat(selects.size() - businessSelects.size()).isLessThanOrEqualTo(1); // Driver session read-only check. + String plan = jdbc.queryForObject("EXPLAIN FORMAT=JSON " + LocationMaintenanceReader.PAGE_SQL, + String.class, after, reader.upperId(), 25); + var tablePlan = new ObjectMapper().readTree(plan).findValues("table").stream() + .filter(table -> "r".equals(table.path("table_name").asText())).findFirst().orElseThrow(); + assertThat(tablePlan.path("key").asText()).isEqualTo("PRIMARY"); + assertThat(tablePlan.path("access_type").asText()).isIn("range", "index"); + assertThat(plan).doesNotContain("\"using_filesort\": true"); + System.out.println("Synthetic inspection: rows=120 inspected=50 pages=2 application SELECTs=" + + businessSelects.size() + " total SELECTs=" + selects.size() + + " partial=true; EXPLAIN primary-key " + tablePlan.path("access_type").asText() + + ". Not an operational performance guarantee."); + } + + @Test + void 배포jar의_실제launcher는_SELECT전용계정으로_확인하고_새프로세스에서_재개한다() throws Exception { + long first = original(); + long upper = original(); + var root = rootJdbc(); + root.execute("CREATE USER IF NOT EXISTS 'maintenance_reader'@'%' IDENTIFIED BY 'fixture_only'"); + root.execute("GRANT SELECT ON location_maintenance.* TO 'maintenance_reader'@'%'"); + long migrations = count("flyway_schema_history"); + Logger rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + var originalLogLevel = rootLogger.getLevel(); + String output = runCli("maintenance_reader", "fixture_only", + "--hashi.map.maintenance.after-id=" + after, "--hashi.map.maintenance.upper-id=" + upper); + assertThat(output).contains("inspected=2", "partial=false").doesNotContain(ADDRESS, "latitude", "longitude"); + assertThat(count("flyway_schema_history")).isEqualTo(migrations); + assertThat(count("restaurant_location_maintenance_run")).isZero(); + assertThat(count("restaurant_location_job")).isZero(); + assertThat(provider.calls.get()).isZero(); + + UUID runId = UUID.randomUUID(); + runCli("hashi", "hashi", "--hashi.map.maintenance.command=START", + "--hashi.map.maintenance.execute=true", "--hashi.map.maintenance.run-id=" + runId, + "--hashi.map.maintenance.after-id=" + after, "--hashi.map.maintenance.upper-id=" + upper, + "--hashi.map.maintenance.batch-size=1", "--hashi.map.maintenance.max-batches=1"); + assertThat(maintenance.status(runId).registration().enqueued()).isEqualTo(1); + var firstAudit = locationAudit(first); + assertThat(firstAudit).hasSize(2).doesNotContainValue(null); + runCli("hashi", "hashi", "--hashi.map.maintenance.command=RESUME", + "--hashi.map.maintenance.execute=true", "--hashi.map.maintenance.run-id=" + runId); + assertThat(maintenance.status(runId).registration().enqueued()).isEqualTo(2); + assertThat(maintenance.status(runId).registration().state()).isEqualTo("SCANNED"); + assertThat(locationAudit(first).get("created_at")).isEqualTo(firstAudit.get("created_at")); + assertThat(locationAudit(upper)).hasSize(2).doesNotContainValue(null); + + // Inspect the same bootstrap without SpringApplication's JVM-global logging initialization. + new ApplicationContextRunner().withUserConfiguration(LocationMaintenanceCli.CliConfiguration.class) + .withPropertyValues("spring.profiles.active=location-maintenance-cli", + "spring.datasource.url=" + MYSQL.getJdbcUrl(), "spring.datasource.username=hashi", + "spring.datasource.password=hashi", "spring.jpa.hibernate.ddl-auto=validate") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(GeocodingProvider.class)).isEmpty(); + assertThat(context.getBeansOfType(RestaurantLocationWorker.class)).isEmpty(); + assertThat(context.getBeansOfType(LocationRetentionScheduler.class)).isEmpty(); + assertThat(context.getBeansOfType(org.flywaydb.core.Flyway.class)).isEmpty(); + }); + assertThat(rootLogger.getLevel()).isEqualTo(originalLogLevel); + } + + private Map locationAudit(long restaurantId) { + return jdbc.queryForMap(""" + SELECT l.created_at, l.updated_at FROM restaurant_location l + JOIN restaurant r ON r.location_id=l.id WHERE r.id=? + """, restaurantId); + } + + private String runCli(String username, String password, String... args) throws Exception { + String jar = System.getProperty("location.maintenance.jar"); + assertThat(jar).isNotBlank(); + List command = new ArrayList<>(List.of( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-Dloader.main=" + LocationMaintenanceCli.class.getName(), "-cp", jar, + "org.springframework.boot.loader.launch.PropertiesLauncher", + "--spring.datasource.url=" + MYSQL.getJdbcUrl(), "--spring.datasource.username=" + username, + "--spring.datasource.password=" + password, + "--logging.config=" + Path.of("src/test/resources/logback-maintenance-test.xml").toAbsolutePath())); + command.addAll(List.of(args)); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + try { + assertThat(process.waitFor(60, TimeUnit.SECONDS)).isTrue(); + String output = new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(process.exitValue()).as(output).isZero(); + return output; + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + } + } + } + + @Test + void 자동갱신은_3일전에_한번만등록하고_짧은수명과_실패작업을_반복등록하지않는다() { + long id = ready(Duration.ofDays(2), false); + long shortLived = ready(Duration.ofHours(3), false); + var policy = new org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties( + null, null, null, null, false, null); + assertThat(retention.refresh(policy)).isEqualTo(1); + var target = target(id); + provider.answer.set(address -> new GeocodingResult.Failure(GeocodingResult.FailureKind.TRANSIENT_ERROR, null)); + worker.process(target); + assertThat(locations.get(id).attempt()).isEqualTo(1); + var until = locations.get(id).validUntil(); + assertThat(retention.refresh(policy)).isZero(); + assertThat(target(id).jobId()).isEqualTo(target.jobId()); + assertThat(locations.get(id).attempt()).isEqualTo(1); + assertThat(locations.get(id).validUntil()).isEqualTo(until); + assertThat(locations.get(shortLived).locationStatus()).isEqualTo("READY"); + makeDue(id); + provider.answer.set(address -> new GeocodingResult.Candidates(List.of(candidate()))); + worker.process(target); + assertThat(retention.refresh(policy)).isZero(); + assertThat(count("restaurant_location_job")).isEqualTo(1); + } + + @Test + void 만료후_재시작도_READY최초작업을_등록하고_옛좌표만_정리한다() { + long id = ready(Duration.ofSeconds(-1), false); + jdbc.update("UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id " + + "SET l.obtained_at=UTC_TIMESTAMP(6)-INTERVAL 10 DAY WHERE r.id=?", id); + var policy = new org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties( + null, null, null, null, false, null); + assertThat(retention.refresh(policy)).isEqualTo(1); + var target = target(id); + assertThat(retention.purge(policy).purged()).isEqualTo(1); + assertCleared(id, "PENDING"); + worker.process(target); + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + assertThat(locations.get(id).attempt()).isEqualTo(1); + } + + @Test + void 기본정리는_만료전좌표를_유지하고_만료후에도_같은작업으로_재시도한다() { + long id = ready(Duration.ofHours(3), false); + runner.execute(options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80)); + var target = target(id); + provider.answer.set(address -> new GeocodingResult.Failure(GeocodingResult.FailureKind.TRANSIENT_ERROR, null)); + worker.process(target); + var policy = new org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties( + null, null, null, null, false, null); + assertThat(retention.purge(policy).purged()).isZero(); + jdbc.update("UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id " + + "SET l.valid_until=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE r.id=?", id); + assertThat(retention.purge(policy).purged()).isEqualTo(1); + assertCleared(id, "RETRY_WAIT"); + assertThat(target(id).jobId()).isEqualTo(target.jobId()); + assertThat(locations.get(id).attempt()).isEqualTo(1); + makeDue(id); + provider.answer.set(address -> new GeocodingResult.Candidates(List.of(candidate()))); + worker.process(target); + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + assertThat(locations.get(id).attempt()).isEqualTo(2); + } + + @Test + void Places_위치는_같은_Place_ID의_DETAILS로_갱신하고_출처와_attribution을_유지한다() { + long id = readyPlaces(Duration.ofHours(3), false); + var options = options(Command.START, Mode.REFRESH, true, UUID.randomUUID(), id, 10, 80); + + runner.execute(options); + assertThat(maintenance.status(options.runId()).registration().enqueued()).isEqualTo(1); + RestaurantLocationJob job = currentJob(id); + assertThat(job.getOperation()).isEqualTo(RestaurantLocationJob.Operation.PLACE_DETAILS); + assertThat(job.getGooglePlaceId()).isEqualTo("place-" + id); + openDetailsBudget(); + var claim = workerTransactions.claim(new LocationJobTransactions.Target(id, job.getId())).orElseThrow(); + assertThat(workerTransactions.completePlaces(claim, new LocationJobTransactions.PlacesOutcome( + point(), "place-" + id, List.of(attribution()), null, null))).isTrue(); + + tx.executeWithoutResult(status -> { + var location = restaurants.findById(id).orElseThrow().getLocation(); + assertThat(location.getStatus()).isEqualTo(RestaurantLocationStatus.READY); + assertThat(location.getSource()).isEqualTo(RestaurantLocationSource.GOOGLE_PLACES); + assertThat(location.getGooglePlaceId()).isEqualTo("place-" + id); + assertThat(location.getPlacesAttributions()).containsExactly(attribution()); + assertThat(location.getCoordinates()).isEqualTo(point()); + }); + } + + @Test + void 만료된_Places_accepted_tuple을_지워도_DETAILS작업과_Place_ID는_재시도에_보존한다() { + long id = readyPlaces(Duration.ofSeconds(-1), false); + jdbc.update("UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id " + + "SET l.obtained_at=UTC_TIMESTAMP(6)-INTERVAL 10 DAY WHERE r.id=?", id); + var policy = new org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties( + null, null, null, null, false, null); + assertThat(retention.refresh(policy)).isEqualTo(1); + RestaurantLocationJob original = currentJob(id); + openDetailsBudget(); + var claim = workerTransactions.claim(new LocationJobTransactions.Target(id, original.getId())).orElseThrow(); + assertThat(workerTransactions.completePlaces(claim, + LocationJobTransactions.PlacesOutcome.failure(GeocodingResult.FailureKind.TRANSIENT_ERROR))).isTrue(); + + assertThat(retention.purge(policy).purged()).isEqualTo(1); + assertCleared(id, "RETRY_WAIT"); + RestaurantLocationJob preserved = jobs.findById(original.getId()).orElseThrow(); + assertThat(preserved.getOperation()).isEqualTo(RestaurantLocationJob.Operation.PLACE_DETAILS); + assertThat(preserved.getGooglePlaceId()).isEqualTo("place-" + id); + + locations.retry(id, locations.get(id).addressRevision()); + RestaurantLocationJob retry = currentJob(id); + assertThat(retry.getId()).isNotEqualTo(original.getId()); + assertThat(retry.getOperation()).isEqualTo(RestaurantLocationJob.Operation.PLACE_DETAILS); + assertThat(retry.getGooglePlaceId()).isEqualTo("place-" + id); + } + + @Test + void BACKFILL은_선택된_Places_DETAILS작업을_geocoding으로_덮어쓰지않는다() { + long id = original(); + tx.executeWithoutResult(status -> { + Restaurant restaurant = restaurants.findByIdForUpdate(id).orElseThrow(); + restaurant.requestLocationResolution(); + restaurant.selectPlaceForLocation(); + jobs.save(RestaurantLocationJob.pendingDetails(restaurant, "selected-place", reader.now())); + }); + + var options = options(Command.START, Mode.BACKFILL, true, UUID.randomUUID(), id, 10, 80); + runner.execute(options); + + assertThat(maintenance.status(options.runId()).registration().enqueued()).isZero(); + assertThat(count("restaurant_location_job")).isEqualTo(1); + RestaurantLocationJob selected = currentJob(id); + assertThat(selected.getOperation()).isEqualTo(RestaurantLocationJob.Operation.PLACE_DETAILS); + assertThat(selected.getGooglePlaceId()).isEqualTo("selected-place"); + assertThat(provider.calls.get()).isZero(); + } + + @Test + void 알림집계는_현재작업만_세고_주소나식당ID를_label에_노출하지않는다() { + long id = original(); + enqueue(id); + var target = target(id); + provider.answer.set(address -> new GeocodingResult.Failure(GeocodingResult.FailureKind.ACCESS_DENIED, 403)); + worker.process(target); + var registry = new io.micrometer.core.instrument.simple.SimpleMeterRegistry(); + var metrics = new org.sopt.hashi.restaurant.internal.map.LocationMaintenanceMetrics(jdbc, registry, + new Fixtures().jobs(), new GooglePlacesProperties(true, "test-key", null, null, null), + new org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties( + null, null, null, null, false, null)); + metrics.refresh(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "access_denied").gauge().value()).isEqualTo(1); + locations.retry(id, locations.get(id).addressRevision()); + metrics.refresh(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "access_denied").gauge().value()).isZero(); + jdbc.update("UPDATE restaurant_location_job SET attempt=3, failure_code='TIMEOUT', " + + "next_attempt_at=UTC_TIMESTAMP(6)-INTERVAL 16 MINUTE, " + + "updated_at=UTC_TIMESTAMP(6)-INTERVAL 16 MINUTE WHERE id=?", target(id).jobId()); + ready(Duration.ofHours(23), false); + metrics.refresh(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "retrying").gauge().value()).isEqualTo(1); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "stalled").gauge().value()).isEqualTo(1); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "expiry_soon").gauge().value()).isEqualTo(1); + jdbc.update("UPDATE restaurant_geocoding_budget SET blocked_until=UTC_TIMESTAMP(6)+INTERVAL 1 HOUR WHERE id=1"); + metrics.refresh(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "stalled").gauge().value()).isZero(); + jdbc.update("UPDATE restaurant_location_job SET operation='PLACE_DETAILS', google_place_id='metric-place' " + + "WHERE id=?", target(id).jobId()); + jdbc.update(""" + UPDATE restaurant_places_budget SET enabled=true, daily_limit=100, minute_limit=10, + budget_day=UTC_DATE(), daily_used=0, + minute_window_start=DATE_FORMAT(UTC_TIMESTAMP(6), '%Y-%m-%d %H:%i:00'), minute_used=0, + blocked_until=NULL WHERE operation='DETAILS' + """); + metrics.refresh(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "stalled").gauge().value()).isEqualTo(1); + jdbc.update(""" + UPDATE restaurant_places_budget SET minute_used=minute_limit WHERE operation='DETAILS' + """); + metrics.refresh(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "stalled").gauge().value()).isZero(); + var disabledRegistry = new io.micrometer.core.instrument.simple.SimpleMeterRegistry(); + var disabledMetrics = new org.sopt.hashi.restaurant.internal.map.LocationMaintenanceMetrics( + jdbc, disabledRegistry, new Fixtures().jobs(), + new GooglePlacesProperties(false, null, null, null, null), + new org.sopt.hashi.restaurant.internal.map.LocationRetentionProperties( + null, null, null, null, false, null)); + jdbc.update("UPDATE restaurant_places_budget SET minute_used=0 WHERE operation='DETAILS'"); + disabledMetrics.refresh(); + assertThat(disabledRegistry.get("hashi.map.maintenance.issues").tag("reason", "stalled") + .gauge().value()).isZero(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "refresh_window_invalid").gauge().value()).isEqualTo(1); + jdbc.update("UPDATE restaurant_location_job SET state='FAILED', failure_code='CONFIGURATION_ERROR' WHERE id=?", target(id).jobId()); + metrics.refresh(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "configuration_error").gauge().value()).isEqualTo(1); + jdbc.update("UPDATE restaurant_location_job SET failure_code='ATTEMPTS_EXHAUSTED' WHERE id=?", target(id).jobId()); + metrics.refresh(); + assertThat(registry.get("hashi.map.maintenance.issues").tag("reason", "attempts_exhausted").gauge().value()).isEqualTo(1); + assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags()) + .allSatisfy(tag -> assertThat(tag.getKey()).isEqualTo("reason"))); + } + + private LocationMaintenanceProperties options(Command command, Mode mode, boolean execute, UUID runId, + Long upper, int registrations, int calls) { + return new LocationMaintenanceProperties(command, mode, execute, runId, after, upper, 50, 2, + registrations, calls, Duration.ofDays(1), Duration.ofHours(1), false, null); + } + + private long original() { + return tx.execute(status -> { + Restaurant restaurant = Restaurant.create("합성 식당", "試験", "요약", "설명", ADDRESS, "표시 지역", + RestaurantGenre.SUSHI, "초밥", RestaurantPlaceType.RESTAURANT, PriceCurrency.JPY, + BigDecimal.ONE, BigDecimal.TEN); + restaurant.assignMapRegion(42L); + restaurant.replaceHashtags(List.of("보존")); + return restaurants.saveAndFlush(restaurant).getId(); + }); + } + + private long ready(Duration remaining, boolean deleted) { + long id = original(); + tx.executeWithoutResult(status -> { + var restaurant = restaurants.findByIdForUpdate(id).orElseThrow(); + restaurant.requestLocationResolution(); + var now = reader.now(); + var location = restaurant.getLocation(); + // Start with a valid result; an expired fixture is written below as a restored DB value. + restaurant.completeLocation(location.getAddressRevision(), location.getRequestId(), point(), + RestaurantLocationSource.GOOGLE_GEOCODING, now.minusDays(1), now.plusDays(4), clock(now)); + if (deleted) { restaurant.softDelete(); } + }); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id SET l.valid_until=? WHERE r.id=? + """, LocationMaintenanceReader.sqlTime(reader.now().plus(remaining)), id); + return id; + } + + private long readyPlaces(Duration remaining, boolean deleted) { + long id = original(); + tx.executeWithoutResult(status -> { + var restaurant = restaurants.findByIdForUpdate(id).orElseThrow(); + restaurant.requestLocationResolution(); + var now = reader.now(); + var location = restaurant.getLocation(); + restaurant.completePlacesLocation(location.getAddressRevision(), location.getRequestId(), point(), + "place-" + id, List.of(attribution()), now.minusDays(1), now.plusDays(4), clock(now)); + if (deleted) { + restaurant.softDelete(); + } + }); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id SET l.valid_until=? WHERE r.id=? + """, LocationMaintenanceReader.sqlTime(reader.now().plus(remaining)), id); + return id; + } + + private void enqueue(long id) { + tx.executeWithoutResult(status -> locations.enqueue(restaurants.findByIdForUpdate(id).orElseThrow())); + } + + private void drain(UUID id) { + for (int i = 0; i < 100 && maintenance.advance(id); i++) { /* fixture upper bound */ } + } + + private LocationJobTransactions.Target target(long id) { + return tx.execute(status -> { + var restaurant = restaurants.findById(id).orElseThrow(); + var job = jobs.findByRestaurantIdAndRequestId(id, restaurant.getLocation().getRequestId()).orElseThrow(); + return new LocationJobTransactions.Target(id, job.getId()); + }); + } + + private RestaurantLocationJob currentJob(long id) { + return tx.execute(status -> { + var location = restaurants.findById(id).orElseThrow().getLocation(); + return jobs.findByRestaurantIdAndRequestId(id, location.getRequestId()).orElseThrow(); + }); + } + + private void openDetailsBudget() { + jdbc.update(""" + UPDATE restaurant_places_budget SET enabled=true, daily_limit=100, minute_limit=10, + budget_day=NULL, daily_used=0, minute_window_start=NULL, minute_used=0, + blocked_until=NULL WHERE operation='DETAILS' + """); + } + + private void makeDue(long id) { + jdbc.update("UPDATE restaurant_location_job SET next_attempt_at=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE restaurant_id=?", id); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id + SET l.next_attempt_at=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE r.id=? + """, id); + } + + private void assertCleared(long id, String state) { + Map row = jdbc.queryForMap(""" + SELECT l.status, l.latitude, l.longitude, l.source, l.google_place_id, + l.places_attributions, l.obtained_at, l.valid_until + FROM restaurant r JOIN restaurant_location l ON l.id=r.location_id WHERE r.id=? + """, id); + assertThat(row.get("status")).isEqualTo(state); + for (String column : List.of("latitude", "longitude", "source", "google_place_id", + "places_attributions", "obtained_at", "valid_until")) { + assertThat(row.get(column)).as(column).isNull(); + } + } + + private long count(String table) { + return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); + } + + private JdbcTemplate rootJdbc() { + return new JdbcTemplate(new DriverManagerDataSource(MYSQL.getJdbcUrl(), "root", MYSQL.getPassword())); + } + + private void waitForDatabaseLockWait() throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + var root = rootJdbc(); + while (System.nanoTime() < deadline) { + if (root.queryForObject("SELECT COUNT(*) FROM performance_schema.data_lock_waits", Long.class) > 0) { + return; + } + Thread.sleep(20); + } + throw new AssertionError("Expected an actual MySQL row-lock wait"); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(20, TimeUnit.SECONDS)) { throw new AssertionError("Fixture latch timed out"); } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + } + + private static Clock clock(LocalDateTime now) { return Clock.fixed(now.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); } + private static MapCoordinates point() { return MapCoordinates.of(new BigDecimal("10.5"), new BigDecimal("20.5")); } + + private static RestaurantLocationAttribution attribution() { + return new RestaurantLocationAttribution("Google Maps", "https://maps.google.com/"); + } + + private static GeocodingCandidate candidate() { + var components = List.of(component("日本", "JP", "country"), + component("東京都", "東京都", "administrative_area_level_1"), component("試験区", "試験区", "locality"), + component("架空町", "架空町", "sublocality_level_1"), component("1丁目", "1丁目", "sublocality_level_2"), + component("2番", "2", "sublocality_level_3"), component("3号", "3", "sublocality_level_4")); + return new GeocodingCandidate(new BigDecimal("10.5"), new BigDecimal("20.5"), + GeocodingCandidate.Granularity.ROOFTOP, "JP", "東京都", components, List.of("street_address")); + } + + private static GeocodingCandidate.AddressComponent component(String value, String shortValue, String type) { + return new GeocodingCandidate.AddressComponent(value, shortValue, List.of(type)); + } + + static class FakeProvider implements GeocodingProvider { + final AtomicInteger calls = new AtomicInteger(); + final AtomicReference> answer = new AtomicReference<>(); + public GeocodingResult geocode(String address) { + calls.incrementAndGet(); + return answer.get().apply(address); + } + } + + @TestConfiguration(proxyBeanMethods = false) + static class Fixtures { + @Bean LocationJobProperties jobs() { + return new LocationJobProperties(true, Duration.ofDays(1), BigDecimal.TEN, new BigDecimal("11"), + new BigDecimal("20"), new BigDecimal("21"), 8); + } + @Bean FakeProvider provider() { return new FakeProvider(); } + @Bean PlacesProvider placesProvider() { + return new PlacesProvider() { + @Override + public PlacesSearchResult search(String query) { + return new PlacesSearchResult.Failure(GeocodingResult.FailureKind.DISABLED, null); + } + + @Override + public PlaceDetailsResult details(String placeId) { + return new PlaceDetailsResult.Failure(GeocodingResult.FailureKind.DISABLED, null); + } + }; + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/CollectionMapPortMySqlIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/CollectionMapPortMySqlIntegrationTest.java new file mode 100644 index 00000000..aa1a25a3 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/CollectionMapPortMySqlIntegrationTest.java @@ -0,0 +1,338 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.BDDMockito.given; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import jakarta.persistence.EntityManagerFactory; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.hibernate.SessionFactory; +import org.hibernate.resource.jdbc.spi.StatementInspector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.config.JpaAuditingConfig; +import org.sopt.hashi.config.TimeConfig; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.domain.PriceCurrency; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantMapQueryRepository; +import org.sopt.hashi.restaurant.domain.RestaurantPlaceType; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.internal.map.MapQueryFailureLogger; +import org.sopt.hashi.restaurant.internal.map.MapQueryProperties; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.storage.FileStorage; +import org.sopt.hashi.user.code.UserErrorCode; +import org.sopt.hashi.user.collection.domain.CollectionColor; +import org.sopt.hashi.user.collection.domain.CollectionVisibility; +import org.sopt.hashi.user.collection.domain.RestaurantCollection; +import org.sopt.hashi.user.collection.domain.RestaurantCollectionRepository; +import org.sopt.hashi.user.collection.domain.SavedRestaurantRepository; +import org.sopt.hashi.user.collection.dto.CreateRestaurantCollectionRequest; +import org.sopt.hashi.user.collection.dto.SaveRestaurantRequest; +import org.sopt.hashi.user.collection.service.CollectionMapQueryService; +import org.sopt.hashi.user.collection.service.RestaurantCollectionService; +import org.sopt.hashi.user.collection.service.RestaurantSaveSummaryService; +import org.sopt.hashi.user.domain.User; +import org.sopt.hashi.user.domain.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** user→실제 RestaurantPort→MapService→MySQL을 검증한다. 미사용 admin/detail Service와 외부 media/storage만 격리한다. */ +@Testcontainers(disabledWithoutDocker = true) +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({CollectionMapPortMySqlIntegrationTest.Infrastructure.class, RestaurantPortImpl.class, + RestaurantMapService.class, RestaurantMapQueryRepository.class, TimeConfig.class, JpaAuditingConfig.class}) +@TestPropertySource(properties = {"spring.flyway.enabled=true", "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.generate_statistics=true"}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class CollectionMapPortMySqlIntegrationTest { + private static final Instant NOW = Instant.parse("2026-10-01T00:00:00Z"); + private static final Clock FIXED = Clock.fixed(NOW, ZoneOffset.UTC); + private static final String PRIVATE_DETAIL = "SELECT private_sql collectionId=987654321 key=synthetic-secret"; + private static final String PRIVATE_CAUSE = "private-address 35.654321 139.123456"; + private static final AtomicInteger BATCHES = new AtomicInteger(); + private static volatile boolean failSecondBatch; + + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi").withUsername("hashi").withPassword("hashi"); + + @TestConfiguration + @EnableConfigurationProperties(MapQueryProperties.class) + @ComponentScan(basePackageClasses = RestaurantCollectionService.class) + static class Infrastructure { + @Bean + HibernatePropertiesCustomizer inspectMapBatches() { + return properties -> properties.put("hibernate.session_factory.statement_inspector", (StatementInspector) sql -> { + if (sql.contains("date_format(l.valid_until") && sql.contains("from restaurant r left join")) { + if (BATCHES.incrementAndGet() == 2 && failSecondBatch) { + throw new DataAccessResourceFailureException(PRIVATE_DETAIL, + new IllegalStateException(PRIVATE_CAUSE)); + } + } + return sql; + }); + } + } + + @Autowired CollectionMapQueryService maps; + @Autowired RestaurantCollectionService writes; + @Autowired RestaurantSaveSummaryService summaries; + @Autowired RestaurantRepository restaurants; + @Autowired RestaurantCollectionRepository collections; + @Autowired SavedRestaurantRepository saved; + @Autowired UserRepository users; + @Autowired EntityManagerFactory entityManagerFactory; + @Autowired JdbcTemplate jdbc; + @MockitoBean CurrentUserProvider currentUser; + @MockitoBean RestaurantService unusedRestaurantService; + @MockitoBean RestaurantLocationService unusedAdminLocationService; + @MockitoBean PlacesLocationService unusedPlacesLocationService; + @MockitoBean MediaPort media; + @MockitoBean FileStorage storage; + @MockitoBean(name = "japanClock") Clock clock; + private Long userId; + + @BeforeEach + void setUp() { + User active = users.saveAndFlush(User.onboard( + "지도Port회원", "HASHI", LocalDate.of(1998, 1, 1), + "01011110005", "map-port@hashi.test", null)); + userId = active.getId(); + given(currentUser.isAuthenticatedUser()).willReturn(true); + given(currentUser.currentUserId()).willReturn(userId); + given(clock.instant()).willReturn(NOW); + BATCHES.set(0); + failSecondBatch = false; + } + + @AfterEach + void clean() { + failSecondBatch = false; + saved.deleteAllInBatch(); + collections.deleteAllInBatch(); + users.deleteAllInBatch(); + jdbc.update("delete from restaurant"); + jdbc.update("delete from restaurant_location"); + } + + @Test + void 실제_저장과_Port로_23개중_21개핀을_연결하고_삭제식당은_제외한다() { + List rows = seedRestaurants(23, 21); + var created = writes.create(new CreateRestaurantCollectionRequest("실제 경로", "red", null, "public")); + writes.saveRestaurant(created.collectionId(), new SaveRestaurantRequest(rows.getFirst().getId())); + assertThat(maps.getMarkers(created.collectionId()).content()).hasSize(1); + Long collectionId = seedCollection("전체 경로", rows); + var response = maps.getMarkers(collectionId); + assertThat(response.visibleRestaurantCount()).isEqualTo(23); + assertThat(response.locationUnavailableCount()).isEqualTo(2); + assertThat(response.content()).hasSize(21); + assertThat(summaries.getSaveCounts(List.of(rows.getFirst().getId())).restaurants().getFirst().saveCount()).isEqualTo(1); + assertThat(summaries.getMySaves(List.of(rows.getFirst().getId())).restaurants().getFirst().saved()).isTrue(); + jdbc.update("update restaurant set deleted=true where id=?", rows.getFirst().getId()); + var afterDelete = maps.getMarkers(collectionId); + assertThat(afterDelete.visibleRestaurantCount()).isEqualTo(22); + assertThat(afterDelete.content()).hasSize(20); + assertThat(saved.findAllByCollection_IdOrderByIdDesc(collectionId)).hasSize(23); + } + + @Test + void 갱신과_재시도중_유효핀은_유지하고_만료와_주소변경은_핀만_제외한다() { + List rows = seedRestaurants(4, 4); + Long collectionId = seedCollection("좌표 갱신", rows); + rows.forEach(Restaurant::refreshLocation); + Restaurant retrying = rows.get(1); + retrying.deferLocation(1, retrying.getLocation().getRequestId(), + LocalDateTime.ofInstant(NOW.plusSeconds(60), ZoneOffset.UTC), FIXED); + Restaurant failed = rows.get(2); + failed.rejectLocation(1, failed.getLocation().getRequestId(), + org.sopt.hashi.restaurant.domain.RestaurantLocationStatus.FAILED); + Restaurant moved = rows.get(3); + moved.updateBasicInfo(null, null, null, null, "changed address", null, null, null, + null, null, null, null); + restaurants.saveAllAndFlush(rows); + + var refreshing = maps.getMarkers(collectionId); + assertThat(refreshing.visibleRestaurantCount()).isEqualTo(4); + assertThat(refreshing.content()).extracting(marker -> marker.restaurantId()) + .containsExactly(rows.get(0).getId(), retrying.getId(), failed.getId()); + assertThat(refreshing.locationUnavailableCount()).isEqualTo(1); + assertThat(saved.findAllByCollection_IdOrderByIdDesc(collectionId)).hasSize(4); + + given(clock.instant()).willReturn(NOW.plusSeconds(3600)); + var expired = maps.getMarkers(collectionId); + assertThat(expired.visibleRestaurantCount()).isEqualTo(4); + assertThat(expired.content()).isEmpty(); + assertThat(expired.locationUnavailableCount()).isEqualTo(4); + assertThat(saved.findAllByCollection_IdOrderByIdDesc(collectionId)).hasSize(4); + } + + @Test + void Places_출처는_전체핀과_함께_전달하고_만료되면_좌표와_같이_제외한다() { + List rows = seedRestaurants(1, 1); + Long id = rows.getFirst().getId(); + Long collectionId = seedCollection("출처 표시", rows); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id = l.id + SET l.source = 'GOOGLE_PLACES', l.google_place_id = 'synthetic-place', + l.places_attributions = '[{"displayName":"지도 제공처","uri":"https://example.test/credit"}]' + WHERE r.id = ? + """, id); + + var response = maps.getMarkers(collectionId); + assertThat(response.content()).singleElement().satisfies(marker -> + assertThat(marker.location().attributions()).singleElement().satisfies(attribution -> { + assertThat(attribution.displayName()).isEqualTo("지도 제공처"); + assertThat(attribution.uri()).isEqualTo("https://example.test/credit"); + })); + + given(clock.instant()).willReturn(NOW.plusSeconds(3600)); + assertThat(maps.getMarkers(collectionId).content()).isEmpty(); + assertThat(saved.findAllByCollection_IdOrderByIdDesc(collectionId)).hasSize(1); + } + + @Test + void 손상된_Places_출처는_출처없는_핀_대신_조회실패로_처리한다() { + List rows = seedRestaurants(1, 1); + Long collectionId = seedCollection("출처 오류", rows); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id = l.id + SET l.source = 'GOOGLE_PLACES', l.google_place_id = 'synthetic-place', + l.places_attributions = '[null]' + WHERE r.id = ? + """, rows.getFirst().getId()); + BusinessException failure = catchThrowableOfType(() -> maps.getMarkers(collectionId), BusinessException.class); + assertThat(failure).isNotNull(); + assertThat(failure.getErrorCode()).isEqualTo(UserErrorCode.COLLECTION_MAP_UNAVAILABLE); + assertThat(saved.findAllByCollection_IdOrderByIdDesc(collectionId)).hasSize(1); + } + + @Test + void 실제_1000개_전체핀은_Port_2batch와_전체_5SQL로_조회한다() { + Long collectionId = seedCollection("1000 경로", seedRestaurants(1000, 1000)); + var statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.clear(); + assertThat(maps.getMarkers(collectionId).content()).hasSize(1000); + assertThat(BATCHES).hasValue(2); + assertThat(statistics.getPrepareStatementCount()).isEqualTo(5); + assertThat(statistics.getEntityLoadCount()).isEqualTo(1); + } + + @Test + void 실제_둘째_batch_SQL실패는_부분_핀_대신_503이다() { + Long collectionId = seedCollection("실패 경로", seedRestaurants(501, 501)); + failSecondBatch = true; + Logger collectionLogger = (Logger) LoggerFactory.getLogger(CollectionMapQueryService.class); + Logger queryLogger = (Logger) LoggerFactory.getLogger(MapQueryFailureLogger.class); + Level collectionLevel = collectionLogger.getLevel(); + Level queryLevel = queryLogger.getLevel(); + ListAppender logs = new ListAppender<>(); + collectionLogger.setLevel(Level.WARN); + queryLogger.setLevel(Level.WARN); + logs.start(); + collectionLogger.addAppender(logs); + queryLogger.addAppender(logs); + try { + BusinessException failure = catchThrowableOfType( + () -> maps.getMarkers(collectionId), BusinessException.class); + assertThat(failure).isNotNull(); + assertThat(failure.getErrorCode()).isEqualTo(UserErrorCode.COLLECTION_MAP_UNAVAILABLE); + assertThat(failure.getCause()).isInstanceOfSatisfying(BusinessException.class, downstream -> { + assertThat(downstream.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_QUERY_UNAVAILABLE); + assertThat(downstream.getCause()).isInstanceOf(DataAccessResourceFailureException.class) + .hasMessage(PRIVATE_DETAIL).hasCauseInstanceOf(IllegalStateException.class); + }); + assertThat(BATCHES).hasValue(2); + assertThat(logs.list).hasSize(2); + assertThat(logs.list).filteredOn(event -> event.getLoggerName().equals(collectionLogger.getName())) + .singleElement().satisfies(event -> { + assertThat(event.getFormattedMessage()).isEqualTo( + "Collection map failed. operation=collection-map-port exceptionType=BusinessException"); + assertThat(event.getArgumentArray()).containsExactly("BusinessException"); + }); + assertThat(logs.list).filteredOn(event -> event.getLoggerName().equals(queryLogger.getName())) + .singleElement().satisfies(event -> { + assertThat(event.getFormattedMessage()).isEqualTo( + "Map query failed. operation=restaurant-map-query exceptionType=" + + "DataAccessResourceFailureException"); + assertThat(event.getArgumentArray()).containsExactly("DataAccessResourceFailureException"); + }); + assertThat(logs.list).allSatisfy(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getThrowableProxy()).isNull(); + assertThat(event.getFormattedMessage()).doesNotContain(PRIVATE_DETAIL, PRIVATE_CAUSE, + "987654321", "private_sql", "synthetic-secret", "35.654321", "139.123456"); + }); + } finally { + collectionLogger.detachAppender(logs); + queryLogger.detachAppender(logs); + logs.stop(); + collectionLogger.setLevel(collectionLevel); + queryLogger.setLevel(queryLevel); + } + } + + private List seedRestaurants(int count, int located) { + List rows = new ArrayList<>(); + LocalDateTime now = LocalDateTime.ofInstant(NOW, ZoneOffset.UTC); + for (int index = 0; index < count; index++) { + Restaurant restaurant = Restaurant.create("합성 식당 " + index, "fixture", "fixture", "fixture", + "synthetic address", "synthetic area", RestaurantGenre.SUSHI, "fixture", RestaurantPlaceType.RESTAURANT, + PriceCurrency.JPY, BigDecimal.ONE, BigDecimal.TEN); + if (index < located) { + restaurant.requestLocationResolution(); + restaurant.completeLocation(1, restaurant.getLocation().getRequestId(), + MapCoordinates.of(new BigDecimal("35.6"), new BigDecimal("139.7")), RestaurantLocationSource.ADMIN, + now.minusHours(1), now.plusHours(1), FIXED); + } + rows.add(restaurant); + } + return restaurants.saveAllAndFlush(rows); + } + + private Long seedCollection(String name, List rows) { + RestaurantCollection collection = RestaurantCollection.create( + userId, name, CollectionColor.RED, null, CollectionVisibility.PUBLIC); + rows.forEach(restaurant -> collection.save(restaurant.getId())); + return collections.saveAndFlush(collection).getId(); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/LocationAdoptionPolicyTest.java b/src/test/java/org/sopt/hashi/restaurant/service/LocationAdoptionPolicyTest.java new file mode 100644 index 00000000..b0d63d43 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/LocationAdoptionPolicyTest.java @@ -0,0 +1,330 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.AddressComponent; +import org.sopt.hashi.restaurant.internal.map.GeocodingCandidate.Granularity; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Candidates; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.sopt.hashi.restaurant.internal.map.LocationJobProperties; + +class LocationAdoptionPolicyTest { + static final String ADDRESS = "東京都試験区架空町1丁目2番3号"; + private final LocationAdoptionPolicy policy = new LocationAdoptionPolicy(properties()); + + static LocationJobProperties properties() { + // Synthetic bounds/addresses; these are not operational Tokyo region data. + return new LocationJobProperties(true, Duration.ofDays(1), new BigDecimal("10"), new BigDecimal("11"), + new BigDecimal("20"), new BigDecimal("21"), null); + } + + static GeocodingCandidate candidate() { + return numericPremiseCandidate("3"); + } + + static GeocodingCandidate numericPremiseCandidate(String premise) { + return new GeocodingCandidate(new BigDecimal("10.1234567"), new BigDecimal("20.7654321"), + Granularity.ROOFTOP, "JP", "東京都", List.of( + component("日本", "JP", "country"), + component("東京都", "東京都", "administrative_area_level_1"), + component("試験区", "試験区", "locality"), + component("架空町", "架空町", "sublocality_level_2"), + component("1丁目", "1丁目", "sublocality_level_3"), + component("2", "2", "sublocality_level_4"), + component(premise, premise, "premise"), + component("100-0001", "100-0001", "postal_code"), + component("架空ビル 99F", "99F", "subpremise")), + List.of("establishment", "point_of_interest", "shopping_mall")); + } + + static AddressComponent component(String value, String shortValue, String type) { + return new AddressComponent(value, shortValue, List.of(type)); + } + + @ParameterizedTest + @ValueSource(strings = {ADDRESS, "東京都試験区架空町1-2-3", "東京都試験区架空町1−2−3 架空ビル 4F", + "Tokyo Shiken-ku Kaku-cho 1 Chome-2-3, Building 4F"}) + void 언어와_건물층_표기와_결과type에_관계없이_안전한_rooftop을_채택한다(String address) { + var decision = policy.evaluate(address, new Candidates(List.of(candidate()))); + + assertThat(decision.failureCode()).isNull(); + assertThat(decision.coordinates().getLatitude()).isEqualByComparingTo("10.123457"); + assertThat(decision.coordinates().getLongitude()).isEqualByComparingTo("20.765432"); + } + + @Test + void subpremise와_알수없는_component는_본번_충돌판정에서_무시한다() { + var original = candidate(); + var components = new ArrayList<>(original.addressComponents()); + components.add(component("untrusted detail 777", "777", "future_component")); + var value = copy(original, original.latitude(), original.longitude(), original.granularity(), + original.countryCode(), original.administrativeArea(), components); + + assertThat(policy.evaluate("東京都試験区架空町1-2-3 別館777 99F", + new Candidates(List.of(value))).failureCode()).isNull(); + } + + @Test + void 두구간_주소는_provider가_마지막_본번만_확실히_주면_suffix만_비교한다() { + var suffix = candidateWithComponents(List.of( + component("日本", "JP", "country"), + component("東京都", "東京都", "administrative_area_level_1"), + component("3", "3", "premise"))); + + assertThat(policy.evaluate("東京都試験区架空町2-3", new Candidates(List.of(suffix))) + .failureCode()).isNull(); + assertThat(policy.evaluate("東京都試験区架空町2-4", new Candidates(List.of(suffix))) + .failureCode()).isEqualTo("ADDRESS_MISMATCH"); + } + + @ParameterizedTest + @ValueSource(strings = {"東京都試験区架空町2-2-3", "東京都試験区架空町1-9-3", + "東京都試験区架空町1-2-4"}) + void 양쪽에서_전체_본번을_확실히_추출하면_어느구간_충돌도_거절한다(String address) { + assertThat(policy.evaluate(address, new Candidates(List.of(candidate()))).failureCode()) + .isEqualTo("ADDRESS_MISMATCH"); + } + + @Test + void 영어응답의_Chome과_component계층도_전체tuple로_비교한다() { + var value = candidateWithComponents(List.of( + component("日本", "JP", "country"), + component("東京都", "東京都", "administrative_area_level_1"), + component("1-chōme", "1-chōme", "sublocality_level_3"), + component("2", "2", "sublocality_level_4"), + component("3", "3", "premise"))); + + assertThat(policy.evaluate("Tokyo Shiken-ku 1丁目2番3号", new Candidates(List.of(value))) + .failureCode()).isNull(); + assertThat(policy.evaluate("Tokyo Shiken-ku 2丁目2番3号", new Candidates(List.of(value))) + .failureCode()).isEqualTo("ADDRESS_MISMATCH"); + assertThat(policy.evaluate("Tokyo Shiken-ku 1丁目9番3号", new Candidates(List.of(value))) + .failureCode()).isEqualTo("ADDRESS_MISMATCH"); + assertThat(policy.evaluate("Tokyo Shiken-ku 1丁目2番4号", new Candidates(List.of(value))) + .failureCode()).isEqualTo("ADDRESS_MISMATCH"); + } + + @Test + void 명시단위와_같은_component의_숫자short는_같은역할로만_해석한다() { + var value = candidateWithComponents(List.of( + component("日本", "JP", "country"), + component("東京都", "東京都", "administrative_area_level_1"), + component("1丁目", "1丁目", "sublocality_level_2"), + component("2番", "2", "sublocality_level_3"), + component("3号", "3", "sublocality_level_4"))); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(value))).failureCode()).isNull(); + } + + @Test + void 명시단위와_같은_component의_다른숫자short는_거절한다() { + var value = candidateWithComponents(List.of( + component("日本", "JP", "country"), + component("東京都", "東京都", "administrative_area_level_1"), + component("1丁目", "1丁目", "sublocality_level_2"), + component("2番", "3", "sublocality_level_3"), + component("3号", "3", "sublocality_level_4"))); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(value))).failureCode()) + .isEqualTo("ADDRESS_MISMATCH"); + } + + @Test + void premise에_층표기가_섞이면_본번tuple로_추측하지_않는다() { + var value = candidateWithComponents(List.of( + component("日本", "JP", "country"), + component("東京都", "東京都", "administrative_area_level_1"), + component("6-chōme", "6-chōme", "sublocality_level_3"), + component("4", "4", "sublocality_level_4"), + component("12-7F", "12-7F", "premise"))); + + assertThat(policy.evaluate("Tokyo Chuo City 6 Chome-4-12 Building 7F", + new Candidates(List.of(value))).failureCode()).isNull(); + } + + @Test + void 우편번호는_양쪽에서_하나씩_확실할때만_충돌을_거절한다() { + assertThat(policy.evaluate("〒100-0001 " + ADDRESS, new Candidates(List.of(candidate()))) + .failureCode()).isNull(); + assertThat(policy.evaluate("〒100-0002 " + ADDRESS, new Candidates(List.of(candidate()))) + .failureCode()).isEqualTo("ADDRESS_MISMATCH"); + + var withoutPostal = candidateWithComponents(candidate().addressComponents().stream() + .filter(component -> !component.types().contains("postal_code")).toList()); + assertThat(policy.evaluate("〒100-0002 " + ADDRESS, new Candidates(List.of(withoutPostal))) + .failureCode()).isNull(); + + var conflictingProviderPostal = new ArrayList<>(candidate().addressComponents()); + conflictingProviderPostal.add(component("100-0002", "100-0002", "postal_code")); + assertThat(policy.evaluate("〒100-0001 " + ADDRESS, + new Candidates(List.of(candidateWithComponents(conflictingProviderPostal)))).failureCode()) + .isEqualTo("ADDRESS_MISMATCH"); + } + + @Test + void provider의_서로다른_본번component는_불확실하다고_우회하지_않고_거절한다() { + var components = new ArrayList<>(candidate().addressComponents()); + components.add(component("9-chōme", "9-chōme", "sublocality_level_3")); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(candidateWithComponents(components)))) + .failureCode()).isEqualTo("ADDRESS_MISMATCH"); + } + + @Test + void 긴_숫자component도_overflow없이_충돌로_거절한다() { + String longNumber = "9".repeat(100); + var value = candidateWithComponents(List.of( + component("日本", "JP", "country"), + component("東京都", "東京都", "administrative_area_level_1"), + component(longNumber + "-chōme", longNumber + "-chōme", "sublocality_level_3"), + component("2", "2", "sublocality_level_4"), + component("3", "3", "premise"))); + + assertThat(policy.evaluate("Tokyo Shiken-ku 1 Chome-2-3", new Candidates(List.of(value))) + .failureCode()).isEqualTo("ADDRESS_MISMATCH"); + } + + @Test + void 입력에_서로다른_본번이_두개면_거절하고_같은_본번의_반복은_허용한다() { + assertThat(policy.evaluate("東京都新宿区西新宿1-2-3 / 4-5-6", + new Candidates(List.of(candidate()))).failureCode()).isEqualTo("ADDRESS_MISMATCH"); + assertThat(policy.evaluate("東京都新宿区西新宿1-2-3 / 1-2-3", + new Candidates(List.of(candidate()))).failureCode()).isNull(); + } + + @Test + void country_누락과_다른국가를_서로_다른_운영사유로_남긴다() { + var original = candidate(); + var missing = copy(original, original.latitude(), original.longitude(), original.granularity(), + "", original.administrativeArea(), original.addressComponents()); + var foreign = copy(original, original.latitude(), original.longitude(), original.granularity(), + "US", original.administrativeArea(), original.addressComponents()); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(missing))).failureCode()) + .isEqualTo("COUNTRY_MISSING"); + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(foreign))).failureCode()) + .isEqualTo("COUNTRY_MISMATCH"); + } + + @Test + void 고정된_영어응답의_Tokyo도_같은_도쿄지원영역으로_인정한다() { + var original = candidate(); + var english = copy(original, original.latitude(), original.longitude(), original.granularity(), + original.countryCode(), "Tokyo", original.addressComponents()); + + assertThat(policy.evaluate("Tokyo Shiken-ku 1 Chome-2-3", new Candidates(List.of(english))) + .failureCode()).isNull(); + } + + @ParameterizedTest + @EnumSource(value = Granularity.class, names = "ROOFTOP", mode = EnumSource.Mode.EXCLUDE) + void rooftop이_아닌_좌표는_주소나_type과_관계없이_거절한다(Granularity granularity) { + var original = candidate(); + var value = copy(original, original.latitude(), original.longitude(), granularity, + original.countryCode(), original.administrativeArea(), original.addressComponents()); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(value))).failureCode()) + .isEqualTo("INSUFFICIENT_PRECISION"); + } + + @Test + void 좌표와_도쿄_지원범위를_반올림전에_검증한다() { + var original = candidate(); + var outside = copy(original, new BigDecimal("12"), original.longitude(), original.granularity(), + original.countryCode(), original.administrativeArea(), original.addressComponents()); + var invalid = copy(original, new BigDecimal("90.0000001"), original.longitude(), original.granularity(), + original.countryCode(), original.administrativeArea(), original.addressComponents()); + var unsupportedArea = copy(original, original.latitude(), original.longitude(), original.granularity(), + original.countryCode(), "Osaka", original.addressComponents()); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(outside))).failureCode()) + .isEqualTo("OUTSIDE_SUPPORTED_AREA"); + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(invalid))).failureCode()) + .isEqualTo("INVALID_COORDINATES"); + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(unsupportedArea))).failureCode()) + .isEqualTo("OUTSIDE_SUPPORTED_AREA"); + } + + @Test + void 여러응답중_안전한후보가_하나면_그후보만_채택하고_둘이면_거절한다() { + var original = candidate(); + var outside = copy(original, new BigDecimal("12"), original.longitude(), original.granularity(), + original.countryCode(), original.administrativeArea(), original.addressComponents()); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(outside, original))).failureCode()).isNull(); + var distinct = copy(original, new BigDecimal("10.1234568"), original.longitude(), original.granularity(), + original.countryCode(), original.administrativeArea(), original.addressComponents()); + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(original, distinct))).failureCode()) + .isEqualTo("AMBIGUOUS_RESULTS"); + } + + @Test + void 반올림전_숫자좌표가_정확히_같은_중복후보만_하나의_위치로_취급한다() { + var original = candidate(); + var samePointDifferentScale = copy(original, new BigDecimal("10.123456700"), + new BigDecimal("20.765432100"), original.granularity(), original.countryCode(), + original.administrativeArea(), original.addressComponents()); + var roundsToSameButDistinct = copy(original, new BigDecimal("10.12345671"), + original.longitude(), original.granularity(), original.countryCode(), + original.administrativeArea(), original.addressComponents()); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(original, samePointDifferentScale))) + .failureCode()).isNull(); + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(original, roundsToSameButDistinct))) + .failureCode()).isEqualTo("AMBIGUOUS_RESULTS"); + } + + @Test + void 여러후보가_모두_탈락하면_임의로_첫후보를_고르지_않는다() { + var original = candidate(); + var first = copy(original, new BigDecimal("12"), original.longitude(), original.granularity(), + original.countryCode(), original.administrativeArea(), original.addressComponents()); + var second = copy(original, original.latitude(), original.longitude(), Granularity.APPROXIMATE, + original.countryCode(), original.administrativeArea(), original.addressComponents()); + + assertThat(policy.evaluate(ADDRESS, new Candidates(List.of(first, second))).failureCode()) + .isEqualTo("AMBIGUOUS_RESULTS"); + } + + @Test + void 기본_비활성과_명시적_보관_수명_지원범위_활성화_조건을_검증한다() { + var disabled = new LocationJobProperties(false, null, null, null, null, null, null); + assertThat(disabled.enabled()).isFalse(); + assertThat(disabled.maxAttempts()).isEqualTo(8); + assertThat(new LocationJobProperties(true, null, null, null, null, null, null).isConfigured()).isFalse(); + assertThat(new LocationJobProperties(true, Duration.ofDays(31), + BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ONE, 4).isConfigured()).isFalse(); + assertThat(properties().isConfigured()).isTrue(); + } + + @ParameterizedTest + @EnumSource(FailureKind.class) + void 모든_adapter_실패를_한정된_재시도_정책으로_분류한다(FailureKind kind) { + var retries = new LocationRetryPolicy(); + assertThat(retries.canRetry(kind)).isEqualTo(List.of(FailureKind.TIMEOUT, FailureKind.CONNECTION_ERROR, + FailureKind.TRANSIENT_ERROR, FailureKind.QUOTA_EXCEEDED, FailureKind.CAPACITY_EXCEEDED, + FailureKind.CANCELLED).contains(kind)); + } + + private static GeocodingCandidate candidateWithComponents(List components) { + var original = candidate(); + return copy(original, original.latitude(), original.longitude(), original.granularity(), + original.countryCode(), original.administrativeArea(), components); + } + + private static GeocodingCandidate copy(GeocodingCandidate original, BigDecimal latitude, BigDecimal longitude, + Granularity granularity, String countryCode, String administrativeArea, + List components) { + return new GeocodingCandidate(latitude, longitude, granularity, countryCode, administrativeArea, + components, original.types()); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/LocationJobMySqlTest.java b/src/test/java/org/sopt/hashi/restaurant/service/LocationJobMySqlTest.java new file mode 100644 index 00000000..e7a5f52f --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/LocationJobMySqlTest.java @@ -0,0 +1,1387 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mockingDetails; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.TimeZone; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import org.sopt.hashi.config.TimeConfig; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.CurrentActor; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.restaurant.RestaurantLocationPreviewCommand; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.AdminRestaurantCommand; +import org.sopt.hashi.restaurant.AdminRestaurantCommand.BusinessHourCommand; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.internal.map.GeocodingProvider; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.Failure; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; +import org.sopt.hashi.restaurant.internal.map.LocationJobProperties; +import org.sopt.hashi.restaurant.internal.map.RestaurantLocationWorker; +import org.sopt.hashi.restaurant.internal.map.PlacesSelectionProperties; +import org.sopt.hashi.restaurant.internal.map.PlacesSelectionToken; +import org.sopt.hashi.restaurant.internal.map.places.GooglePlacesProperties; +import org.sopt.hashi.restaurant.internal.map.places.PlaceDetailsResult; +import org.sopt.hashi.restaurant.internal.map.places.PlacesAttribution; +import org.sopt.hashi.restaurant.internal.map.places.PlacesCandidate; +import org.sopt.hashi.restaurant.internal.map.places.PlacesProvider; +import org.sopt.hashi.restaurant.internal.map.places.PlacesSearchResult; +import org.sopt.hashi.restaurant.service.LocationJobTransactions.Claim; +import org.sopt.hashi.restaurant.service.LocationJobTransactions.Outcome; +import org.sopt.hashi.restaurant.service.LocationJobTransactions.Target; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.storage.FileStorage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({RestaurantService.class, RestaurantPortImpl.class, RestaurantLocationService.class, LocationJobTransactions.class, + LocationAdoptionPolicy.class, LocationRetryPolicy.class, RestaurantLocationWorker.class, + PlacesCandidatePolicy.class, PlacesLocationTransactions.class, PlacesLocationService.class, + PlacesSelectionToken.class, + TimeConfig.class, LocationJobMySqlTest.Fixtures.class}) +@TestPropertySource(properties = {"spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true"}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class LocationJobMySqlTest { + private static final AtomicInteger RESTAURANT_SEQUENCE = new AtomicInteger(); + private static final DateTimeFormatter DATABASE_TIME = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("location_jobs").withUsername("hashi").withPassword("hashi") + .withCommand("--log-bin-trust-function-creators=1") + .withUrlParam("connectionTimeZone", "Asia/Seoul") + .withUrlParam("forceConnectionTimeZoneToSession", "true"); + + @Autowired RestaurantService restaurants; + @Autowired RestaurantPort restaurantPort; + @Autowired RestaurantRepository restaurantRepository; + @Autowired RestaurantLocationService locations; + @MockitoSpyBean RestaurantLocationJobRepository jobs; + @Autowired LocationJobTransactions transactions; + @Autowired RestaurantLocationWorker worker; + @Autowired PlacesLocationService places; + @MockitoSpyBean LocationJobProperties properties; + @Autowired JdbcTemplate jdbc; + @Autowired TransactionTemplate tx; + @Autowired FakeProvider provider; + @Autowired FakePlacesProvider placesProvider; + @MockitoBean MediaPort mediaPort; + @MockitoBean CurrentActorProvider currentActorProvider; + @MockitoBean FileStorage fileStorage; + @MockitoBean RestaurantMapService restaurantMapService; + + @BeforeEach + void reset() { + doReturn(new CurrentActor(ActorType.ADMIN, 7L)).when(currentActorProvider).currentActor(); + jdbc.update("DELETE FROM restaurant_location_job"); + jdbc.update(""" + UPDATE restaurant_geocoding_budget SET enabled=true, daily_limit=100, max_concurrent=4, + reserved_calls=0, budget_day=NULL, blocked_until=NULL WHERE id=1 + """); + jdbc.update(""" + UPDATE restaurant_places_budget SET enabled=true, daily_limit=100, minute_limit=10, + daily_used=0, minute_used=0, budget_day=NULL, minute_window_start=NULL, blocked_until=NULL + """); + provider.answer.set(address -> new GeocodingResult.Candidates(List.of(LocationAdoptionPolicyTest.candidate()))); + placesProvider.searchAnswer.set(query -> new PlacesSearchResult.NoResults()); + placesProvider.detailsAnswer.set(placeId -> new PlaceDetailsResult.NoResults()); + } + + @Test + void 저장_작업_등록_자동_완료와_UTC_원시_시각이_실제_DB에서_일치한다() { + assertThat(jdbc.queryForObject("SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), NOW())", Integer.class)) + .isEqualTo(9 * 60 * 60); + var response = restaurants.createByAdmin(createCommand()); + assertThat(response.locationStatus()).isEqualTo("PENDING"); + assertThat(response.addressRevision()).isEqualTo(1); + Target target = target(response.restaurantId()); + Claim claim = transactions.claim(target).orElseThrow(); + assertThat(Duration.between(claim.obtainedAt().toInstant(ZoneOffset.UTC), Instant.now()).abs()) + .isLessThan(Duration.ofSeconds(10)); + assertThat(rawTime("lease_until", target.jobId())).isEqualTo(claim.leaseUntil()); + assertThat(rawTime("reserved_until", target.jobId())).isEqualTo(claim.leaseUntil()); + assertThat(transactions.complete(claim, ready())).isTrue(); + var status = locations.get(response.restaurantId()); + assertThat(status.locationStatus()).isEqualTo("READY"); + assertThat(status.attempt()).isEqualTo(1); + assertThat(status.validUntil()).isEqualTo(claim.obtainedAt().plusDays(1).toInstant(ZoneOffset.UTC)); + assertThat(jdbc.queryForObject(""" + SELECT DATE_FORMAT(l.obtained_at, '%Y-%m-%dT%H:%i:%s.%f') FROM restaurant r + JOIN restaurant_location l ON l.id=r.location_id WHERE r.id=? + """, String.class, response.restaurantId())).isEqualTo(format(claim.obtainedAt())); + assertThat(used()).isEqualTo(1); + assertThat(transactions.complete(claim, Outcome.failure(FailureKind.ACCESS_DENIED))).isFalse(); + } + + @Test + void 명시_위치확인주소를_provider와_채택정책에_사용하고_표시주소는_보존한다() { + AtomicReference providerInput = new AtomicReference<>(); + provider.answer.set(address -> { + providerInput.set(address); + return new GeocodingResult.Candidates(List.of(LocationAdoptionPolicyTest.candidate())); + }); + var response = restaurants.createByAdmin(createCommandWithGeocodingAddress()); + + worker.process(target(response.restaurantId())); + + assertThat(providerInput).hasValue(LocationAdoptionPolicyTest.ADDRESS); + assertThat(response.address()).contains("架空ビル"); + assertThat(response.geocodingAddress()).isEqualTo(LocationAdoptionPolicyTest.ADDRESS); + assertThat(locations.get(response.restaurantId()).locationStatus()).isEqualTo("READY"); + } + + @Test + void 위치확인주소만_바꿔도_이전_job결과를_막고_공백으로_지우면_표시주소로_fallback한다() { + var created = restaurants.createByAdmin(createCommandWithGeocodingAddress()); + Long id = created.restaurantId(); + Claim first = transactions.claim(target(id)).orElseThrow(); + + var changed = restaurants.updateByAdmin(id, + geocodingAddressCommand(" 東京都試験区架空町1丁目2番4号 ")); + + assertThat(changed.addressRevision()).isEqualTo(2); + assertThat(changed.geocodingAddress()).isEqualTo("東京都試験区架空町1丁目2番4号"); + assertThat(transactions.complete(first, ready())).isFalse(); + Claim second = transactions.claim(target(id)).orElseThrow(); + assertThat(second.geocodingAddress()).isEqualTo("東京都試験区架空町1丁目2番4号"); + + var cleared = restaurants.updateByAdmin(id, geocodingAddressCommand("\u00A0\u2007\u202F")); + + assertThat(cleared.addressRevision()).isEqualTo(3); + assertThat(cleared.geocodingAddress()).isNull(); + assertThat(transactions.complete(second, ready())).isFalse(); + Claim fallback = transactions.claim(target(id)).orElseThrow(); + assertThat(fallback.geocodingAddress()).isEqualTo(cleared.address()); + } + + @Test + void 같은_명시base라도_층수_변경은_새작업을_등록하고_옛결과를_차단한다() { + var created = restaurants.createByAdmin(createCommandWithGeocodingAddress()); + Long id = created.restaurantId(); + Claim inFlight = transactions.claim(target(id)).orElseThrow(); + long jobCount = jobs.count(); + String nextDisplayAddress = created.address().replace("1F", "2F"); + + var pending = restaurants.updateByAdmin(id, + addressAndGeocodingAddressCommand(nextDisplayAddress, LocationAdoptionPolicyTest.ADDRESS)); + + assertThat(pending.addressRevision()).isEqualTo(2); + assertThat(pending.address()).isEqualTo(nextDisplayAddress); + assertThat(pending.geocodingAddress()).isEqualTo(LocationAdoptionPolicyTest.ADDRESS); + assertThat(jobs.count()).isEqualTo(jobCount + 1); + assertThat(transactions.complete(inFlight, ready())).isFalse(); + worker.process(target(id)); + var locationBefore = readLocation(id); + + var ready = restaurants.updateByAdmin(id, + addressAndGeocodingAddressCommand(nextDisplayAddress.replace("2F", "3F"), + LocationAdoptionPolicyTest.ADDRESS)); + + assertThat(ready.addressRevision()).isEqualTo(3); + assertThat(ready.locationStatus()).isEqualTo("PENDING"); + assertThat(jobs.count()).isEqualTo(jobCount + 2); + assertThat(readLocation(id).getCoordinates()).isNull(); + assertThat(locationBefore.getCoordinates()).isNotNull(); + } + + @Test + void 명시base가_있던_legacy_표시주소수정은_base를_지우고_옛job을_막아_새입력으로_재확인한다() { + var created = restaurants.createByAdmin(createCommandWithGeocodingAddress()); + Long id = created.restaurantId(); + Claim old = transactions.claim(target(id)).orElseThrow(); + String nextDisplayAddress = "東京都試験区別町4-5-6 架空ビル 2F"; + + var changed = restaurants.updateByAdmin(id, addressCommand(nextDisplayAddress)); + + assertThat(changed.addressRevision()).isEqualTo(2); + assertThat(changed.geocodingAddress()).isNull(); + assertThat(transactions.complete(old, ready())).isFalse(); + Claim current = transactions.claim(target(id)).orElseThrow(); + assertThat(current.geocodingAddress()).isEqualTo(changed.address()); + assertThat(current.addressRevision()).isEqualTo(2); + } + + @Test + void 같은_주소_갱신의_재시도와_실패도_기존_좌표와_만료를_보존한다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + transactions.complete(transactions.claim(target(id)).orElseThrow(), ready()); + var before = readLocation(id); + tx.executeWithoutResult(ignored -> { + var restaurant = restaurantRepository.findByIdForUpdate(id).orElseThrow(); + restaurant.refreshLocation(); + locations.enqueue(restaurant); + }); + Target refresh = target(id); + transactions.complete(transactions.claim(refresh).orElseThrow(), Outcome.failure(FailureKind.TRANSIENT_ERROR)); + assertThat(locations.get(id).locationStatus()).isEqualTo("RETRY_WAIT"); + assertRetainedLocation(id, before); + due(id, refresh.jobId()); + transactions.complete(transactions.claim(refresh).orElseThrow(), Outcome.failure(FailureKind.ACCESS_DENIED)); + assertThat(locations.get(id).locationStatus()).isEqualTo("FAILED"); + assertRetainedLocation(id, before); + assertThat(readLocation(id).isUsable( + java.time.Clock.fixed(before.getValidUntil().toInstant(ZoneOffset.UTC), ZoneOffset.UTC))).isFalse(); + restaurants.updateByAdmin(id, addressCommand("東京都試験区架空町1-2-4")); + var changed = readLocation(id); + assertThat(changed.getCoordinates()).isNull(); + assertThat(changed.getValidUntil()).isNull(); + } + + private org.sopt.hashi.restaurant.domain.RestaurantLocation readLocation(Long id) { + return tx.execute(ignored -> { + var location = restaurantRepository.findById(id).orElseThrow().getLocation(); + org.hibernate.Hibernate.initialize(location); + return location; + }); + } + + private void assertRetainedLocation(Long id, org.sopt.hashi.restaurant.domain.RestaurantLocation before) { + var actual = readLocation(id); + assertThat(actual.getCoordinates()).usingRecursiveComparison().isEqualTo(before.getCoordinates()); + assertThat(actual.getSource()).isEqualTo(before.getSource()); + assertThat(actual.getObtainedAt()).isEqualTo(before.getObtainedAt()); + assertThat(actual.getValidUntil()).isEqualTo(before.getValidUntil()); + assertThat(actual.isUsable(java.time.Clock.systemUTC())).isTrue(); + } + + @Test + void 전역_호출_중단과_예산_소진은_후보를_읽기_전에_거르고_다음날_다시_허용한다() { + Target target = target(restaurants.createByAdmin(createCommand()).restaurantId()); + assertThat(transactions.candidates()).contains(target); + jdbc.update("UPDATE restaurant_geocoding_budget SET enabled=false WHERE id=1"); + assertThat(transactions.candidates()).isEmpty(); + jdbc.update("UPDATE restaurant_geocoding_budget SET enabled=true, blocked_until=UTC_TIMESTAMP(6)+INTERVAL 1 HOUR WHERE id=1"); + assertThat(transactions.candidates()).isEmpty(); + jdbc.update("UPDATE restaurant_geocoding_budget SET blocked_until=NULL, budget_day=UTC_DATE(), reserved_calls=100 WHERE id=1"); + assertThat(transactions.candidates()).isEmpty(); + jdbc.update("UPDATE restaurant_geocoding_budget SET budget_day=UTC_DATE()-INTERVAL 1 DAY WHERE id=1"); + assertThat(transactions.candidates()).contains(target); + assertThat(used()).isEqualTo(100); + assertThat(transactions.claim(target)).isPresent(); + assertThat(used()).isEqualTo(1); + jdbc.update("UPDATE restaurant_geocoding_budget SET max_concurrent=1 WHERE id=1"); + assertThat(transactions.candidates()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 예산이_닫혀도_만료된_마지막_시도는_대기중인_50개_뒤에서_종료한다(boolean disabled) { + doReturn(1).when(properties).maxAttempts(); + for (int i = 0; i < 51; i++) { + restaurants.createByAdmin(createCommand()); + } + jdbc.update("UPDATE restaurant_location_job SET next_attempt_at=UTC_TIMESTAMP(6)-INTERVAL 1 DAY"); + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Target last = target(id); + jdbc.update("UPDATE restaurant_geocoding_budget SET daily_limit=1 WHERE id=1"); + transactions.claim(last).orElseThrow(); + expire(last.jobId()); + if (disabled) { + jdbc.update("UPDATE restaurant_geocoding_budget SET enabled=false WHERE id=1"); + } + provider.answer.set(address -> { throw new AssertionError("cleanup must not call provider"); }); + assertThat(transactions.candidates()).containsExactly(last); + worker.runOnce(); + assertThat(locations.get(id).locationStatus()).isEqualTo("FAILED"); + assertThat(locations.get(id).failureCode()).isEqualTo("ATTEMPTS_EXHAUSTED"); + assertThat(locations.get(id).canRetry()).isTrue(); + assertThat(used()).isEqualTo(1); + assertThat(transactions.candidates()).isEmpty(); + } + + @Test + void 설정_오류는_예산이_닫혀도_호출없이_실패로_정리한다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + doReturn(false).when(properties).isConfigured(); + jdbc.update("UPDATE restaurant_geocoding_budget SET enabled=false, daily_limit=0 WHERE id=1"); + provider.answer.set(address -> { throw new AssertionError("invalid configuration must not call provider"); }); + worker.runOnce(); + assertThat(locations.get(id).locationStatus()).isEqualTo("FAILED"); + assertThat(locations.get(id).failureCode()).isEqualTo("CONFIGURATION_ERROR"); + assertThat(locations.get(id).canRetry()).isTrue(); + assertThat(used()).isZero(); + } + + @Test + void 애플리케이션_시계가_앞서도_새_PENDING_작업은_즉시_처리한다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Target target = target(id); + jdbc.update("UPDATE restaurant_location_job SET next_attempt_at=UTC_TIMESTAMP(6)+INTERVAL 9 HOUR WHERE id=?", + target.jobId()); + assertThat(transactions.candidates()).contains(target); + assertThat(transactions.claim(target)).isPresent(); + } + + @ParameterizedTest + @EnumSource(AdminOperation.class) + void 빈_작업_테이블에서도_서로_다른_식당의_작업을_동시에_등록한다(AdminOperation operation) throws Exception { + Long firstId = operation == AdminOperation.CREATE ? null + : restaurantPort.createByAdmin(createCommand()).restaurantId(); + Long secondId = operation == AdminOperation.CREATE ? null + : restaurantPort.createByAdmin(createCommand()).restaurantId(); + jdbc.update("DELETE FROM restaurant_location_job"); + if (operation == AdminOperation.RETRY) { + jdbc.update("UPDATE restaurant SET location_id=NULL WHERE id IN (?, ?)", firstId, secondId); + } + CyclicBarrier selected = new CyclicBarrier(2); + var realRepository = mockingDetails(jobs).getMockCreationSettings().getDefaultAnswer(); + // 실제 MySQL 조회를 마친 두 요청을 INSERT 직전에 맞춘다. DB 동작은 대체하지 않는다. + doAnswer(invocation -> { + Object result = realRepository.answer(invocation); + selected.await(10, TimeUnit.SECONDS); + return result; + }).when(jobs).findActiveForUpdate(anyLong()); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(() -> saveLocationJob(operation, firstId)); + var second = executor.submit(() -> saveLocationJob(operation, secondId)); + assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo("PENDING"); + assertThat(second.get(15, TimeUnit.SECONDS)).isEqualTo("PENDING"); + } + assertThat(jobs.findAll()).hasSize(2); + } + + @Test + void 서로_다른_식당의_동시_claim도_전역_slot_한개를_초과하지_않는다() throws Exception { + Target first = target(restaurants.createByAdmin(createCommand()).restaurantId()); + Target second = target(restaurants.createByAdmin(createCommand()).restaurantId()); + jdbc.update("UPDATE restaurant_geocoding_budget SET max_concurrent=1 WHERE id=1"); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var a = executor.submit(() -> { await(start); return transactions.claim(first); }); + var b = executor.submit(() -> { await(start); return transactions.claim(second); }); + start.countDown(); + assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS)) + .stream().filter(java.util.Optional::isPresent)).hasSize(1); + } + assertThat(used()).isEqualTo(1); + } + + @Test + void worker는_transaction_밖에서_호출하고_HTTP_대기_중_주소수정이_완료된다() throws Exception { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + provider.answer.set(address -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + entered.countDown(); + await(release); + return new GeocodingResult.Candidates(List.of(LocationAdoptionPolicyTest.candidate())); + }); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var running = executor.submit(() -> worker.process(target(id))); + assertThat(entered.await(10, TimeUnit.SECONDS)).isTrue(); + try { + var updated = executor.submit(() -> restaurants.updateByAdmin(id, addressCommand("東京都試験区架空町1-2-4"))); + assertThat(updated.get(10, TimeUnit.SECONDS).addressRevision()).isEqualTo(2); + } finally { + release.countDown(); + } + running.get(10, TimeUnit.SECONDS); + } + assertThat(locations.get(id).locationStatus()).isEqualTo("PENDING"); + assertThat(locations.get(id).addressRevision()).isEqualTo(2); + assertThat(jobs.findAll()).hasSize(2); + } + + @Test + void 두_worker가_경쟁해도_하나만_유효한_lease와_호출예산을_얻는다() throws Exception { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Target target = target(id); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(() -> { await(start); return transactions.claim(target); }); + var second = executor.submit(() -> { await(start); return transactions.claim(target); }); + start.countDown(); + assertThat(List.of(first.get(15, TimeUnit.SECONDS), second.get(15, TimeUnit.SECONDS)) + .stream().filter(java.util.Optional::isPresent)).hasSize(1); + } + assertThat(used()).isEqualTo(1); + } + + @Test + void crash_후_lease를_재획득하고_옛_성공과_실패와_중복_완료를_모두_무시한다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Target target = target(id); + Claim old = transactions.claim(target).orElseThrow(); + expire(target.jobId()); + Claim current = transactions.claim(target).orElseThrow(); + assertThat(current.leaseToken()).isNotEqualTo(old.leaseToken()); + assertThat(transactions.complete(old, ready())).isFalse(); + assertThat(transactions.complete(old, Outcome.failure(FailureKind.ACCESS_DENIED))).isFalse(); + assertThat(transactions.complete(current, ready())).isTrue(); + assertThat(transactions.complete(current, ready())).isFalse(); + assertThat(locations.get(id).attempt()).isEqualTo(2); + assertThat(used()).isEqualTo(2); + } + + @Test + void 삭제와_주소변경_후_늦은_실패는_현재_위치를_오염시키지_않는다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Claim old = transactions.claim(target(id)).orElseThrow(); + restaurants.updateByAdmin(id, addressCommand("東京都試験区架空町1-2-4")); + assertThat(transactions.complete(old, Outcome.failure(FailureKind.INVALID_REQUEST))).isFalse(); + Claim current = transactions.claim(target(id)).orElseThrow(); + restaurants.deleteByAdmin(id); + assertThat(transactions.complete(current, ready())).isFalse(); + assertThatThrownBy(() -> locations.get(id)).isInstanceOf(BusinessException.class); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 삭제된_식당의_주소_편집은_허용하되_위치와_작업은_변경하지_않는다(boolean isReady) { + Long id = restaurantPort.createByAdmin(createCommand()).restaurantId(); + Claim old = transactions.claim(target(id)).orElseThrow(); + if (isReady) { + assertThat(transactions.complete(old, ready())).isTrue(); + } + restaurantPort.deleteByAdmin(id); + String snapshotSql = "SELECT l.* FROM restaurant_location l JOIN restaurant r ON r.location_id=l.id WHERE r.id=?"; + var locationBefore = jdbc.queryForMap(snapshotSql, id); + long jobCount = jobs.count(); + var command = addressCommand("東京都試験区架空町1-2-4"); + var response = restaurantPort.updateByAdmin(id, command); + assertThat(response.address()).isEqualTo(command.address()); + assertThat(jobs.count()).isEqualTo(jobCount); + assertThat(jdbc.queryForMap(snapshotSql, id)).isEqualTo(locationBefore); + assertThat(response.locationStatus()).isEqualTo(isReady ? "READY" : "PENDING"); + assertThat(restaurantRepository.findById(id).orElseThrow().isDeleted()).isTrue(); + assertThat(transactions.complete(old, ready())).isFalse(); + assertThatThrownBy(() -> locations.get(id)).isInstanceOf(BusinessException.class); + } + + @Test + void provider_예외는_예산과_재시도를_유지하고_진단에는_클래스명만_남긴다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Target target = target(id); + provider.answer.set(address -> { + throw new IllegalStateException("synthetic-secret-key " + address + " SELECT private_column", + new IllegalArgumentException("synthetic-secret-cause")); + }); + Logger logger = (Logger) LoggerFactory.getLogger(RestaurantLocationWorker.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + worker.process(target); + var status = locations.get(id); + assertThat(status.locationStatus()).isEqualTo("RETRY_WAIT"); + assertThat(status.failureCode()).isEqualTo("TRANSIENT_ERROR"); + assertThat(status.attempt()).isEqualTo(1); + assertThat(status.nextAttemptAt()).isNotNull(); + assertThat(used()).isEqualTo(1); + assertThat(appender.list).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).isEqualTo( + "Location worker failure operation=location-provider-call exceptionType=java.lang.IllegalStateException"); + assertThat(event.getArgumentArray()).containsExactly("java.lang.IllegalStateException"); + assertThat(event.getThrowableProxy()).isNull(); + }); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + + @ParameterizedTest + @EnumSource(value = FailureKind.class, names = {"TRANSIENT_ERROR", "CONNECTION_ERROR", "TIMEOUT", "CAPACITY_EXCEEDED", "CANCELLED"}) + void 자동_재시도는_같은_작업에서_누적되고_여덟번째에_중단한다(FailureKind failure) { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Target target = target(id); + java.util.UUID previousRequest = null; + for (int attempt = 1; attempt <= 8; attempt++) { + Claim claim = transactions.claim(target).orElseThrow(); + assertThat(claim.requestId()).isNotEqualTo(previousRequest); + previousRequest = claim.requestId(); + transactions.complete(claim, Outcome.failure(failure)); + assertThat(locations.get(id).attempt()).isEqualTo(attempt); + if (attempt < 8) { + assertThat(locations.get(id).locationStatus()).isEqualTo("RETRY_WAIT"); + assertThat(rawTime("next_attempt_at", target.jobId())) + .isEqualTo(LocalDateTime.ofInstant(locations.get(id).nextAttemptAt(), ZoneOffset.UTC)); + jdbc.update("UPDATE restaurant_location_job SET reserved_until=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE id=?", + target.jobId()); + due(id, target.jobId()); + } + } + assertThat(locations.get(id).locationStatus()).isEqualTo("FAILED"); + assertThat(locations.get(id).failureCode()).isEqualTo("ATTEMPTS_EXHAUSTED"); + assertThat(transactions.claim(target)).isEmpty(); + assertThat(used()).isEqualTo(8); + assertThat(jobs.findAll()).hasSize(1); + } + + @Test + void 마지막_시도의_429도_다른_식당과_관리자_재처리에_공유_대기를_적용한다() { + Long firstId = restaurants.createByAdmin(createCommand()).restaurantId(); + Long secondId = restaurants.createByAdmin(createCommand()).restaurantId(); + Target first = target(firstId); + for (int attempt = 1; attempt < 8; attempt++) { + transactions.complete(transactions.claim(first).orElseThrow(), Outcome.failure(FailureKind.TRANSIENT_ERROR)); + due(firstId, first.jobId()); + } + transactions.complete(transactions.claim(first).orElseThrow(), Outcome.failure(FailureKind.QUOTA_EXCEEDED)); + assertThat(locations.get(firstId).locationStatus()).isEqualTo("FAILED"); + assertThat(locations.get(firstId).failureCode()).isEqualTo("ATTEMPTS_EXHAUSTED"); + assertThat(transactions.claim(target(secondId))).isEmpty(); + assertThat(jdbc.queryForObject("SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(6), blocked_until) FROM restaurant_geocoding_budget WHERE id=1", + Long.class)).isBetween(86390L, 95040L); + locations.retry(firstId, 1); + assertThat(transactions.claim(target(firstId))).isEmpty(); + assertThat(used()).isEqualTo(8); + } + + @Test + void 로컬_slot_부족과_취소는_주소오류가_아니며_disabled는_자동반복하지_않는다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Claim claim = transactions.claim(target(id)).orElseThrow(); + transactions.complete(claim, Outcome.failure(FailureKind.CAPACITY_EXCEEDED)); + assertThat(locations.get(id).failureCode()).isEqualTo("CAPACITY_EXCEEDED"); + assertThat(locations.get(id).locationStatus()).isEqualTo("RETRY_WAIT"); + locations.retry(id, 1); + assertThat(transactions.complete(claim, ready())).isFalse(); + Claim cancelled = transactions.claim(target(id)).orElseThrow(); + transactions.complete(cancelled, Outcome.failure(FailureKind.CANCELLED)); + assertThat(locations.get(id).locationStatus()).isEqualTo("RETRY_WAIT"); + assertThat(rawTime("reserved_until", cancelled.jobId())).isEqualTo(cancelled.leaseUntil()); + locations.retry(id, 1); + Claim disabled = transactions.claim(target(id)).orElseThrow(); + transactions.complete(disabled, Outcome.failure(FailureKind.DISABLED)); + assertThat(locations.get(id).locationStatus()).isEqualTo("FAILED"); + assertThat(locations.get(id).failureCode()).isEqualTo("DISABLED"); + } + + @Test + void timeout_후_재처리도_이전_실행_slot이_만료되기_전에_호출하지_않는다() { + jdbc.update("UPDATE restaurant_geocoding_budget SET max_concurrent=1 WHERE id=1"); + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Claim old = transactions.claim(target(id)).orElseThrow(); + transactions.complete(old, Outcome.failure(FailureKind.TIMEOUT)); + due(id, old.jobId()); + assertThat(transactions.claim(new Target(id, old.jobId()))).isEmpty(); + locations.retry(id, 1); + assertThat(transactions.claim(target(id))).isEmpty(); + jdbc.update("UPDATE restaurant_location_job SET reserved_until=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE id=?", + old.jobId()); + assertThat(transactions.claim(target(id))).isPresent(); + assertThat(used()).isEqualTo(2); + } + + @Test + void 일일한도와_동시슬롯과_중단과_공유quota_대기를_재처리로_우회하지_못한다() { + Long firstId = restaurants.createByAdmin(createCommand()).restaurantId(); + Long secondId = restaurants.createByAdmin(createCommand()).restaurantId(); + jdbc.update("UPDATE restaurant_geocoding_budget SET max_concurrent=1, daily_limit=1 WHERE id=1"); + Claim first = transactions.claim(target(firstId)).orElseThrow(); + assertThat(transactions.claim(target(secondId))).isEmpty(); + transactions.complete(first, Outcome.failure(FailureKind.QUOTA_EXCEEDED)); + locations.retry(firstId, 1); + assertThat(transactions.claim(target(firstId))).isEmpty(); + assertThat(transactions.claim(target(secondId))).isEmpty(); + jdbc.update("UPDATE restaurant_geocoding_budget SET blocked_until=NULL WHERE id=1"); + assertThat(transactions.claim(target(secondId))).isEmpty(); + jdbc.update("UPDATE restaurant_geocoding_budget SET budget_day=UTC_DATE()-INTERVAL 1 DAY WHERE id=1"); + assertThat(transactions.claim(target(secondId))).isPresent(); + assertThat(used()).isEqualTo(1); + jdbc.update("UPDATE restaurant_geocoding_budget SET enabled=false WHERE id=1"); + assertThat(transactions.claim(target(firstId))).isEmpty(); + } + + @Test + void 숫자_premise_주소는_worker가_한번_확인하고_좌표와_READY를_저장한다() { + provider.answer.set(address -> new GeocodingResult.Candidates( + List.of(LocationAdoptionPolicyTest.numericPremiseCandidate("3")))); + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + assertThat(locations.get(id).locationStatus()).isEqualTo("PENDING"); + worker.process(target(id)); + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + assertThat(locations.get(id).attempt()).isEqualTo(1); + assertThat(locations.get(id).failureCode()).isNull(); + assertThat(readLocation(id).getCoordinates().getLatitude()).isEqualByComparingTo("10.123457"); + assertThat(readLocation(id).getCoordinates().getLongitude()).isEqualByComparingTo("20.765432"); + assertThat(used()).isEqualTo(1); + } + + @Test + void 관리자_재처리는_pending에서_멱등이고_revision과_ready_충돌을_거부한다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Target original = target(id); + assertThat(locations.retry(id, 1).locationStatus()).isEqualTo("PENDING"); + assertThat(target(id)).isEqualTo(original); + assertThatThrownBy(() -> locations.retry(id, 0)).isInstanceOf(BusinessException.class); + worker.process(original); + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + assertThatThrownBy(() -> locations.retry(id, 1)).isInstanceOf(BusinessException.class); + } + + @Test + void 작업_INSERT_실패는_신규식당과_주소변경과_관리자_재처리를_함께_rollback한다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + String originalAddress = restaurantRepository.findById(id).orElseThrow().getAddress(); + Claim claim = transactions.claim(target(id)).orElseThrow(); + transactions.complete(claim, new Outcome(null, null, "NO_RESULTS")); + long count = restaurantRepository.count(); + jdbc.execute(""" + CREATE TRIGGER reject_job BEFORE INSERT ON restaurant_location_job FOR EACH ROW + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic job failure' + """); + try { + assertThatThrownBy(() -> restaurants.createByAdmin(createCommand())).isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> restaurants.updateByAdmin(id, addressCommand("東京都試験区架空町1-2-4"))) + .isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> locations.retry(id, 1)).isInstanceOf(RuntimeException.class); + } finally { + jdbc.execute("DROP TRIGGER reject_job"); + } + assertThat(restaurantRepository.count()).isEqualTo(count); + assertThat(locations.get(id).locationStatus()).isEqualTo("REVIEW_REQUIRED"); + assertThat(locations.get(id).addressRevision()).isEqualTo(1); + assertThat(restaurantRepository.findById(id).orElseThrow().getAddress()) + .isEqualTo(originalAddress); + } + + @Test + void 완료_DB_실패는_lease를_보존하고_만료후_새_worker가_복구한다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Target target = target(id); + Claim claim = transactions.claim(target).orElseThrow(); + jdbc.execute(""" + CREATE TRIGGER reject_location BEFORE UPDATE ON restaurant_location FOR EACH ROW + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic completion failure' + """); + try { + assertThatThrownBy(() -> transactions.complete(claim, ready())).isInstanceOf(RuntimeException.class); + } finally { + jdbc.execute("DROP TRIGGER reject_location"); + } + assertThat(locations.get(id).locationStatus()).isEqualTo("PENDING"); + expire(target.jobId()); + worker.process(target); + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + assertThat(locations.get(id).attempt()).isEqualTo(2); + } + + @Test + void 기존_transaction_호출과_lease_만료_직후의_완료를_거절한다() { + assertThatThrownBy(() -> tx.executeWithoutResult(status -> worker.runOnce())) + .isInstanceOf(IllegalStateException.class); + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Claim claim = transactions.claim(target(id)).orElseThrow(); + expire(claim.jobId()); + assertThat(transactions.complete(claim, ready())).isFalse(); + } + + @Test + void 관리자_위치검토_목록은_현재작업을_조인하고_ID_ASC_offset과_총건수를_반환한다() { + hideExistingRestaurantsFromAdminList(); + Long first = createReviewRequired("OLD_RESULT"); + locations.retry(first, 1); + Claim current = transactions.claim(target(first)).orElseThrow(); + transactions.complete(current, new Outcome(null, null, "CURRENT_RESULT")); + Long second = createReviewRequired("SECOND_RESULT"); + Long third = createReviewRequired("THIRD_RESULT"); + + var firstPage = restaurantPort.findLocationReviewsByAdmin( + "REVIEW_REQUIRED", null, 0, 2); + assertThat(firstPage.restaurants()).extracting("restaurantId").containsExactly(first, second); + assertThat(firstPage.restaurants().getFirst().failureCode()).isEqualTo("CURRENT_RESULT"); + assertThat(firstPage.restaurants().getFirst().attempt()).isEqualTo(1); + assertThat(firstPage.page()).isZero(); + assertThat(firstPage.size()).isEqualTo(2); + assertThat(firstPage.totalCount()).isEqualTo(3); + assertThat(firstPage.totalPages()).isEqualTo(2); + + var secondPage = restaurantPort.findLocationReviewsByAdmin( + "REVIEW_REQUIRED", null, 1, 2); + assertThat(secondPage.restaurants()).extracting("restaurantId").containsExactly(third); + assertThat(secondPage.page()).isEqualTo(1); + assertThat(secondPage.totalCount()).isEqualTo(3); + assertThat(secondPage.totalPages()).isEqualTo(2); + + var missingPage = restaurantPort.findLocationReviewsByAdmin( + "REVIEW_REQUIRED", null, 2, 2); + assertThat(missingPage.restaurants()).isEmpty(); + assertThat(missingPage.page()).isEqualTo(2); + assertThat(missingPage.totalCount()).isEqualTo(3); + assertThat(missingPage.totalPages()).isEqualTo(2); + + var hugePage = restaurantPort.findLocationReviewsByAdmin( + "REVIEW_REQUIRED", null, 1_000_000, 100); + assertThat(hugePage.restaurants()).isEmpty(); + assertThat(hugePage.page()).isEqualTo(1_000_000); + assertThat(hugePage.totalCount()).isEqualTo(3); + assertThat(hugePage.totalPages()).isEqualTo(1); + + assertThatThrownBy(() -> restaurantPort.findLocationReviewsByAdmin( + "REVIEW_REQUIRED", null, -1, 20)).isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> restaurantPort.findLocationReviewsByAdmin( + "REVIEW_REQUIRED", null, Integer.MAX_VALUE, 100)).isInstanceOf(BusinessException.class); + } + + @Test + void 관리자_위치검토_목록은_상태와_source를_필터하고_미해결과_삭제식당을_구분한다() { + hideExistingRestaurantsFromAdminList(); + Long pending = restaurants.createByAdmin(createCommand()).restaurantId(); + + Long ready = restaurants.createByAdmin(createCommand()).restaurantId(); + Claim readyClaim = transactions.claim(target(ready)).orElseThrow(); + transactions.complete(readyClaim, ready()); + + Long retryWait = restaurants.createByAdmin(createCommand()).restaurantId(); + transactions.complete(transactions.claim(target(retryWait)).orElseThrow(), + Outcome.failure(FailureKind.TRANSIENT_ERROR)); + + Long failed = restaurants.createByAdmin(createCommand()).restaurantId(); + transactions.complete(transactions.claim(target(failed)).orElseThrow(), + Outcome.failure(FailureKind.INVALID_REQUEST)); + + Long unresolved = restaurants.createByAdmin(createCommand()).restaurantId(); + jdbc.update("UPDATE restaurant SET location_id=NULL WHERE id=?", unresolved); + + Long deleted = createReviewRequired("DELETED_RESULT"); + restaurantPort.deleteByAdmin(deleted); + + var pendingPage = restaurantPort.findLocationReviewsByAdmin("PENDING", null, 0, 20); + assertThat(pendingPage.restaurants()) + .extracting("restaurantId").containsExactly(pending); + assertThat(pendingPage.totalCount()).isEqualTo(1); + var readyPage = restaurantPort.findLocationReviewsByAdmin( + "READY", "GOOGLE_GEOCODING", 0, 20); + assertThat(readyPage.restaurants()) + .singleElement() + .satisfies(item -> { + assertThat(item.restaurantId()).isEqualTo(ready); + assertThat(item.source()).isEqualTo("GOOGLE_GEOCODING"); + assertThat(item.validUntil()) + .isEqualTo(readyClaim.obtainedAt().plusDays(1).toInstant(ZoneOffset.UTC)); + assertThat(item.canRetry()).isFalse(); + }); + assertThat(readyPage.totalCount()).isEqualTo(1); + var excludedSourcePage = restaurantPort.findLocationReviewsByAdmin("READY", "ADMIN", 0, 20); + assertThat(excludedSourcePage.restaurants()).isEmpty(); + assertThat(excludedSourcePage.totalCount()).isZero(); + assertThat(restaurantPort.findLocationReviewsByAdmin("RETRY_WAIT", null, 0, 20).restaurants()) + .extracting("restaurantId").containsExactly(retryWait); + assertThat(restaurantPort.findLocationReviewsByAdmin("FAILED", null, 0, 20).restaurants()) + .extracting("restaurantId").containsExactly(failed); + assertThat(restaurantPort.findLocationReviewsByAdmin("UNRESOLVED", null, 0, 20).restaurants()) + .singleElement() + .satisfies(item -> { + assertThat(item.restaurantId()).isEqualTo(unresolved); + assertThat(item.addressRevision()).isZero(); + assertThat(item.attempt()).isZero(); + assertThat(item.canRetry()).isTrue(); + }); + assertThat(restaurantPort.findLocationReviewsByAdmin( + "REVIEW_REQUIRED", null, 0, 20).restaurants()) + .isEmpty(); + } + + @Test + void 관리자_위치목록은_JVM_UTC와_JDBC_서울에서도_DATETIME을_UTC로_반환한다() { + hideExistingRestaurantsFromAdminList(); + TimeZone originalTimezone = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + assertThat(TimeZone.getDefault().getID()).isEqualTo("UTC"); + assertThat(jdbc.queryForObject("SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), NOW())", Integer.class)) + .isEqualTo(9 * 60 * 60); + + Long ready = restaurants.createByAdmin(createCommand()).restaurantId(); + transactions.complete(transactions.claim(target(ready)).orElseThrow(), ready()); + Long retryWait = restaurants.createByAdmin(createCommand()).restaurantId(); + transactions.complete(transactions.claim(target(retryWait)).orElseThrow(), + Outcome.failure(FailureKind.TRANSIENT_ERROR)); + + LocalDateTime validUntil = LocalDateTime.parse("2030-03-04T05:06:07.123456"); + LocalDateTime nextAttemptAt = LocalDateTime.parse("2030-04-05T06:07:08.654321"); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id + SET l.valid_until=? WHERE r.id=? + """, validUntil.format(DATABASE_TIME), ready); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id + SET l.next_attempt_at=? WHERE r.id=? + """, nextAttemptAt.format(DATABASE_TIME), retryWait); + + assertThat(restaurantPort.findLocationReviewsByAdmin( + "READY", null, 0, 20).restaurants()) + .singleElement() + .satisfies(item -> assertThat(item.validUntil()) + .isEqualTo(validUntil.toInstant(ZoneOffset.UTC))); + assertThat(restaurantPort.findLocationReviewsByAdmin( + "RETRY_WAIT", null, 0, 20).restaurants()) + .singleElement() + .satisfies(item -> assertThat(item.nextAttemptAt()) + .isEqualTo(nextAttemptAt.toInstant(ZoneOffset.UTC))); + } finally { + TimeZone.setDefault(originalTimezone); + } + } + + @Test + void Places_검색선택은_기존좌표를_지우고_DETAILS성공만_30일좌표로_저장한다() { + hideExistingRestaurantsFromAdminList(); + Long id = restaurants.createByAdmin(createCommandWithGeocodingAddress()).restaurantId(); + provider.answer.set(address -> new GeocodingResult.NoResults()); + worker.process(target(id)); + assertThat(locations.get(id).locationStatus()).isEqualTo("REVIEW_REQUIRED"); + + AtomicReference query = new AtomicReference<>(); + PlacesCandidate candidate = placesCandidate("place-123"); + placesProvider.searchAnswer.set(value -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + query.set(value); + return new PlacesSearchResult.Candidates(List.of(candidate)); + }); + var search = restaurantPort.searchLocationPlacesByAdmin(id, 1); + + assertThat(query.get()).contains("試験", LocationAdoptionPolicyTest.ADDRESS); + assertThat(search.candidates()).singleElement().satisfies(value -> { + assertThat(value.selectionToken()).isNotBlank(); + assertThat(value.selectionExpiresAt()).isAfter(Instant.now()); + }); + assertThat(placesUsed("SEARCH")).isEqualTo(1); + assertThat(placesUsed("DETAILS")).isZero(); + + var pending = restaurantPort.selectLocationPlaceByAdmin(id, 1, search.candidates().getFirst().selectionToken()); + assertThat(pending.locationStatus()).isEqualTo("PENDING"); + assertThat(pending.source()).isNull(); + assertThat(pending.verificationMode()).isEqualTo("PLACE_DETAILS"); + assertThat(pending.confirmedByAdminId()).isEqualTo(7L); + assertThat(pending.confirmationMethod()).isEqualTo("ADMIN_PLACE_SELECTION"); + assertThat(jdbc.queryForObject(""" + SELECT l.latitude IS NULL FROM restaurant r JOIN restaurant_location l ON l.id=r.location_id + WHERE r.id=? + """, Boolean.class, id)).isTrue(); + + placesProvider.detailsAnswer.set(placeId -> new PlaceDetailsResult.Place(candidate)); + worker.process(target(id)); + + var ready = locations.get(id); + assertThat(ready.locationStatus()).isEqualTo("READY"); + assertThat(ready.source()).isEqualTo("GOOGLE_PLACES"); + assertThat(ready.verificationMode()).isEqualTo("PLACE_DETAILS"); + assertThat(placesUsed("DETAILS")).isEqualTo(1); + assertThat(jdbc.queryForObject(""" + SELECT l.google_place_id FROM restaurant r JOIN restaurant_location l ON l.id=r.location_id + WHERE r.id=? + """, String.class, id)).isEqualTo("place-123"); + assertThat(jdbc.queryForObject(""" + SELECT TIMESTAMPDIFF(DAY, l.obtained_at, l.valid_until) FROM restaurant r + JOIN restaurant_location l ON l.id=r.location_id WHERE r.id=? + """, Integer.class, id)).isEqualTo(30); + assertThat(restaurantPort.findLocationReviewsByAdmin("READY", "GOOGLE_PLACES", 0, 20).restaurants()) + .singleElement().satisfies(item -> { + assertThat(item.restaurantId()).isEqualTo(id); + assertThat(item.confirmedByAdminId()).isEqualTo(7L); + }); + } + + @Test + void 확인기록은_방법누락과_관리자누락과_다른revision을_DB에서도_거부한다() { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Long locationId = jdbc.queryForObject("SELECT location_id FROM restaurant WHERE id=?", Long.class, id); + for (String assignment : List.of( + "confirmation_method=NULL, confirmed_by_admin_id=7, confirmed_at=UTC_TIMESTAMP(6), confirmed_revision=1", + "confirmation_method='ADMIN_PLACE_SELECTION ', confirmed_by_admin_id=7, confirmed_at=UTC_TIMESTAMP(6), confirmed_revision=1", + "confirmation_method='ADMIN_PLACE_SELECTION', confirmed_by_admin_id=NULL, confirmed_at=UTC_TIMESTAMP(6), confirmed_revision=1", + "confirmation_method='ADMIN_PLACE_SELECTION', confirmed_by_admin_id=7, confirmed_at=UTC_TIMESTAMP(6), confirmed_revision=2", + "confirmation_method='AUTOMATIC', confirmed_by_admin_id=7, confirmed_at=UTC_TIMESTAMP(6), confirmed_revision=1")) { + assertThatThrownBy(() -> jdbc.update("UPDATE restaurant_location SET " + assignment + " WHERE id=?", locationId)) + .isInstanceOf(org.springframework.dao.DataAccessException.class) + .hasMessageContaining("chk_location_confirmation") + .hasRootCauseInstanceOf(java.sql.SQLException.class); + } + assertThat(locations.get(id).confirmationMethod()).isNull(); + } + + @Test + void 등록전_미리보기는_저장없이_HTTP를_호출하고_등록은_DETAILS작업과_관리자확인을_함께_저장한다() { + var command = createCommandWithGeocodingAddress(); + var input = RestaurantLocationPreviewCommand.from(command); + PlacesCandidate candidate = placesCandidate("preview-place"); + long before = restaurantRepository.count(); + placesProvider.searchAnswer.set(query -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + assertThat(query).contains(command.localName(), command.geocodingAddress()); + return new PlacesSearchResult.Candidates(List.of(candidate)); + }); + var preview = restaurantPort.previewLocationByAdmin(input); + assertThat(restaurantRepository.count()).isEqualTo(before); + assertThat(jobs.count()).isZero(); + assertThat(placesUsed("SEARCH")).isEqualTo(1); + + var created = restaurantPort.createByAdmin(command, preview.getFirst().selectionToken()); + var pending = locations.get(created.restaurantId()); + assertThat(pending.locationStatus()).isEqualTo("PENDING"); + assertThat(pending.source()).isNull(); + assertThat(pending.verificationMode()).isEqualTo("PLACE_DETAILS"); + assertThat(pending.confirmationMethod()).isEqualTo("ADMIN_PLACE_SELECTION"); + assertThat(pending.confirmedByAdminId()).isEqualTo(7L); + assertThat(pending.confirmedRevision()).isEqualTo(1); + assertThat(pending.confirmedAt()).isNotNull(); + assertThat(jobs.count()).isEqualTo(1); + assertThat(used()).isZero(); + placesProvider.detailsAnswer.set(placeId -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + assertThat(placeId).isEqualTo("preview-place"); + return new PlaceDetailsResult.Place(candidate); + }); + worker.process(target(created.restaurantId())); + var ready = locations.get(created.restaurantId()); + assertThat(ready.locationStatus()).isEqualTo("READY"); + assertThat(ready.source()).isEqualTo("GOOGLE_PLACES"); + assertThat(ready.confirmedAt()).isEqualTo(pending.confirmedAt()); + assertThat(placesUsed("DETAILS")).isEqualTo(1); + assertThat(used()).isZero(); + } + + @Test + void 다른관리자나_수정한입력의_토큰으로는_식당과_작업이_만들어지지않는다() { + var command = createCommandWithGeocodingAddress(); + var input = RestaurantLocationPreviewCommand.from(command); + placesProvider.searchAnswer.set(query -> new PlacesSearchResult.Candidates(List.of(placesCandidate("preview")))); + String token = restaurantPort.previewLocationByAdmin(input).getFirst().selectionToken(); + long before = restaurantRepository.count(); + doReturn(new CurrentActor(ActorType.ADMIN, 8L)).when(currentActorProvider).currentActor(); + assertThatThrownBy(() -> restaurantPort.createByAdmin(command, token)) + .isInstanceOfSatisfying(BusinessException.class, error -> + assertThat(error.getErrorCode()).isEqualTo(RestaurantErrorCode.PLACE_SELECTION_CONFLICT)); + doReturn(new CurrentActor(ActorType.ADMIN, 7L)).when(currentActorProvider).currentActor(); + for (var changed : List.of( + new RestaurantLocationPreviewCommand(input.name() + "지점", input.localName(), input.address(), input.geocodingAddress()), + new RestaurantLocationPreviewCommand(input.name(), input.localName() + "店", input.address(), input.geocodingAddress()), + new RestaurantLocationPreviewCommand(input.name(), input.localName(), input.address() + "2F", input.geocodingAddress()), + new RestaurantLocationPreviewCommand(input.name(), input.localName(), input.address(), null))) { + assertThatThrownBy(() -> places.verifyForCreate(changed, token)) + .isInstanceOfSatisfying(BusinessException.class, error -> + assertThat(error.getErrorCode()).isEqualTo(RestaurantErrorCode.PLACE_SELECTION_CONFLICT)); + } + assertThatThrownBy(() -> restaurantPort.createByAdmin(command, "arbitrary-place-id")) + .isInstanceOfSatisfying(BusinessException.class, error -> + assertThat(error.getErrorCode()).isEqualTo(RestaurantErrorCode.PLACE_SELECTION_INVALID)); + assertThat(restaurantRepository.count()).isEqualTo(before); + assertThat(jobs.count()).isZero(); + } + + @Test + void 미리보기토큰없이_등록할수있고_후보선택_작업저장실패는_식당생성도_롤백한다() { + var ordinary = restaurants.createByAdmin(createCommand()); + assertThat(locations.get(ordinary.restaurantId()).verificationMode()).isEqualTo("GEOCODING"); + var command = createCommandWithGeocodingAddress(); + placesProvider.searchAnswer.set(query -> new PlacesSearchResult.Candidates(List.of(placesCandidate("preview")))); + String token = restaurantPort.previewLocationByAdmin(RestaurantLocationPreviewCommand.from(command)) + .getFirst().selectionToken(); + long before = restaurantRepository.count(); + long jobsBefore = jobs.count(); + doAnswer(invocation -> { throw new IllegalStateException("synthetic job save failure"); }) + .when(jobs).save(org.mockito.ArgumentMatchers.any()); + assertThatThrownBy(() -> restaurantPort.createByAdmin(command, token)).isInstanceOf(IllegalStateException.class); + assertThat(restaurantRepository.count()).isEqualTo(before); + assertThat(jobs.count()).isEqualTo(jobsBefore); + } + + @Test + void 최대길이_현지명과_기본주소도_Port를_거쳐_잘림없이_검색한다() { + Long id = placesReviewRequiredRestaurant(); + String localName = "가".repeat(100); + String address = "東".repeat(255); + jdbc.update("UPDATE restaurant SET local_name=?, geocoding_address=? WHERE id=?", localName, address, id); + AtomicReference sent = new AtomicReference<>(); + placesProvider.searchAnswer.set(value -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + sent.set(value); + return new PlacesSearchResult.NoResults(); + }); + + assertThat(restaurantPort.searchLocationPlacesByAdmin(id, 1).candidates()).isEmpty(); + assertThat(sent.get()).isEqualTo(localName + " " + address); + assertThat(placesUsed("SEARCH")).isEqualTo(1); + } + + @Test + void PENDING은_후보검색을_provider호출과_SEARCH예산사용전에_거부한다() { + Long id = restaurants.createByAdmin(createCommandWithGeocodingAddress()).restaurantId(); + AtomicInteger calls = new AtomicInteger(); + placesProvider.searchAnswer.set(query -> { + calls.incrementAndGet(); + return new PlacesSearchResult.NoResults(); + }); + + assertThatThrownBy(() -> places.search(id, 1)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.PLACE_SELECTION_CONFLICT)); + assertThat(calls).hasValue(0); + assertThat(placesUsed("SEARCH")).isZero(); + } + + @Test + void 제3자_attribution후보는_token을_발급하고_DETAILS성공시_좌표와_함께_저장한다() { + Long id = placesReviewRequiredRestaurant(); + PlacesCandidate base = placesCandidate("place-attribution"); + PlacesCandidate attributed = new PlacesCandidate(base.placeId(), base.displayName(), base.address(), + base.latitude(), base.longitude(), base.countryCode(), base.administrativeArea(), base.types(), + base.businessStatus(), List.of(new PlacesAttribution("Third party", "https://example.com")), + base.googleMapsUri()); + placesProvider.searchAnswer.set(query -> new PlacesSearchResult.Candidates(List.of(attributed))); + + var search = places.search(id, 1); + assertThat(search.candidates()).singleElement() + .satisfies(value -> assertThat(value.attributions()).singleElement()); + places.select(id, 1, search.candidates().getFirst().selectionToken()); + placesProvider.detailsAnswer.set(placeId -> new PlaceDetailsResult.Place(attributed)); + worker.process(target(id)); + + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + assertThat(jdbc.queryForObject(""" + SELECT JSON_UNQUOTE(JSON_EXTRACT(l.places_attributions, '$[0].displayName')) + FROM restaurant r JOIN restaurant_location l ON l.id=r.location_id WHERE r.id=? + """, String.class, id)).isEqualTo("Third party"); + assertThat(placesUsed("SEARCH")).isEqualTo(1); + } + + @Test + void Places_DETAILS_실패의_수동재시도는_같은_PlaceID와_operation을_복제한다() { + Long id = placesReviewRequiredRestaurant(); + String token = searchCandidate(id, placesCandidate("place-retry")).selectionToken(); + places.select(id, 1, token); + placesProvider.detailsAnswer.set(placeId -> new PlaceDetailsResult.Failure(FailureKind.TRANSIENT_ERROR, 503)); + worker.process(target(id)); + assertThat(locations.get(id).locationStatus()).isEqualTo("RETRY_WAIT"); + + restaurantPort.retryLocationByAdmin(id, 1); + + var job = jobs.findAll().stream().filter(value -> value.getRestaurantId().equals(id)) + .max(java.util.Comparator.comparing(org.sopt.hashi.restaurant.domain.RestaurantLocationJob::getId)) + .orElseThrow(); + assertThat(job.getOperation()).isEqualTo( + org.sopt.hashi.restaurant.domain.RestaurantLocationJob.Operation.PLACE_DETAILS); + assertThat(job.getGooglePlaceId()).isEqualTo("place-retry"); + assertThat(locations.get(id).verificationMode()).isEqualTo("PLACE_DETAILS"); + } + + @Test + void READY에서_다른_Places후보를_선택하면_revision은_유지하고_기존좌표와_attribution을_즉시지운다() { + Long id = placesReviewRequiredRestaurant(); + PlacesCandidate first = placesCandidate("place-first"); + places.select(id, 1, searchCandidate(id, first).selectionToken()); + placesProvider.detailsAnswer.set(placeId -> new PlaceDetailsResult.Place(first)); + worker.process(target(id)); + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + + PlacesCandidate replacement = placesCandidate("place-replacement"); + places.select(id, 1, searchCandidate(id, replacement).selectionToken()); + + assertThat(locations.get(id).addressRevision()).isEqualTo(1); + assertThat(locations.get(id).locationStatus()).isEqualTo("PENDING"); + assertThat(jdbc.queryForMap(""" + SELECT l.latitude, l.longitude, l.source, l.google_place_id, l.places_attributions + FROM restaurant r JOIN restaurant_location l ON l.id=r.location_id WHERE r.id=? + """, id).values()).allMatch(java.util.Objects::isNull); + } + + @Test + void 변조된_후보token은_상태와_DETAILS예산을_바꾸지않는다() { + Long id = placesReviewRequiredRestaurant(); + String token = searchCandidate(id, placesCandidate("place-tamper")).selectionToken(); + String tampered = token.substring(0, token.length() - 1) + + (token.endsWith("A") ? "B" : "A"); + + assertThatThrownBy(() -> places.select(id, 1, tampered)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.PLACE_SELECTION_INVALID)); + assertThat(locations.get(id).locationStatus()).isEqualTo("REVIEW_REQUIRED"); + assertThat(placesUsed("DETAILS")).isZero(); + } + + @Test + void 동일한_이름수정은_선택확인을_유지하고_실제이름변경은_새_Geocoding작업으로_전환한다() { + Long id = placesReviewRequiredRestaurant(); + PlacesCandidate candidate = placesCandidate("selected-name"); + places.select(id, 1, searchCandidate(id, candidate).selectionToken()); + placesProvider.detailsAnswer.set(placeId -> new PlaceDetailsResult.Place(candidate)); + worker.process(target(id)); + String name = restaurantRepository.findById(id).orElseThrow().getName(); + long previousJobs = jobs.count(); + restaurants.updateByAdmin(id, new AdminRestaurantCommand(name, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null)); + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + assertThat(locations.get(id).confirmedByAdminId()).isEqualTo(7L); + assertThat(jobs.count()).isEqualTo(previousJobs); + + restaurants.updateByAdmin(id, new AdminRestaurantCommand(name + "다른 지점", null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null)); + var pending = locations.get(id); + assertThat(pending.addressRevision()).isEqualTo(2); + assertThat(pending.locationStatus()).isEqualTo("PENDING"); + assertThat(pending.verificationMode()).isEqualTo("GEOCODING"); + assertThat(pending.source()).isNull(); + assertThat(pending.confirmedByAdminId()).isNull(); + assertThat(pending.confirmationMethod()).isNull(); + assertThat(jobs.count()).isEqualTo(previousJobs + 1); + } + + @Test + void 오래된_DETAILS결과는_주소변경뒤_좌표를_덮어쓰지못한다() { + Long id = placesReviewRequiredRestaurant(); + PlacesCandidate candidate = placesCandidate("place-stale"); + places.select(id, 1, searchCandidate(id, candidate).selectionToken()); + Claim stale = transactions.claim(target(id)).orElseThrow(); + + restaurants.updateByAdmin(id, addressCommand("東京都試験区架空町9-9-9")); + + assertThat(transactions.completePlaces(stale, + new LocationJobTransactions.PlacesOutcome( + new PlacesCandidatePolicy(LocationAdoptionPolicyTest.properties()).coordinates(candidate), + candidate.placeId(), List.of(), null, null))).isFalse(); + assertThat(locations.get(id).source()).isNull(); + assertThat(locations.get(id).verificationMode()).isEqualTo("GEOCODING"); + } + + @Test + void SEARCH예산이_닫혀도_이미선택한_DETAILS예산과_worker는_독립적으로_동작한다() { + Long id = placesReviewRequiredRestaurant(); + PlacesCandidate candidate = placesCandidate("place-budget"); + places.select(id, 1, searchCandidate(id, candidate).selectionToken()); + jdbc.update("UPDATE restaurant_places_budget SET enabled=false WHERE operation='SEARCH'"); + placesProvider.detailsAnswer.set(placeId -> new PlaceDetailsResult.Place(candidate)); + int geocodingUsed = used(); + + worker.runOnce(); + + assertThat(locations.get(id).locationStatus()).isEqualTo("READY"); + assertThat(placesUsed("DETAILS")).isEqualTo(1); + assertThat(used()).isEqualTo(geocodingUsed); + } + + private Long createReviewRequired(String failureCode) { + Long id = restaurants.createByAdmin(createCommand()).restaurantId(); + Claim claim = transactions.claim(target(id)).orElseThrow(); + transactions.complete(claim, new Outcome(null, null, failureCode)); + return id; + } + + private void hideExistingRestaurantsFromAdminList() { + // 이 클래스는 test transaction을 쓰지 않는다. offset 목록은 이전 테스트가 남긴 활성 식당도 포함하므로 + // 현재 목록 fixture의 page/count 기대값을 독립시키되 production과 같은 soft-delete 조건을 사용한다. + jdbc.update("UPDATE restaurant SET deleted=true WHERE deleted=false"); + } + + private Long placesReviewRequiredRestaurant() { + Long id = restaurants.createByAdmin(createCommandWithGeocodingAddress()).restaurantId(); + provider.answer.set(address -> new GeocodingResult.NoResults()); + worker.process(target(id)); + return id; + } + + private org.sopt.hashi.restaurant.RestaurantPlacesCandidateInfo searchCandidate( + Long restaurantId, PlacesCandidate candidate) { + placesProvider.searchAnswer.set(query -> new PlacesSearchResult.Candidates(List.of(candidate))); + return places.search(restaurantId, 1).candidates().getFirst(); + } + + private PlacesCandidate placesCandidate(String placeId) { + return new PlacesCandidate(placeId, "試験 식당", LocationAdoptionPolicyTest.ADDRESS, + new BigDecimal("10.500000"), new BigDecimal("20.500000"), "JP", "Tokyo", + List.of("restaurant", "food"), "OPERATIONAL", List.of(), + "https://maps.google.com/?cid=123"); + } + + private String saveLocationJob(AdminOperation operation, Long id) { + return switch (operation) { + case CREATE -> restaurantPort.createByAdmin(createCommand()).locationStatus(); + case ADDRESS_UPDATE -> restaurantPort.updateByAdmin(id, addressCommand("東京都試験区架空町1-2-4")) + .locationStatus(); + case RETRY -> restaurantPort.retryLocationByAdmin(id, 0).locationStatus(); + }; + } + + private Target target(Long id) { + return jobs.findAll().stream().filter(job -> job.getRestaurantId().equals(id)) + .max(java.util.Comparator.comparing(org.sopt.hashi.restaurant.domain.RestaurantLocationJob::getId)) + .map(job -> new Target(id, job.getId())).orElseThrow(); + } + + private Outcome ready() { + return new Outcome(new LocationAdoptionPolicy(LocationAdoptionPolicyTest.properties()) + .evaluate(LocationAdoptionPolicyTest.ADDRESS, + new GeocodingResult.Candidates(List.of(LocationAdoptionPolicyTest.candidate()))).coordinates(), null, null); + } + + private int used() { + return jdbc.queryForObject("SELECT reserved_calls FROM restaurant_geocoding_budget WHERE id=1", Integer.class); + } + + private int placesUsed(String operation) { + return jdbc.queryForObject("SELECT daily_used FROM restaurant_places_budget WHERE operation=?", + Integer.class, operation); + } + + private LocalDateTime rawTime(String column, Long jobId) { + return LocalDateTime.parse(jdbc.queryForObject("SELECT DATE_FORMAT(" + column + + ", '%Y-%m-%dT%H:%i:%s.%f') FROM restaurant_location_job WHERE id=?", String.class, jobId)); + } + + private static String format(LocalDateTime time) { + return time.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS")); + } + + private void expire(Long jobId) { + jdbc.update(""" + UPDATE restaurant_location_job SET lease_until=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND, + reserved_until=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE id=? + """, jobId); + } + + private void due(Long restaurantId, Long jobId) { + jdbc.update("UPDATE restaurant_location_job SET next_attempt_at=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE id=?", jobId); + jdbc.update(""" + UPDATE restaurant_location l JOIN restaurant r ON r.location_id=l.id + SET l.next_attempt_at=UTC_TIMESTAMP(6)-INTERVAL 1 SECOND WHERE r.id=? + """, restaurantId); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(15, TimeUnit.SECONDS)) { + throw new AssertionError("synthetic latch timed out"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + } + + private AdminRestaurantCommand createCommand() { + int sequence = RESTAURANT_SEQUENCE.incrementAndGet(); + return new AdminRestaurantCommand("합성 식당 " + sequence, "試験", "요약", "설명", + LocationAdoptionPolicyTest.ADDRESS.replace("試験区", " ".repeat(sequence) + "試験区"), + "표시 지역", "sushi", "초밥", "restaurant", "JPY", BigDecimal.ONE, BigDecimal.TEN, + List.of("restaurants/synthetic.jpg"), null, null, null, List.of("합성"), List.of(), + Arrays.stream(DayOfWeek.values()).map(day -> new BusinessHourCommand(day, null, null, null, null, true)).toList()); + } + + private AdminRestaurantCommand createCommandWithGeocodingAddress() { + int sequence = RESTAURANT_SEQUENCE.incrementAndGet(); + String displayAddress = LocationAdoptionPolicyTest.ADDRESS.replace( + "試験区", " ".repeat(sequence) + "試験区") + " 架空ビル" + sequence + " 1F"; + return new AdminRestaurantCommand("합성 식당 " + sequence, "試験", "요약", "설명", + displayAddress, LocationAdoptionPolicyTest.ADDRESS, "표시 지역", "sushi", "초밥", + "restaurant", "JPY", BigDecimal.ONE, BigDecimal.TEN, + List.of("restaurants/synthetic.jpg"), null, null, null, List.of("합성"), List.of(), + Arrays.stream(DayOfWeek.values()) + .map(day -> new BusinessHourCommand(day, null, null, null, null, true)).toList()); + } + + private AdminRestaurantCommand geocodingAddressCommand(String geocodingAddress) { + return new AdminRestaurantCommand(null, null, null, null, null, geocodingAddress, + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + } + + private AdminRestaurantCommand addressAndGeocodingAddressCommand(String address, String geocodingAddress) { + return new AdminRestaurantCommand(null, null, null, null, address, geocodingAddress, + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + } + + private AdminRestaurantCommand addressCommand(String address) { + String uniqueAddress = address.replace("試験区", " ".repeat(RESTAURANT_SEQUENCE.incrementAndGet()) + "試験区"); + return new AdminRestaurantCommand(null, null, null, null, uniqueAddress, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); + } + + enum AdminOperation { CREATE, ADDRESS_UPDATE, RETRY } + + static class FakeProvider implements GeocodingProvider { + final AtomicReference> answer = new AtomicReference<>(); + + @Override + public GeocodingResult geocode(String address) { + return answer.get().apply(address); + } + } + + static class FakePlacesProvider implements PlacesProvider { + final AtomicReference> searchAnswer = new AtomicReference<>(); + final AtomicReference> detailsAnswer = new AtomicReference<>(); + + @Override + public PlacesSearchResult search(String query) { + return searchAnswer.get().apply(query); + } + + @Override + public PlaceDetailsResult details(String placeId) { + return detailsAnswer.get().apply(placeId); + } + } + + @TestConfiguration(proxyBeanMethods = false) + static class Fixtures { + @Bean LocationJobProperties jobProperties() { return LocationAdoptionPolicyTest.properties(); } + @Bean FakeProvider fakeProvider() { return new FakeProvider(); } + @Bean FakePlacesProvider fakePlacesProvider() { return new FakePlacesProvider(); } + @Bean GooglePlacesProperties googlePlacesProperties() { + return new GooglePlacesProperties(true, "test-key", Duration.ofSeconds(1), Duration.ofSeconds(2), 65536); + } + @Bean PlacesSelectionProperties placesSelectionProperties() { + String key = java.util.Base64.getEncoder().encodeToString(new byte[32]); + return new PlacesSelectionProperties(true, key, Duration.ofMinutes(10)); + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/LocationRetryPolicyTest.java b/src/test/java/org/sopt/hashi/restaurant/service/LocationRetryPolicyTest.java new file mode 100644 index 00000000..778e19aa --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/LocationRetryPolicyTest.java @@ -0,0 +1,41 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult.FailureKind; + +class LocationRetryPolicyTest { + private final LocationRetryPolicy policy = new LocationRetryPolicy(); + + @ParameterizedTest + @EnumSource(value = FailureKind.class, names = {"TIMEOUT", "CONNECTION_ERROR", "TRANSIENT_ERROR", + "QUOTA_EXCEEDED", "CAPACITY_EXCEEDED", "CANCELLED"}) + void 일시_오류는_합의한_간격과_최대_10퍼센트_지연을_사용한다(FailureKind kind) { + var seconds = List.of(60L, 300L, 1800L, 7200L, 21600L, 43200L, 86400L, 86400L); + for (int attempt = 1; attempt <= 8; attempt++) { + long base = seconds.get(attempt - 1); + assertThat(policy.delay(kind, attempt)).isBetween(Duration.ofSeconds(base), + Duration.ofSeconds(base + base / 10)); + } + } + + @ParameterizedTest + @EnumSource(value = FailureKind.class, names = {"DISABLED", "INVALID_REQUEST", "ACCESS_DENIED", + "CONFIGURATION_ERROR", "INVALID_RESPONSE", "RESPONSE_TOO_LARGE", "REDIRECT_REJECTED"}) + void 영구_오류는_자동_재시도하지_않는다(FailureKind kind) { + assertThat(policy.canRetry(kind)).isFalse(); + assertThatThrownBy(() -> policy.delay(kind, 1)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 잘못된_시도_횟수는_거절한다() { + assertThatThrownBy(() -> policy.delay(FailureKind.TIMEOUT, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> policy.delay(FailureKind.TIMEOUT, 9)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/MapQueryLoadTest.java b/src/test/java/org/sopt/hashi/restaurant/service/MapQueryLoadTest.java new file mode 100644 index 00000000..76554edc --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/MapQueryLoadTest.java @@ -0,0 +1,328 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zaxxer.hikari.HikariDataSource; +import io.micrometer.core.instrument.MeterRegistry; +import java.math.BigDecimal; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import javax.sql.DataSource; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.domain.PriceCurrency; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantPlaceType; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.internal.map.MapSessionLimits; +import org.sopt.hashi.shared.storage.FileStorage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** Opt-in, loopback-only experiment. This is not a production capacity estimate. */ +@Tag("map-load") +@Testcontainers +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "server.address=127.0.0.1", "spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test", + "hashi.map.location-job.enabled=false", "hashi.map.google-geocoding.enabled=false", + "server.tomcat.remoteip.internal-proxies=127\\.0\\.0\\.1|0:0:0:0:0:0:0:1", + "hashi.restaurant.map.initial-bounds.south=10", "hashi.restaurant.map.initial-bounds.north=11", + "hashi.restaurant.map.initial-bounds.west=20", "hashi.restaurant.map.initial-bounds.east=21", + "hashi.restaurant.map.supported-bounds.south=10", "hashi.restaurant.map.supported-bounds.north=11", + "hashi.restaurant.map.supported-bounds.west=20", "hashi.restaurant.map.supported-bounds.east=21", + "hashi.restaurant.map.session.enabled=true", + "hashi.restaurant.map.session.signing-key=c3ludGhldGljLW1hcC10ZXN0LWtleS0zMi1ieXRlcy1vbmx5", + "hashi.restaurant.map.session.limits.idle-timeout=10s", + "hashi.restaurant.map.session.limits.max-lifetime=30s", + "hashi.restaurant.map.session.limits.requests-per-caller=600", + "hashi.restaurant.map.session.limits.new-queries-per-caller=500", + "hashi.restaurant.map.session.limits.requests-per-minute=3000" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class MapQueryLoadTest { + private static final String PROFILE = System.getProperty("map.k6.profile", "smoke"); + private static final String TRAFFIC = System.getProperty("map.k6.traffic", "shared"); + private static final int RESTAURANTS = Integer.parseInt(System.getProperty("map.k6.restaurants", "620")); + private static final int BUDGET_MIB = Integer.parseInt(System.getProperty("map.k6.budget-mib", "16")); + private static final int CONCURRENT_REQUESTS = Integer.parseInt(System.getProperty( + "hashi.restaurant.map.session.limits.concurrent-requests", "4")); + private static final int IDLE_SECONDS = "ttl".equals(PROFILE) ? 300 : 10; + private static final int WORKLOAD_SECONDS = "smoke".equals(PROFILE) ? 90 : 300; + private static final String QUERIES = "hashi:restaurant:map:{sessions-v2}:query:*"; + private static final String SENTINEL = "map-load:auth-sentinel"; + private static final String REJECTION_METRIC = "hashi.restaurant.map.capacity.rejected"; + private static final List REJECTION_REASONS = List.of("concurrent_requests", "candidate_count", + "snapshot_bytes", "total_bytes", "session_count", "caller_rate", "caller_cardinality", + "global_requests", "redis_memory_guard"); + + @Container @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("map_load").withUsername("hashi").withPassword("hashi"); + @Container @ServiceConnection(name = "redis") + static final GenericContainer AUTH_REDIS = new GenericContainer<>(DockerImageName.parse( + "redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499")) + .withExposedPorts(6379) + .withCommand("redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "volatile-lru") + .withCreateContainerCmdModifier(command -> command.getHostConfig() + .withMemory(128L * 1024 * 1024).withMemorySwap(128L * 1024 * 1024)); + @Container + static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse( + "redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499")) + .withExposedPorts(6379) + .withCommand("redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "noeviction") + .withCreateContainerCmdModifier(command -> command.getHostConfig() + .withMemory(256L * 1024 * 1024).withMemorySwap(256L * 1024 * 1024)); + + @DynamicPropertySource + static void mapRedis(DynamicPropertyRegistry registry) { + registry.add("hashi.restaurant.map.redis.host", REDIS::getHost); + registry.add("hashi.restaurant.map.redis.port", () -> REDIS.getMappedPort(6379)); + } + + @LocalServerPort int port; + @Autowired RestaurantRepository restaurants; + @Autowired TransactionTemplate transactions; + @Autowired StringRedisTemplate authRedis; + private StringRedisTemplate redis; + private LettuceConnectionFactory mapObservation; + @Autowired MapSessionLimits limits; + @Autowired ObjectMapper json; + @Autowired DataSource dataSource; + @Autowired MeterRegistry meterRegistry; + @MockitoBean MediaPort mediaPort; + @MockitoBean FileStorage fileStorage; + + @BeforeEach + void connectMapObservation() { + // 테스트 관측 전용. Spring bean으로 등록해 인증 자동 구성을 바꾸지 않는다. + mapObservation = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); + mapObservation.afterPropertiesSet(); + redis = new StringRedisTemplate(mapObservation); + } + + @AfterEach + void closeMapObservation() { + if (mapObservation != null) mapObservation.destroy(); + } + + @Test + void 실제_HTTP_혼합부하_후_유휴세션이_정리되고_제한해제후_복구된다() throws Exception { + assertThat(PROFILE).isIn("smoke", "normal", "staged", "ttl"); + assertThat(TRAFFIC).isIn("shared", "distinct"); + assertThat(RESTAURANTS).isBetween(620, 5000); + assertThat(BUDGET_MIB).isIn(16, 32); + assertThat(CONCURRENT_REQUESTS).isIn(4, 8); + assertThat(limits.getConcurrentRequests()).isEqualTo(CONCURRENT_REQUESTS); + limits.setTotalBytes(BUDGET_MIB * 1_048_576L); + if ("normal".equals(PROFILE) || "ttl".equals(PROFILE)) { + assertThat(TRAFFIC).as("paced profiles model distinct callers").isEqualTo("distinct"); + limits.setRequestsPerCaller(120); + limits.setNewQueriesPerCaller(12); + limits.setRequestsPerMinute(600); + } + limits.setIdleTimeout(Duration.ofSeconds(IDLE_SECONDS)); + limits.setMaxLifetime(Duration.ofSeconds("ttl".equals(PROFILE) ? 1800 : 30)); + Path output = Path.of(System.getProperty("map.k6.output")); + Files.createDirectories(output); + for (String file : List.of("k6-summary.json", "k6.log", "resource-samples.json", "recovery.json")) { + Files.deleteIfExists(output.resolve(file)); + } + seedRestaurants(); + authRedis.opsForValue().set(SENTINEL, "synthetic-auth-value", Duration.ofDays(1)); + assertThat(redis.hasKey(SENTINEL)).isFalse(); + String base = "http://127.0.0.1:" + port; + List> samples = new ArrayList<>(); + int k6Exit; + samples.add(sample()); + try (HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()) { + assertThat(request(client, base).statusCode()).isEqualTo(200); + ProcessBuilder builder = new ProcessBuilder(System.getProperty("map.k6.executable"), "run", "--no-usage-report", + "--summary-export", output.resolve("k6-summary.json").toString(), + System.getProperty("map.k6.script")); + builder.environment().remove("HASHI_MAP_GOOGLEGEOCODING_APIKEY"); + builder.environment().put("BASE_URL", base); + builder.environment().put("MAP_LOAD_PROFILE", PROFILE); + builder.environment().put("MAP_LOAD_TRAFFIC", TRAFFIC); + builder.redirectErrorStream(true).redirectOutput(output.resolve("k6.log").toFile()); + Process process = builder.start(); + long deadline = System.nanoTime() + Duration.ofSeconds(WORKLOAD_SECONDS + 60).toNanos(); + try { + while (!process.waitFor(1, TimeUnit.SECONDS)) { + samples.add(sample()); + assertThat(System.nanoTime()).as("k6 must finish within workload plus sixty seconds").isLessThan(deadline); + } + k6Exit = process.exitValue(); // Keep all failures; still measure cleanup/recovery before asserting. + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + json.writeValue(output.resolve("resource-samples.json").toFile(), samples); + } + Map workloadRejections = capacityRejections(); + Map workloadStages = stageDurations(); + assertThat(samples).isNotEmpty(); + assertThat(samples.stream().mapToInt(sample -> ((Number) sample.get("sessions")).intValue()) + .max().orElseThrow()).isPositive(); + awaitSessionCleanup(samples); + assertThat(authRedis.opsForValue().get(SENTINEL)).isEqualTo("synthetic-auth-value"); + assertThat(authRedis.keys(QUERIES)).isEmpty(); + // Prove the limit rejects safely and releases naturally without flushing shared Redis keys. + // The retention profile was measured unchanged above; shorten only this separate rejection probe. + limits.setIdleTimeout(Duration.ofSeconds(10)); + limits.setMaxLifetime(Duration.ofSeconds(30)); + limits.setSessions(2); + if ("shared".equals(TRAFFIC) && !"smoke".equals(PROFILE)) { + // A saturated caller window lasts up to one minute. Do not erase admission Redis data. + for (int second = 0; second < 65; second++) { + samples.add(sample()); + Thread.sleep(1000); + } + } + assertThat(request(client, base).statusCode()).isEqualTo(200); + assertThat(request(client, base).statusCode()).isEqualTo(200); + HttpResponse rejected = request(client, base); + assertThat(rejected.statusCode()).isEqualTo(503); + assertThat(json.readTree(rejected.body()).path("code").asText()).isEqualTo("RESTAURANT-016"); + awaitSessionCleanup(samples); + assertThat(request(client, base).statusCode()).isEqualTo(200); + assertThat(authRedis.opsForValue().get(SENTINEL)).isEqualTo("synthetic-auth-value"); + assertThat(authRedis.keys(QUERIES)).isEmpty(); + json.writeValue(output.resolve("recovery.json").toFile(), Map.ofEntries( + Map.entry("syntheticRestaurants", RESTAURANTS), Map.entry("workloadSeconds", WORKLOAD_SECONDS), + Map.entry("idleTimeoutSeconds", IDLE_SECONDS), Map.entry("profile", PROFILE), + Map.entry("traffic", TRAFFIC), Map.entry("sessionBudgetMiB", BUDGET_MIB), + Map.entry("k6Exit", k6Exit), Map.entry("capacityRejected", true), + Map.entry("recoveredAfterExpiry", true), Map.entry("authSentinelPreserved", true), + Map.entry("workloadCapacityRejections", workloadRejections), + Map.entry("workloadStageDurations", workloadStages), + Map.entry("finalCapacityRejections", capacityRejections()))); + json.writeValue(output.resolve("resource-samples.json").toFile(), samples); + assertThat(k6Exit).as("Original k6 thresholds remain enforced; see rejection counters and k6.log").isZero(); + } + } + + private HttpResponse request(HttpClient client, String base) throws Exception { + return client.send(HttpRequest.newBuilder(URI.create(base + + "/api/v1/restaurants/map?south=10&north=11&west=20&east=21")) + .timeout(Duration.ofSeconds(10)).GET().build(), HttpResponse.BodyHandlers.ofString()); + } + + private Map sample() { + try (var connection = redis.getConnectionFactory().getConnection()) { + var memory = connection.serverCommands().info("memory"); + var hikari = (HikariDataSource) dataSource; + var pool = hikari.getHikariPoolMXBean(); + var ledger = redis.opsForHash().values("hashi:restaurant:map:{sessions-v2}:admission"); + var requestLedger = "hashi:restaurant:map:{sessions-v2}:requests"; + Object requestTotal = redis.opsForHash().get(requestLedger, "total"); + long requestFields = redis.opsForHash().size(requestLedger); + long recordedBytes = ledger.stream().map(Object::toString) + .mapToLong(value -> Long.parseLong(value.substring(value.indexOf(':') + 1))).sum(); + return Map.ofEntries( + Map.entry("elapsedAt", System.currentTimeMillis()), + Map.entry("sessions", redis.keys(QUERIES).size()), + Map.entry("redisUsedBytes", Long.parseLong(memory.getProperty("used_memory"))), + Map.entry("dbActive", pool.getActiveConnections()), + Map.entry("dbWaiting", pool.getThreadsAwaitingConnection()), + Map.entry("dbMaximumPoolSize", hikari.getMaximumPoolSize()), + Map.entry("ledgerRecordedBytes", recordedBytes), + Map.entry("ledgerEntries", ledger.size()), + Map.entry("requestWindowTotal", requestTotal == null ? 0 : Long.parseLong(requestTotal.toString())), + Map.entry("requestCallerEntries", Math.max(0, requestFields - 2)), + Map.entry("capacityRejections", capacityRejections()), + Map.entry("concurrentRequestLimit", limits.getConcurrentRequests()), + Map.entry("ledgerBudgetBytes", limits.getTotalBytes()), + Map.entry("sessionLimit", limits.getSessions())); + } + } + + private Map capacityRejections() { + var counts = new LinkedHashMap(); + for (String reason : REJECTION_REASONS) { + var counter = meterRegistry.find(REJECTION_METRIC).tag("reason", reason).counter(); + counts.put(reason, counter == null ? 0 : counter.count()); + } + return counts; + } + + /** Includes the initial HTTP readiness request; captured before cleanup/recovery probes. */ + private Map stageDurations() { + var durations = new LinkedHashMap(); + for (String operation : List.of("new_query", "sort_change", "next_page")) { + for (String stage : List.of("admit", "candidates", "save", "load_session", "read_page", "touch")) { + var timer = meterRegistry.find("hashi.restaurant.map.stage.duration") + .tags("operation", operation, "stage", stage).timer(); + if (timer != null && timer.count() > 0) { + durations.put(operation + ":" + stage, Map.of( + "count", timer.count(), "meanMs", timer.mean(TimeUnit.MILLISECONDS), + "totalMs", timer.totalTime(TimeUnit.MILLISECONDS))); + } + } + } + return durations; + } + + private void awaitSessionCleanup(List> samples) throws InterruptedException { + long deadline = System.nanoTime() + limits.getIdleTimeout().plusSeconds(15).toNanos(); + while (!redis.keys(QUERIES).isEmpty() && System.nanoTime() < deadline) { + samples.add(sample()); + Thread.sleep(1000); + } + assertThat(redis.keys(QUERIES)).isEmpty(); + samples.add(sample()); + } + + private void seedRestaurants() { + Clock clock = Clock.systemUTC(); + transactions.executeWithoutResult(status -> { + for (int index = 0; index < RESTAURANTS; index++) { + Restaurant restaurant = Restaurant.create("합성 부하 식당 " + index, "試験", "요약", "설명", + "東京都試験区架空町1丁目2番3号", "합성 지역", RestaurantGenre.SUSHI, "초밥", + RestaurantPlaceType.RESTAURANT, PriceCurrency.JPY, BigDecimal.ONE, BigDecimal.TEN); + restaurant.requestLocationResolution(); + restaurant.completeLocation(1, restaurant.getLocation().getRequestId(), + MapCoordinates.of(new BigDecimal("10.5"), new BigDecimal("20.5")), + RestaurantLocationSource.ADMIN, LocalDateTime.now(clock).minusHours(1), + LocalDateTime.now(clock).plusHours(1), clock); + restaurants.save(restaurant); + } + }); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/PlacesLocationTransactionsTest.java b/src/test/java/org/sopt/hashi/restaurant/service/PlacesLocationTransactionsTest.java new file mode 100644 index 00000000..4e59388b --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/PlacesLocationTransactionsTest.java @@ -0,0 +1,74 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.domain.GeocodingBudgetRepository; +import org.sopt.hashi.restaurant.domain.PlacesBudget; +import org.sopt.hashi.restaurant.domain.PlacesBudget.Operation; +import org.sopt.hashi.restaurant.domain.PlacesBudgetRepository; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantLocation; +import org.sopt.hashi.restaurant.domain.RestaurantLocationJobRepository; +import org.sopt.hashi.restaurant.domain.RestaurantLocationStatus; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; + +class PlacesLocationTransactionsTest { + private final RestaurantRepository restaurants = mock(RestaurantRepository.class); + private final PlacesBudgetRepository budgets = mock(PlacesBudgetRepository.class); + private final GeocodingBudgetRepository clock = mock(GeocodingBudgetRepository.class); + private final PlacesLocationTransactions transactions = new PlacesLocationTransactions( + restaurants, mock(RestaurantLocationJobRepository.class), budgets, clock); + + @Test + void 예산_잠금_대기중_자정이_지나면_잠금획득후_날짜와_분으로_예약한다() { + restaurant("매장", "주소"); + AtomicReference now = new AtomicReference<>(LocalDateTime.parse("2026-10-09T23:59:59")); + LocalDateTime acquired = LocalDateTime.parse("2026-10-10T00:00:01"); + PlacesBudget budget = mock(PlacesBudget.class); + given(clock.databaseUtcTime()).willAnswer(ignored -> now.get().toString()); + given(budgets.findByOperationForUpdate(Operation.SEARCH)).willAnswer(ignored -> { + now.set(acquired); + return Optional.of(budget); + }); + given(budget.reserve(any())).willAnswer(invocation -> { + assertThat(invocation.getArgument(0)).isEqualTo(acquired); + return true; + }); + + assertThat(transactions.prepareSearch(1L, 1).query()).isEqualTo("매장 주소"); + } + + @Test + void 로컬_검색입력이_너무_길면_예산을_예약하기_전에_거부한다() { + restaurant("가".repeat(101), "東".repeat(255)); + assertThatThrownBy(() -> transactions.prepareSearch(1L, 1)) + .isInstanceOfSatisfying(BusinessException.class, + error -> assertThat(error.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); + verifyNoInteractions(budgets, clock); + } + + private void restaurant(String name, String address) { + Restaurant restaurant = mock(Restaurant.class); + RestaurantLocation location = mock(RestaurantLocation.class); + given(restaurants.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + given(restaurant.getLocation()).willReturn(location); + given(restaurant.getId()).willReturn(1L); + given(restaurant.getLocalName()).willReturn(name); + given(restaurant.geocodingAddressForResolution()).willReturn(address); + given(location.getAddressRevision()).willReturn(1L); + given(location.getStatus()).willReturn(RestaurantLocationStatus.REVIEW_REQUIRED); + given(location.getRequestId()).willReturn(UUID.randomUUID()); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapFlowIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapFlowIntegrationTest.java new file mode 100644 index 00000000..a51b9415 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapFlowIntegrationTest.java @@ -0,0 +1,539 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.TimeZone; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.parallel.Isolated; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.restaurant.domain.MapBounds; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.domain.MapRegion; +import org.sopt.hashi.restaurant.domain.MapRegionRepository; +import org.sopt.hashi.restaurant.domain.PriceCurrency; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantPlaceType; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.internal.map.GeocodingProvider; +import org.sopt.hashi.restaurant.internal.map.GeocodingResult; +import org.sopt.hashi.restaurant.internal.map.LocationJobScheduler; +import org.sopt.hashi.restaurant.internal.map.MapSessionProperties; +import org.sopt.hashi.restaurant.internal.map.RestaurantLocationWorker; +import org.sopt.hashi.restaurant.migration.LocationMaintenanceProperties; +import org.sopt.hashi.restaurant.migration.LocationMaintenanceProperties.Command; +import org.sopt.hashi.restaurant.migration.LocationMaintenanceProperties.Mode; +import org.sopt.hashi.restaurant.migration.LocationMaintenanceRunner; +import org.sopt.hashi.restaurant.internal.map.LocationRetentionService; +import org.sopt.hashi.restaurant.service.LocationJobTransactions.Outcome; +import org.sopt.hashi.shared.storage.FileStorage; +import org.sopt.hashi.user.domain.User; +import org.sopt.hashi.user.domain.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** 관리자 HTTP 저장부터 durable worker, 실제 DB 지도 조회까지 하나의 서버 조립으로 검증한다. */ +@Testcontainers +@SpringBootTest(properties = { + "spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test", + "hashi.map.location-job.enabled=true", "hashi.map.location-job.retention=1d", + "hashi.map.location-job.south=10", "hashi.map.location-job.north=11", + "hashi.map.location-job.west=20", "hashi.map.location-job.east=21", + "hashi.restaurant.map.initial-bounds.south=10", "hashi.restaurant.map.initial-bounds.north=11", + "hashi.restaurant.map.initial-bounds.west=20", "hashi.restaurant.map.initial-bounds.east=21", + "hashi.restaurant.map.supported-bounds.south=10", "hashi.restaurant.map.supported-bounds.north=11", + "hashi.restaurant.map.supported-bounds.west=20", "hashi.restaurant.map.supported-bounds.east=21", + "hashi.restaurant.map.session.limits.requests-per-caller=600", + "hashi.restaurant.map.session.limits.new-queries-per-caller=100", + "hashi.restaurant.map.session.limits.requests-per-minute=3000" +}) +@AutoConfigureMockMvc +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Isolated("Verifies JVM UTC with a JDBC Asia/Seoul session") +class RestaurantMapFlowIntegrationTest { + private static final String ADDRESS = "東京都試験区架空町1丁目2番3号"; + private static final TimeZone ORIGINAL_ZONE = TimeZone.getDefault(); + + static { + // Class initialization runs before SpringExtension can bootstrap the application context. + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + + @AfterAll + static void restoreJvm() { + TimeZone.setDefault(ORIGINAL_ZONE); + } + + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("map_flow").withUsername("hashi").withPassword("hashi") + .withUrlParam("connectionTimeZone", "Asia/Seoul") + .withUrlParam("forceConnectionTimeZoneToSession", "true"); + @Container + @ServiceConnection(name = "redis") + static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse( + "redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void mapRedis(DynamicPropertyRegistry registry) { + registry.add("hashi.restaurant.map.redis.host", REDIS::getHost); + registry.add("hashi.restaurant.map.redis.port", () -> REDIS.getMappedPort(6379)); + } + @Autowired MockMvc mvc; + @Autowired ObjectMapper json; + @Autowired JwtProvider jwt; + @Autowired RestaurantLocationWorker worker; + @Autowired LocationJobTransactions transactions; + @Autowired RestaurantPort port; + @Autowired MapRegionRepository regions; + @Autowired JdbcTemplate jdbc; + @Autowired UserRepository users; + @Autowired RestaurantRepository restaurants; + @Autowired TransactionTemplate transactionTemplate; + @Autowired MapSessionProperties sessionProperties; + @Autowired LocationRetentionService retention; + @Autowired LocationMaintenanceRunner maintenance; + @Autowired LocationMaintenanceProperties maintenanceOptions; + @MockitoBean GeocodingProvider google; + @MockitoBean LocationJobScheduler scheduler; + @MockitoBean MediaPort mediaPort; + @MockitoBean FileStorage fileStorage; + + @BeforeEach + void resetSyntheticDatabase() { + sessionProperties.setEnabled(true); + assertThat(TimeZone.getDefault().getID()).isEqualTo("UTC"); + assertThat(jdbc.queryForObject("select timestampdiff(second, utc_timestamp(), now())", Integer.class)) + .isEqualTo(9 * 60 * 60); + jdbc.update("update restaurant set deleted=true"); + // The persistent provider budget is closed by default; open only the disposable MySQL fixture. + jdbc.update(""" + update restaurant_geocoding_budget set enabled=true, daily_limit=100, max_concurrent=4, + reserved_calls=0, budget_day=null, blocked_until=null where id=1 + """); + } + + @Test + void 관리자_저장_후_실제_worker가_확인한_식당만_현재위치와_지역수와_Port에_나타난다() throws Exception { + mvc.perform(post("/api/v1/admin/restaurants") + .contentType(MediaType.APPLICATION_JSON).content(createBodyWithDisplayAddress())) + .andExpect(status().isUnauthorized()); + String token = jwt.createAccessToken(1L, "ROLE_ADMIN"); + JsonNode saved = body(mvc.perform(post("/api/v1/admin/restaurants") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(createBodyWithDisplayAddress())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value("ADMIN-204")) + .andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.addressRevision").value(1)) + .andReturn().getResponse().getContentAsString()); + long id = saved.path("data").path("restaurantId").asLong(); + assertThat(jdbc.queryForObject("select count(*) from restaurant_location_job where restaurant_id=?", + Integer.class, id)).isEqualTo(1); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("RESTAURANT-018")); + + MapRegion region = MapRegion.create("SYNTHETIC", "합성 지역", + MapCoordinates.of(new BigDecimal("10.5"), new BigDecimal("20.5")), + MapBounds.of(new BigDecimal("10"), new BigDecimal("11"), + new BigDecimal("20"), new BigDecimal("21")), 0); + region.activate(); + long regionId = regions.saveAndFlush(region).getId(); + jdbc.update("update restaurant set map_region_id=? where id=?", regionId, id); + when(google.geocode(anyString())).thenAnswer(invocation -> { + assertThat(org.springframework.transaction.support.TransactionSynchronizationManager + .isActualTransactionActive()).isFalse(); + assertThat(invocation.getArgument(0, String.class)).isEqualTo(ADDRESS); + return new GeocodingResult.Candidates(List.of(LocationAdoptionPolicyTest.candidate())); + }); + worker.runOnce(); + sessionProperties.setSigningKey(Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes())); + + mvc.perform(get("/api/v1/admin/restaurants/{id}/location", id) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.locationStatus").value("READY")); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.restaurantId").value(id)) + .andExpect(jsonPath("$.data.location.latitude").value(10.123457)); + mvc.perform(get("/api/v1/restaurants/map/regions")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.regions[0].restaurantCount").value(1)); + assertThat(port.findActiveMapInfos(List.of(id)).getFirst().location()).isNotNull(); + + JsonNode firstPage = body(mvc.perform(get("/api/v1/restaurants/map") + .param("south", "10").param("north", "11").param("west", "20").param("east", "21")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(id)) + .andReturn().getResponse().getContentAsString()).path("data"); + assertThat(firstPage.path("content").get(0).path("location").path("latitude").decimalValue()) + .isEqualByComparingTo("10.123457"); + + User mapUser = users.saveAndFlush(User.onboard( + "지도통합회원", "HASHI", LocalDate.of(1998, 1, 1), + "01011110006", "map-flow@hashi.test", null)); + String userToken = jwt.createAccessToken(mapUser.getId(), "ROLE_USER"); + long collectionId = body(mvc.perform(post("/api/v1/collections") + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"지도 통합\",\"color\":\"red\",\"visibility\":\"public\"}")) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString()).path("data").path("collectionId").asLong(); + mvc.perform(post("/api/v1/collections/{collectionId}/restaurants", collectionId) + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"restaurantId\":" + id + "}")) + .andExpect(status().isCreated()); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.locationUnavailableCount").value(0)) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(id)) + .andExpect(jsonPath("$.data.content[0].location.latitude").value(10.123457)); + mvc.perform(get("/api/v1/restaurants/save-counts").param("restaurantIds", Long.toString(id))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.restaurants[0].saveCount").value(1)); + mvc.perform(get("/api/v1/users/me/restaurant-saves").param("restaurantIds", Long.toString(id)) + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.restaurants[0].saved").value(true)); + + mvc.perform(patch("/api/v1/admin/restaurants/{id}", id) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"합성 식당\",\"localName\":\"試験\",\"address\":\"" + ADDRESS + + " 架空ビル1F\",\"geocodingAddress\":\"" + ADDRESS + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.locationStatus").value("READY")) + .andExpect(jsonPath("$.data.addressRevision").value(1)); + assertThat(jdbc.queryForObject("select count(*) from restaurant_location_job where restaurant_id=?", + Integer.class, id)).isEqualTo(1); + verify(google, times(1)).geocode(ADDRESS); + + mvc.perform(patch("/api/v1/admin/restaurants/{id}", id) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"address\":\"" + ADDRESS + " 架空ビル2F\",\"geocodingAddress\":\"" + + ADDRESS + "\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("ADMIN-205")) + .andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.addressRevision").value(2)); + assertThat(jdbc.queryForObject("select count(*) from restaurant_location_job where restaurant_id=?", + Integer.class, id)).isEqualTo(2); + verify(google, times(1)).geocode(ADDRESS); + mvc.perform(get("/api/v1/admin/restaurants/{id}/location", id) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.confirmationMethod").doesNotExist()) + .andExpect(jsonPath("$.data.confirmedAt").doesNotExist()) + .andExpect(jsonPath("$.data.confirmedRevision").doesNotExist()); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("RESTAURANT-018")); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.locationUnavailableCount").value(1)) + .andExpect(jsonPath("$.data.content").isEmpty()); + mvc.perform(get("/api/v1/restaurants/map") + .param("querySessionId", firstPage.path("querySessionId").asText()) + .param("sort", "recommend")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content").isEmpty()); + + worker.runOnce(); + verify(google, times(2)).geocode(ADDRESS); + mvc.perform(get("/api/v1/admin/restaurants/{id}/location", id) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.locationStatus").value("READY")) + .andExpect(jsonPath("$.data.addressRevision").value(2)) + .andExpect(jsonPath("$.data.confirmationMethod").value("AUTOMATIC")) + .andExpect(jsonPath("$.data.confirmedRevision").value(2)); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.restaurantId").value(id)); + mvc.perform(get("/api/v1/restaurants/map") + .param("south", "10").param("north", "11").param("west", "20").param("east", "21")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content[0].restaurantId").value(id)); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.locationUnavailableCount").value(0)) + .andExpect(jsonPath("$.data.content[0].restaurantId").value(id)); + + mvc.perform(patch("/api/v1/admin/restaurants/{id}", id) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"address\":\"東京都試験区架空町4丁目5番6号\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("ADMIN-205")) + .andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.addressRevision").value(3)); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("RESTAURANT-018")); + mvc.perform(get("/api/v1/restaurants/map/regions")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.regions[0].restaurantCount").value(0)); + assertThat(port.findActiveMapInfos(List.of(id)).getFirst().location()).isNull(); + mvc.perform(get("/api/v1/collections/{collectionId}/map-markers", collectionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.visibleRestaurantCount").value(1)) + .andExpect(jsonPath("$.data.locationUnavailableCount").value(1)) + .andExpect(jsonPath("$.data.content").isEmpty()); + mvc.perform(get("/api/v1/restaurants/map") + .param("querySessionId", firstPage.path("querySessionId").asText()) + .param("sort", "recommend")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content").isEmpty()); + } + + @Test + void 주소_A와_B의_늦은_완료는_현재_주소와_삭제_상태를_덮어쓰지_못한다() throws Exception { + String token = jwt.createAccessToken(1L, "ROLE_ADMIN"); + long id = body(mvc.perform(post("/api/v1/admin/restaurants") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString()) + .path("data").path("restaurantId").asLong(); + var targetA = transactions.candidates().stream().filter(value -> value.restaurantId().equals(id)) + .findFirst().orElseThrow(); + var claimA = transactions.claim(targetA).orElseThrow(); + mvc.perform(patch("/api/v1/admin/restaurants/{id}", id) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"address\":\"東京都試験区架空町4丁目5番6号\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.addressRevision").value(2)); + var position = new LocationAdoptionPolicy(LocationAdoptionPolicyTest.properties()) + .evaluate(ADDRESS, new GeocodingResult.Candidates(List.of(LocationAdoptionPolicyTest.candidate()))) + .coordinates(); + assertThat(transactions.complete(claimA, new Outcome(position, null, null))).isFalse(); + mvc.perform(get("/api/v1/admin/restaurants/{id}/location", id) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.locationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.addressRevision").value(2)); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isConflict()); + + var targetB = transactions.candidates().stream().filter(value -> value.restaurantId().equals(id)) + .findFirst().orElseThrow(); + var claimB = transactions.claim(targetB).orElseThrow(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .delete("/api/v1/admin/restaurants/{id}", id) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + assertThat(transactions.complete(claimB, Outcome.failure( + GeocodingResult.FailureKind.TRANSIENT_ERROR))).isFalse(); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isNotFound()); + assertThat(port.findActiveMapInfos(List.of(id))).isEmpty(); + } + + @Test + void Google이_비활성이어도_보존기한_정리후_현재위치_페이지_Port에서_좌표가_사라진다() throws Exception { + String token = jwt.createAccessToken(1L, "ROLE_ADMIN"); + long id = body(mvc.perform(post("/api/v1/admin/restaurants") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString()) + .path("data").path("restaurantId").asLong(); + when(google.geocode(anyString())).thenReturn( + new GeocodingResult.Candidates(List.of(LocationAdoptionPolicyTest.candidate()))); + worker.runOnce(); + sessionProperties.setSigningKey(Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes())); + JsonNode first = body(mvc.perform(get("/api/v1/restaurants/map") + .param("south", "10").param("north", "11").param("west", "20").param("east", "21")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content[0].restaurantId").value(id)) + .andReturn().getResponse().getContentAsString()).path("data"); + jdbc.update(""" + update restaurant_location l join restaurant r on r.location_id=l.id + set l.obtained_at=UTC_TIMESTAMP(6)-interval 1 day, + l.valid_until=UTC_TIMESTAMP(6)-interval 1 second where r.id=? + """, id); + jdbc.update("update restaurant_geocoding_budget set enabled=false where id=1"); + assertThat(retention.purge(maintenanceOptions.retentionOptions()).purged()).isEqualTo(1); + assertThat(jdbc.queryForObject(""" + select count(*) from restaurant_location l join restaurant r on r.location_id=l.id + where r.id=? and (l.latitude is not null or l.longitude is not null or l.valid_until is not null) + """, Integer.class, id)).isZero(); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("RESTAURANT-018")); + assertThat(port.findActiveMapInfos(List.of(id)).getFirst().location()).isNull(); + mvc.perform(get("/api/v1/restaurants/map") + .param("querySessionId", first.path("querySessionId").asText()) + .param("sort", "recommend")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content").isEmpty()); + } + + @Test + void 같은_주소의_갱신_등록은_유효한_좌표를_현재위치와_기존_페이지에_유지한다() throws Exception { + String token = jwt.createAccessToken(1L, "ROLE_ADMIN"); + long id = body(mvc.perform(post("/api/v1/admin/restaurants") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString()) + .path("data").path("restaurantId").asLong(); + when(google.geocode(anyString())).thenReturn( + new GeocodingResult.Candidates(List.of(LocationAdoptionPolicyTest.candidate()))); + worker.runOnce(); + sessionProperties.setSigningKey(Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes())); + JsonNode first = body(mvc.perform(get("/api/v1/restaurants/map") + .param("south", "10").param("north", "11").param("west", "20").param("east", "21")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content[0].restaurantId").value(id)) + .andReturn().getResponse().getContentAsString()).path("data"); + jdbc.update(""" + update restaurant_location l join restaurant r on r.location_id=l.id + set l.obtained_at=UTC_TIMESTAMP(6)-interval 21 hour, + l.valid_until=UTC_TIMESTAMP(6)+interval 3 hour where r.id=? + """, id); + jdbc.update("update restaurant_geocoding_budget set enabled=false where id=1"); + UUID runId = UUID.randomUUID(); + var options = new LocationMaintenanceProperties(Command.START, Mode.REFRESH, true, + runId, id - 1, id, 10, 1, 10, 80, + Duration.ofHours(6), Duration.ofHours(1), false, null); + maintenance.execute(options); + assertThat(jdbc.queryForObject(""" + select count(*) from restaurant_location_maintenance_job m + join restaurant_location_job j on j.id=m.job_id + where m.run_id=? and j.restaurant_id=? and j.state='PENDING' + """, Integer.class, runId.toString(), id)).isEqualTo(1); + verify(google, times(1)).geocode(ADDRESS); + mvc.perform(get("/api/v1/admin/restaurants/{id}/location", id) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.locationStatus").value("PENDING")); + mvc.perform(get("/api/v1/restaurants/{id}/map-location", id)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.restaurantId").value(id)); + assertThat(port.findActiveMapInfos(List.of(id)).getFirst().location()).isNotNull(); + mvc.perform(get("/api/v1/restaurants/map") + .param("querySessionId", first.path("querySessionId").asText()) + .param("sort", "recommend")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content[0].restaurantId").value(id)); + } + + @ParameterizedTest + @ValueSource(ints = {23, 500, 501, 620}) + void 전체_식당은_중복누락없이_조회되고_같은_커서와_동점정렬은_순서를_유지한다(int count) throws Exception { + Clock clock = Clock.systemUTC(); + List expected = transactionTemplate.execute(status -> { + List ids = new ArrayList<>(); + for (int index = 0; index < count; index++) { + Restaurant restaurant = Restaurant.create("합성 식당 " + index, "試験", "요약", "설명", + ADDRESS, "합성 지역", RestaurantGenre.SUSHI, "초밥", RestaurantPlaceType.RESTAURANT, + PriceCurrency.JPY, BigDecimal.ONE, BigDecimal.TEN); + restaurant.requestLocationResolution(); + restaurant.completeLocation(1, restaurant.getLocation().getRequestId(), + MapCoordinates.of(new BigDecimal("10.5"), new BigDecimal("20.5")), + RestaurantLocationSource.ADMIN, LocalDateTime.now(clock).minusHours(1), + LocalDateTime.now(clock).plusHours(1), clock); + ids.add(restaurants.save(restaurant).getId()); + } + return ids; + }); + sessionProperties.setSigningKey(Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes())); + JsonNode page = body(mvc.perform(get("/api/v1/restaurants/map") + .param("south", "10").param("north", "11").param("west", "20").param("east", "21")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); + List seen = new ArrayList<>(); + String sessionId = page.path("querySessionId").asText(); + JsonNode sorted = body(mvc.perform(get("/api/v1/restaurants/map") + .param("querySessionId", sessionId).param("sort", "rating")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); + assertThat(sorted.path("content")).isEqualTo(page.path("content")); + for (int offset = 0; offset < count; offset += 10) { + int size = Math.min(10, count - offset); + assertThat(page.path("querySessionId").asText()).isEqualTo(sessionId); + assertThat(page.path("content").size()).isEqualTo(size); + page.path("content").forEach(card -> { + assertThat(card.path("restaurantId").asLong()).isPositive(); + assertThat(card.path("location").path("latitude").decimalValue()) + .isEqualByComparingTo("10.5"); + assertThat(card.path("location").path("longitude").decimalValue()) + .isEqualByComparingTo("20.5"); + seen.add(card.path("restaurantId").asLong()); + }); + if (offset + size == count) { + assertThat(page.path("hasNext").asBoolean()).isFalse(); + assertThat(page.has("nextCursor")).isFalse(); + } else { + assertThat(page.path("hasNext").asBoolean()).isTrue(); + String cursor = page.path("nextCursor").asText(); + page = body(mvc.perform(get("/api/v1/restaurants/map").param("cursor", cursor)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); + if (offset == 0) { + JsonNode replay = body(mvc.perform(get("/api/v1/restaurants/map").param("cursor", cursor)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); + assertThat(replay.path("content")).isEqualTo(page.path("content")); + assertThat(replay.path("nextCursor")).isEqualTo(page.path("nextCursor")); + } + } + } + assertThat(seen).doesNotHaveDuplicates().containsExactlyInAnyOrderElementsOf(expected); + } + + private JsonNode body(String value) throws Exception { + return json.readTree(value); + } + + private String createBody() { + return """ + {"name":"합성 식당","localName":"試験","summary":"합성 요약","description":"합성 설명", + "address":"東京都試験区架空町1丁目2番3号","area":"합성 지역","genre":"sushi", + "foodCategory":"초밥","placeType":"restaurant","priceCurrency":"JPY", + "minPrice":1,"maxPrice":10,"imageKeys":["restaurants/synthetic.jpg"], + "hashtags":["합성"],"curationTypes":[],"businessHours":[ + {"dayOfWeek":"MONDAY","closed":true},{"dayOfWeek":"TUESDAY","closed":true}, + {"dayOfWeek":"WEDNESDAY","closed":true},{"dayOfWeek":"THURSDAY","closed":true}, + {"dayOfWeek":"FRIDAY","closed":true},{"dayOfWeek":"SATURDAY","closed":true}, + {"dayOfWeek":"SUNDAY","closed":true}]} + """; + } + + private String createBodyWithDisplayAddress() { + return createBody().replace("\"address\":\"" + ADDRESS + "\"", + "\"address\":\"" + ADDRESS + " 架空ビル1F\",\"geocodingAddress\":\"" + ADDRESS + "\""); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageIntegrationTest.java new file mode 100644 index 00000000..5b716394 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageIntegrationTest.java @@ -0,0 +1,655 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zaxxer.hikari.HikariDataSource; +import jakarta.persistence.EntityManagerFactory; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.auth.internal.security.CookieUtil; +import org.sopt.hashi.auth.internal.security.JwtAccessDeniedHandler; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationEntryPoint; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationFilter; +import org.sopt.hashi.auth.internal.security.OriginValidator; +import org.sopt.hashi.auth.internal.security.SecurityConfig; +import org.sopt.hashi.auth.internal.token.OnboardingTokenStore; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.config.RedisConfig; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.domain.MapBounds; +import org.sopt.hashi.restaurant.domain.MapRegion; +import org.sopt.hashi.restaurant.domain.MapRegionRepository; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.PriceCurrency; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantBusinessHour; +import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantImage; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.restaurant.domain.RestaurantMenu; +import org.sopt.hashi.restaurant.domain.RestaurantPlaceType; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.internal.map.MapCursorCodec; +import org.sopt.hashi.restaurant.internal.map.MapQuerySession; +import org.sopt.hashi.restaurant.internal.map.MapSessionId; +import org.sopt.hashi.restaurant.internal.map.MapSessionProperties; +import org.sopt.hashi.restaurant.internal.map.MapSessionLimits; +import org.sopt.hashi.restaurant.internal.map.RedisMapSessionStore; +import org.sopt.hashi.shared.storage.FileStorage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.aop.framework.ProxyFactory; +import org.aopalliance.intercept.MethodInterceptor; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.boot.autoconfigure.data.redis.LettuceClientOptionsBuilderCustomizer; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.modulith.test.ApplicationModuleTest; +import org.springframework.orm.jpa.EntityManagerHolder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@ApplicationModuleTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, extraIncludes = "shared") +@AutoConfigureMockMvc +@Import({RestaurantMapPageIntegrationTest.Infrastructure.class, SecurityConfig.class, + JwtAuthenticationFilter.class, JwtProvider.class, CookieUtil.class, OriginValidator.class, + JwtAuthenticationEntryPoint.class, JwtAccessDeniedHandler.class}) +@TestPropertySource(properties = { + "spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true", + "spring.jpa.properties.hibernate.generate_statistics=true", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "jwt.access-token-ttl=30m", "jwt.refresh-token-ttl=14d", "jwt.onboarding-token-ttl=30m", + "kakao.client-id=test-client-id", "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test", + "hashi.cors.allowed-origins=https://app.hashi.test", "springdoc.api-docs.enabled=false", + "hashi.restaurant.map.initial-bounds.south=0", "hashi.restaurant.map.initial-bounds.north=1", + "hashi.restaurant.map.initial-bounds.west=0", "hashi.restaurant.map.initial-bounds.east=1", + "hashi.restaurant.map.supported-bounds.south=-1", "hashi.restaurant.map.supported-bounds.north=2", + "hashi.restaurant.map.supported-bounds.west=-1", "hashi.restaurant.map.supported-bounds.east=2" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class RestaurantMapPageIntegrationTest { + private static final Clock CLOCK = Clock.system(ZoneId.of("Asia/Tokyo")); + private static final DateTimeFormatter UTC_DATETIME = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSS"); + private static final String PATH = "/api/v1/restaurants/map"; + private static final String PREFIX = "hashi:restaurant:map:{sessions-v2}:query:"; + private static final MapSearchCriteria CRITERIA = MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, "sushi", "restaurant", "fixture"); + + @Container @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("map_pages").withUsername("hashi").withPassword("hashi") + .withUrlParam("serverTimezone", "Asia/Seoul"); + @Container @ServiceConnection(name = "redis") + static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse( + "redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499")) + .withExposedPorts(6379); + + // 이 테스트는 페이지 계약을 검증한다. 서로 다른 Redis의 인증 격리는 MapRedisIsolationIntegrationTest에서 확인한다. + @DynamicPropertySource + static void mapRedis(DynamicPropertyRegistry registry) { + registry.add("hashi.restaurant.map.redis.host", REDIS::getHost); + registry.add("hashi.restaurant.map.redis.port", () -> REDIS.getMappedPort(6379)); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper mapper; + @Autowired RestaurantRepository restaurants; + @Autowired MapRegionRepository regions; + @Autowired RestaurantMapPageService pages; + @Autowired RestaurantMapService mapService; + @Autowired RestaurantPort port; + @Autowired RedisMapSessionStore store; + @Autowired MapCursorCodec cursors; + @Autowired MapSessionProperties properties; + @Autowired MapSessionLimits limits; + @Autowired StringRedisTemplate redis; + @Autowired JdbcTemplate jdbc; + @Autowired PlatformTransactionManager transactions; + @Autowired EntityManagerFactory entityManagerFactory; + @MockitoBean MediaPort media; + @MockitoBean FileStorage files; + @MockitoBean OnboardingTokenStore onboardingTokens; + + @BeforeEach + void resetSyntheticData() { + properties.setEnabled(true); + jdbc.update("update restaurant set deleted=true"); + redis.getConnectionFactory().getConnection().serverCommands().flushDb(); + properties.setSigningKey(Base64.getEncoder().encodeToString("synthetic-map-test-key-32-bytes-only".getBytes())); + given(files.resolveFileUrl(any())).willAnswer(invocation -> "https://cdn.hashi.test/" + invocation.getArgument(0)); + given(media.findImages(any())).willReturn(Map.of()); + } + + @Test + void 기본OSIV에서_지도목록만_연결을_분리하고_일반API는_기존OSIV를_유지한다() throws Exception { + fixtures(11, false); + mvc.perform(newQuery()).andExpect(status().isOk()) + .andExpect(request().attribute("test.osiv.bound", false)); + mvc.perform(get("/api/v1/restaurants")).andExpect(status().isOk()) + .andExpect(request().attribute("test.osiv.bound", true)); + } + + @ParameterizedTest + @ValueSource(ints = {0, 10, 11, 21, 500, 501, 620}) + void 실제_HTTP에서_10개씩_빠짐없이_반환하고_마지막_cursor를_생략한다(int count) throws Exception { + List expected = fixtures(count, false); + JsonNode data = page(newQuery()); + List result = new ArrayList<>(); + String session = data.get("querySessionId").asText(); + do { + assertThat(data.get("content").size()).isLessThanOrEqualTo(10); + result.addAll(ids(data)); + assertThat(data.get("querySessionId").asText()).isEqualTo(session); + if (!data.get("hasNext").asBoolean()) { + assertThat(data.has("nextCursor")).isFalse(); + break; + } + assertThat(data.get("content").size()).isEqualTo(10); + data = page(get(PATH).param("cursor", data.get("nextCursor").asText())); + } while (true); + assertThat(result).doesNotHaveDuplicates().containsExactlyInAnyOrderElementsOf(expected); + assertThat(Instant.parse(data.get("expiresAt").asText())) + .isAfter(Instant.parse(data.get("rankingAsOf").asText())); + assertThat(data.get("query").get("sort").asText()).isEqualTo("recommend"); + assertThat(data.has("searchResult")).isFalse(); + } + + @Test + void 검색결과요약은_첫페이지밖_좌표까지_포함하고_페이지중_삭제되어도_최초값을_유지한다() throws Exception { + List restaurantIds = fixtures(12, false); + Long outlierId = restaurantIds.getLast(); + jdbc.update("update restaurant set rating=5 where deleted=false and id<>?", outlierId); + jdbc.update("update restaurant_location set latitude=0.9, longitude=0.8 " + + "where id=(select location_id from restaurant where id=?)", outlierId); + + JsonNode first = page(newQuery().param("keyword", "fixture").param("sort", "rating")); + assertThat(first.get("content")).hasSize(10); + assertThat(ids(first)).doesNotContain(outlierId); + JsonNode searchResult = first.get("searchResult"); + assertThat(searchResult.get("totalCount").asLong()).isEqualTo(12); + assertThat(searchResult.has("earliestValidUntil")).isFalse(); + assertThat(searchResult.at("/bounds/south").decimalValue()).isEqualByComparingTo("0.5"); + assertThat(searchResult.at("/bounds/north").decimalValue()).isEqualByComparingTo("0.9"); + assertThat(searchResult.at("/bounds/west").decimalValue()).isEqualByComparingTo("0.5"); + assertThat(searchResult.at("/bounds/east").decimalValue()).isEqualByComparingTo("0.8"); + + jdbc.update("update restaurant set deleted=true where id=?", outlierId); + JsonNode next = page(get(PATH).param("cursor", first.get("nextCursor").asText())); + assertThat(next.get("content")).hasSize(1); + assertThat(ids(next)).doesNotContain(outlierId); + assertThat(next.get("searchResult")).isEqualTo(searchResult); + assertThat(next.get("rankingAsOf")).isEqualTo(first.get("rankingAsOf")); + + JsonNode empty = page(newQuery().param("keyword", "no-such-restaurant")); + assertThat(empty.at("/searchResult/totalCount").asLong()).isZero(); + assertThat(empty.at("/searchResult/bounds").isNull()).isTrue(); + } + + @Test + void 동점과_최신통계변경에도_최초추천_별점_리뷰순위가_고정된다() throws Exception { + fixtures(21, false); + JsonNode first = page(newQuery()); + String session = first.get("querySessionId").asText(); + var snapshot = store.find(MapSessionId.parse(session)); + jdbc.update("update restaurant set rating=5, review_count=100 where deleted=false"); + for (String sort : List.of("rating", "reviews", "recommend", "rating")) { + JsonNode sorted = page(get(PATH).param("querySessionId", session).param("sort", sort)); + assertThat(ids(sorted)).isEqualTo(ids(first)); + assertThat(Instant.parse(sorted.get("expiresAt").asText())) + .isAfterOrEqualTo(Instant.parse(first.get("expiresAt").asText())); + sorted.get("content").forEach(card -> { + assertThat(card.get("rating").decimalValue()).isEqualByComparingTo("0.0"); + assertThat(card.get("reviewCount").asLong()).isZero(); + }); + } + assertThat(store.find(MapSessionId.parse(session))).isEqualTo(snapshot); + } + + @Test + void 다른_별점과_리뷰수는_내림차순이며_동점은_원래추천순이다() throws Exception { + List ids = fixtures(21, false); + for (int index = 0; index < ids.size(); index++) { + jdbc.update("update restaurant set rating=?, review_count=? where id=?", index % 5, index % 3, ids.get(index)); + } + var first = page(newQuery()); + String sessionId = first.get("querySessionId").asText(); + var session = store.find(MapSessionId.parse(sessionId)); + for (var sort : List.of(RestaurantMapSort.RATING, RestaurantMapSort.REVIEWS)) { + var sorted = page(get(PATH).param("querySessionId", sessionId).param("sort", sort.value())); + assertThat(ids(sorted)).containsExactlyElementsOf(session.ordered(sort).subList(0, 10).stream() + .map(RestaurantMapCandidate::restaurantId).toList()); + } + } + + @Test + void 앞뒤의_삭제_만료_주소_분류_검색변경을_건너뛰고_lookahead를_보존한다() throws Exception { + List ids = fixtures(30, false); + var session = orderedSession(Duration.ofMinutes(15)); + String token = cursors.encode(session, RestaurantMapSort.RECOMMEND, 5); + jdbc.update("update restaurant set deleted=true where id in (?,?)", ids.get(0), ids.get(5)); + jdbc.update("update restaurant_location set valid_until=cast(? as datetime(6)) " + + "where id=(select location_id from restaurant where id=?)", expiredLocationTimestamp(), ids.get(6)); + new TransactionTemplate(transactions).executeWithoutResult(status -> restaurants.findById(ids.get(7)).orElseThrow() + .updateBasicInfo(null, null, null, null, "changed fixture address", null, null, null, null, null, null, null)); + jdbc.update("update restaurant set place_type='CAFE' where id=?", ids.get(8)); + jdbc.update("update restaurant set genre='NOODLE' where id=?", ids.get(9)); + jdbc.update("update restaurant set name='changed' where id=?", ids.get(10)); + var page = page(get(PATH).param("cursor", token)); + assertThat(ids(page)).containsExactlyElementsOf(ids.subList(11, 21)); + assertThat(page.get("hasNext").asBoolean()).isTrue(); + String next = page.get("nextCursor").asText(); + assertThat(cursors.decode(next).position()).isEqualTo(21); + jdbc.update("update restaurant set deleted=false where id=?", ids.get(0)); + fixtures(1, false); + var last = page(get(PATH).param("cursor", next)); + assertThat(ids(last)).containsExactlyElementsOf(ids.subList(21, 30)); + assertThat(last.has("nextCursor")).isFalse(); + jdbc.update("update restaurant set deleted=true where id>=?", ids.get(21)); + var nowEmpty = page(get(PATH).param("cursor", next)); + assertThat(ids(nowEmpty)).isEmpty(); + assertThat(nowEmpty.get("hasNext").asBoolean()).isFalse(); + } + + @Test + void 같은_cursor의_반복과_동시_HTTP는_전역위치를_움직이지_않는다() throws Exception { + fixtures(21, false); + JsonNode first = page(newQuery()); + String token = first.get("nextCursor").asText(); + JsonNode expected = page(get(PATH).param("cursor", token)); + try (var executor = Executors.newFixedThreadPool(3)) { + List> calls = IntStream.range(0, 12) + .mapToObj(index -> (Callable) () -> page(get(PATH).param("cursor", token))).toList(); + for (var result : executor.invokeAll(calls)) { + assertThat(result.get().get("content")).isEqualTo(expected.get("content")); + } + } + assertThat(page(get(PATH).param("cursor", token)).get("content")).isEqualTo(expected.get("content")); + } + + @Test + void 절대수명은_정렬로_연장되지_않으며_유실과_만료는_410이다() throws Exception { + fixtures(1, false); + var session = orderedSession(Duration.ofSeconds(2)); + long ttl = redis.getExpire(PREFIX + session.value(), TimeUnit.MILLISECONDS); + Thread.sleep(150); + page(get(PATH).param("querySessionId", session.value()).param("sort", "rating")); + assertThat(redis.getExpire(PREFIX + session.value(), TimeUnit.MILLISECONDS)).isLessThan(ttl); + Thread.sleep(2000); + mvc.perform(get(PATH).param("querySessionId", session.value()).param("sort", "reviews")) + .andExpect(status().isGone()).andExpect(jsonPath("$.code").value("RESTAURANT-013")); + var lost = orderedSession(Duration.ofMinutes(15)); + redis.delete(PREFIX + lost.value()); + mvc.perform(get(PATH).param("querySessionId", lost.value()).param("sort", "recommend")) + .andExpect(status().isGone()); + } + + @Test + void 호출자_신규조회_제한은_429이고_DB조회_전에_차단한다() throws Exception { + limits.setNewQueriesPerCaller(1); + try { + page(newQuery()); + var statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.clear(); + mvc.perform(newQuery()).andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value("RESTAURANT-022")); + assertThat(statistics.getPrepareStatementCount()).isZero(); + } finally { + limits.setNewQueriesPerCaller(12); + } + } + + @Test + void 잘못된_cursor와_DB실패는_남은_TTL을_연장하지_않는다() throws Exception { + fixtures(1, false); + var id = orderedSession(Duration.ofMinutes(30)); + String key = PREFIX + id.value(); + long initial = redis.getExpire(key, TimeUnit.MILLISECONDS); + Thread.sleep(100); + mvc.perform(get(PATH).param("cursor", cursors.encode(id, RestaurantMapSort.RECOMMEND, 10))) + .andExpect(status().isBadRequest()); + long afterInvalid = redis.getExpire(key, TimeUnit.MILLISECONDS); + assertThat(afterInvalid).isLessThan(initial); + jdbc.execute("rename table restaurant to restaurant_map_test_unavailable"); + try { + mvc.perform(get(PATH).param("querySessionId", id.value()).param("sort", "recommend")) + .andExpect(status().isServiceUnavailable()); + assertThat(redis.getExpire(key, TimeUnit.MILLISECONDS)).isLessThan(afterInvalid); + } finally { + jdbc.execute("rename table restaurant_map_test_unavailable to restaurant"); + } + } + + @Test + void 현재좌표_가격_이미지_영업시간을_일괄조회하며_식당수가_늘어도_쿼리수가_늘지_않는다() throws Exception { + var ids = fixtures(10, true); + // 같은 JDBC 연결의 Asia/Seoul과 무관하게 UTC 원문을 조회한다. + jdbc.update("update restaurant_location set valid_until=UTC_TIMESTAMP(6) + interval 1 hour where status='READY'"); + var statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.clear(); + clearInvocations(media); + var ten = page(newQuery()); + long tenQueries = statistics.getPrepareStatementCount(); + assertThat(ten.get("content")).hasSize(10); + var card = ten.get("content").get(0); + assertThat(card.get("priceRange").get("currency").asText()).isEqualTo("JPY"); + assertThat(card.get("priceRange").get("minPrice").asLong()).isEqualTo(100); + assertThat(card.get("priceRange").get("maxPrice").asLong()).isEqualTo(999); + assertThat(card.get("placeType").asText()).isEqualTo("restaurant"); + assertThat(card.get("imageUrls")).hasSize(1); + assertThat(card.get("cardImages")).hasSize(2); + assertThat(card.get("todayBusinessHour").get("openTime").asText()).isEqualTo("10:00"); + assertThat(Instant.parse(card.get("location").get("validUntil").asText())).isAfter(Instant.now()); + assertThat(card.has("isSaved")).isFalse(); + assertThat(card.has("savedCount")).isFalse(); + verify(media, times(1)).findImages(any()); + jdbc.update("update restaurant set deleted=true where id<>?", ids.getFirst()); + statistics.clear(); + var one = page(newQuery()); + long oneQueries = statistics.getPrepareStatementCount(); + assertThat(one.get("content")).hasSize(1); + assertThat(tenQueries).isEqualTo(oneQueries).isLessThanOrEqualTo(7); + System.out.println("MAP_CARD_QUERIES one=" + oneQueries + " ten=" + tenQueries + " mediaBulk=1"); + jdbc.update("update restaurant_location set valid_until=cast(? as datetime(6)) where status='READY'", + expiredLocationTimestamp()); + assertThat(page(newQuery()).get("content")).isEmpty(); + assertThat(port.findActiveMapInfos(List.of(ids.getFirst()))).hasSize(1) + .allSatisfy(info -> assertThat(info.location()).isNull()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 비활성화나_키_누락은_지도세션만_503이며_기존목록과_보안경로는_그대로이다(boolean enabled) throws Exception { + activeRegion(); + properties.setEnabled(enabled); + if (enabled) properties.setSigningKey(null); + mvc.perform(newQuery()).andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("RESTAURANT-014")); + mvc.perform(get("/api/v1/restaurants")).andExpect(status().isOk()); + mvc.perform(get(PATH + "/regions")).andExpect(status().isOk()); + mvc.perform(get("/api/v1/admin/restaurants")).andExpect(status().isUnauthorized()); + mvc.perform(get("/api/v1/users/me")).andExpect(status().isUnauthorized()); + } + + @Test + void 지역매핑과_BBOX_변경을_재검사하고_현재비활성지역은_거절한다() throws Exception { + List ids = fixtures(12, false); + Long region = activeRegion(); + jdbc.update("update restaurant set map_region_id=? where deleted=false", region); + var query = MapSearchCriteria.of(CRITERIA.bounds(), region, "sushi", "restaurant", "fixture"); + var snapshot = mapService.findCandidates(query, 500); + var id = store.save(new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), query, + snapshot.candidates(), snapshot.resultExtent(), snapshot.rankingAsOf(), + Instant.now().plusSeconds(900))); + jdbc.update("update restaurant set map_region_id=null where id=?", ids.get(0)); + jdbc.update("update restaurant_location set latitude=1.5 where id=(select location_id from restaurant where id=?)", ids.get(1)); + var result = page(get(PATH).param("querySessionId", id.value()).param("sort", "recommend")); + assertThat(ids(result)).containsExactlyElementsOf(ids.subList(2, 12)); + assertThat(result.get("hasNext").asBoolean()).isFalse(); + jdbc.update("update map_region set active=false where id=?", region); + mvc.perform(get(PATH).param("querySessionId", id.value()).param("sort", "recommend")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("RESTAURANT-012")); + } + + @Test + void 실제Redis_읽기쓰기실패와_DB실패를_HTTP503으로_구분한다() throws Exception { + fixtures(1, false); + var id = orderedSession(Duration.ofMinutes(15)); + try { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "1"); + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory-policy", "noeviction"); + mvc.perform(newQuery()).andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("RESTAURANT-014")); + } finally { + REDIS.execInContainer("redis-cli", "CONFIG", "SET", "maxmemory", "0"); + } + REDIS.getDockerClient().pauseContainerCmd(REDIS.getContainerId()).exec(); + try { + mvc.perform(get(PATH).param("querySessionId", id.value()).param("sort", "rating")) + .andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value("RESTAURANT-014")); + } finally { + REDIS.getDockerClient().unpauseContainerCmd(REDIS.getContainerId()).exec(); + } + jdbc.execute("rename table restaurant to restaurant_map_test_unavailable"); + try { + mvc.perform(newQuery()).andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("RESTAURANT-015")); + } finally { + jdbc.execute("rename table restaurant_map_test_unavailable to restaurant"); + } + } + + @Test + void 상위DB_transaction이_열려있으면_Redis_호출_전에_거절한다() { + fixtures(1, false); + assertThatThrownBy(() -> new TransactionTemplate(transactions).executeWithoutResult(status -> + pages.getPage(new org.sopt.hashi.restaurant.dto.RestaurantMapPageRequest( + CRITERIA, RestaurantMapSort.RECOMMEND, null, null)))) + .isInstanceOf(org.springframework.transaction.IllegalTransactionStateException.class); + assertThat(redis.keys(PREFIX + "*")).isEmpty(); + } + + @Test + @ExtendWith(OutputCaptureExtension.class) + void 잘못된_HTTP_파라미터와_변조cursor는_400이고_민감값을_노출하지_않는다(CapturedOutput output) throws Exception { + for (String key : List.of("south", "unknown", "size", "cursor", "querySessionId")) { + mvc.perform(newQuery().param(key, "private-fixture")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("COMMON-400")) + .andExpect(jsonPath("$.data").isEmpty()); + } + mvc.perform(get(PATH).param("cursor", "x".repeat(513))).andExpect(status().isBadRequest()); + String token = cursors.encode(new MapSessionId(UUID.randomUUID()), RestaurantMapSort.RECOMMEND, 10); + String tampered = (token.startsWith("A") ? "B" : "A") + token.substring(1); + String body = mvc.perform(get(PATH).param("cursor", tampered)).andExpect(status().isBadRequest()) + .andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain(tampered, "signingKey", "private-fixture"); + assertThat(output.getAll()).doesNotContain(tampered, "private-fixture", "synthetic-map-test-key-32-bytes-only"); + } + + private JsonNode page(MockHttpServletRequestBuilder request) throws Exception { + String body = mvc.perform(request).andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("COMMON-200")) + .andReturn().getResponse().getContentAsString(); + return mapper.readTree(body).get("data"); + } + + private MockHttpServletRequestBuilder newQuery() { + return get(PATH).param("south", "0").param("north", "1").param("west", "0").param("east", "1"); + } + + private List ids(JsonNode page) { + List ids = new ArrayList<>(); + page.get("content").forEach(card -> ids.add(card.get("restaurantId").asLong())); + return ids; + } + + private MapSessionId orderedSession(Duration ttl) { + Instant startedAt = store.admit("synthetic-fixture", true); + var snapshot = mapService.findCandidates(CRITERIA, 500); + return store.save(new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), CRITERIA, + snapshot.candidates(), snapshot.resultExtent(), snapshot.rankingAsOf(), startedAt.plus(ttl))); + } + + /** DB와 JVM 벽시계 차이에도 테스트의 조회 Clock보다 확실히 과거인 만료값을 저장한다. */ + private String expiredLocationTimestamp() { + return UTC_DATETIME.format(LocalDateTime.ofInstant(CLOCK.instant().minusSeconds(1), ZoneOffset.UTC)); + } + + private Long activeRegion() { + return new TransactionTemplate(transactions).execute(status -> { + var region = MapRegion.create("FIXTURE_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(), + "합성 지역", MapCoordinates.of(new BigDecimal(".5"), new BigDecimal(".5")), + MapBounds.of(BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ONE), 0); + region.activate(); + return regions.save(region).getId(); + }); + } + + private List fixtures(int count, boolean cards) { + return new TransactionTemplate(transactions).execute(status -> IntStream.range(0, count).mapToObj(index -> { + Restaurant restaurant = Restaurant.create("fixture " + index, "fixture", "fixture", "fixture", + "synthetic address", "fixture", RestaurantGenre.SUSHI, "fixture", RestaurantPlaceType.RESTAURANT, + PriceCurrency.JPY, new BigDecimal("100.75"), new BigDecimal("999.99")); + restaurant.requestLocationResolution(); + LocalDateTime now = LocalDateTime.ofInstant(CLOCK.instant(), ZoneOffset.UTC); + restaurant.completeLocation(1, restaurant.getLocation().getRequestId(), + MapCoordinates.of(new BigDecimal(".5"), new BigDecimal(".5")), RestaurantLocationSource.ADMIN, + now.minusHours(1), now.plusHours(1), CLOCK); + if (cards) { + restaurant.addImage(RestaurantImage.createLegacy("fixture/image" + index, 1)); + restaurant.addImage(RestaurantImage.createAsset(UUID.randomUUID(), 2)); + restaurant.addMenu(RestaurantMenu.create("menu", "fixture", null, PriceCurrency.JPY, BigDecimal.ONE, true)); + restaurant.replaceHashtags(List.of("fixture")); + restaurant.addBusinessHour(RestaurantBusinessHour.create(LocalDate.now(CLOCK).getDayOfWeek(), + LocalTime.of(10, 0), LocalTime.of(21, 0), null, null, false)); + } + return restaurants.save(restaurant).getId(); + }).toList()); + } + + @TestConfiguration(proxyBeanMethods = false) + @EnableJpaAuditing + static class Infrastructure { + // ModuleTest가 root config의 BeanDefinition을 제거하므로 같은 factory 메서드로 연결한다. + @Bean + RedisTemplate redisTemplate(RedisConnectionFactory connections) { + return new RedisConfig().redisTemplate(connections); + } + + @Bean + LettuceClientOptionsBuilderCustomizer socketOptions(RedisProperties properties) { + return new RedisConfig().lettuceSocketOptionsCustomizer(properties); + } + + /** 실제 adapter 경계의 resource와 최초 저장 시 물리 pool 연결 반환을 관측한다. */ + @Bean + static BeanPostProcessor observeRedisTransactionBoundary(ConfigurableListableBeanFactory factory) { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String name) { + if (!(bean instanceof RedisMapSessionStore)) { + return bean; + } + ProxyFactory proxy = new ProxyFactory(bean); + proxy.setProxyTargetClass(true); + proxy.addAdvice((MethodInterceptor) invocation -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + if (invocation.getMethod().getName().equals("save")) { + var pool = factory.getBean(DataSource.class).unwrap(HikariDataSource.class); + assertThat(pool.getHikariPoolMXBean().getActiveConnections()) + .as("Redis save must not retain a physical DB connection").isZero(); + } + assertThat(TransactionSynchronizationManager.getResourceMap().values()) + .noneMatch(EntityManagerHolder.class::isInstance); + return invocation.proceed(); + }); + return proxy.getProxy(); + } + }; + } + + @Bean + WebMvcConfigurer observeRequestPersistenceContext(EntityManagerFactory factory) { + return new WebMvcConfigurer() { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new HandlerInterceptor() { + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) { + request.setAttribute("test.osiv.bound", TransactionSynchronizationManager.hasResource(factory)); + return true; + } + }).order(Ordered.LOWEST_PRECEDENCE); + } + }; + } + + @Bean + static BeanFactoryPostProcessor excludeBackfillAttachment() { + return factory -> ((BeanDefinitionRegistry) factory).removeBeanDefinition("restaurantMediaBackfillAttachmentService"); + } + + @Bean("japanClock") + Clock clock() { + return CLOCK; + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageReaderTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageReaderTest.java new file mode 100644 index 00000000..58e9941c --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageReaderTest.java @@ -0,0 +1,71 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.restaurant.internal.map.MapQuerySession; + +class RestaurantMapPageReaderTest { + @Test + void 후보620개여도_10개와_lookahead를_찾으면_첫묶음에서_멈춘다() { + check(0, 1, 10); + } + + @Test + void 삭제된_70개를_건너뛰고_lookahead는_다음_cursor에_남긴다() { + check(70, 3, 80); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void check(int deleted, int batches, int nextPosition) { + var maps = mock(RestaurantMapService.class); + var restaurants = mock(RestaurantService.class); + var candidates = IntStream.rangeClosed(1, 620).mapToObj(id -> + new RestaurantMapCandidate((long) id, BigDecimal.ZERO, 0)).toList(); + var criteria = MapSearchCriteria.of(MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, null); + var session = new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), criteria, candidates, + null, Instant.now(), Instant.now().plusSeconds(1800)); + when(maps.findMatchingCandidates(any(), any())).thenAnswer(invocation -> { + Collection ids = invocation.getArgument(1); + assertThat(ids).hasSizeLessThanOrEqualTo(32); + return candidates.stream().filter(candidate -> ids.contains(candidate.restaurantId()) + && candidate.restaurantId() > deleted).toList(); + }); + when(maps.findActiveMapInfos(any())).thenAnswer(invocation -> { + Collection ids = invocation.getArgument(0); + return ids.stream().map(id -> new RestaurantMapInfo(id, "fixture", "restaurant", "sushi", + new RestaurantMapInfo.LocationInfo(BigDecimal.ZERO, BigDecimal.ZERO, Instant.now().plusSeconds(60)))) + .toList(); + }); + when(restaurants.findMapCards(any(), any())).thenReturn(List.of()); + var page = new RestaurantMapPageReader(maps, restaurants, Clock.systemUTC()) + .read(session, RestaurantMapSort.RECOMMEND, 0); + assertThat(page.hasNext()).isTrue(); + assertThat(page.nextPosition()).isEqualTo(nextPosition); + verify(maps, times(batches)).findMatchingCandidates(any(), any()); + var selected = ArgumentCaptor.forClass(List.class); + verify(restaurants).findMapCards(selected.capture(), any()); + assertThat((List) selected.getValue()).hasSize(10) + .extracting(RestaurantMapCandidate::restaurantId) + .containsExactlyElementsOf(IntStream.rangeClosed(deleted + 1, deleted + 10) + .mapToObj(id -> (long) id).toList()); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageServiceTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageServiceTest.java new file mode 100644 index 00000000..75e2b187 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapPageServiceTest.java @@ -0,0 +1,222 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent; +import org.sopt.hashi.restaurant.domain.MapSearchResultExtent.ResultBounds; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageRequest; +import org.sopt.hashi.restaurant.internal.map.MapCursorCodec; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics; +import org.sopt.hashi.restaurant.internal.map.MapQuerySession; +import org.sopt.hashi.restaurant.internal.map.MapSessionId; +import org.sopt.hashi.restaurant.internal.map.MapSessionLimits; +import org.sopt.hashi.restaurant.internal.map.MapSessionProperties; +import org.sopt.hashi.restaurant.internal.map.RedisMapSessionStore; +import org.sopt.hashi.shared.error.BusinessException; + +class RestaurantMapPageServiceTest { + @Test + void 신규조회_성공은_다섯_처리단계를_같은_operation으로_기록한다() { + var mapService = mock(RestaurantMapService.class); + var reader = mock(RestaurantMapPageReader.class); + var store = mock(RedisMapSessionStore.class); + var properties = enabledProperties(); + var registry = new SimpleMeterRegistry(); + Instant startedAt = Instant.parse("2026-01-01T00:00:00Z"); + Instant rankingAsOf = startedAt.plusSeconds(1); + Instant expiresAt = startedAt.plusSeconds(300); + var sessionId = new MapSessionId(UUID.randomUUID()); + when(store.admit(any(), org.mockito.ArgumentMatchers.eq(true))).thenReturn(startedAt); + when(mapService.findCandidates(any(), anyInt())) + .thenReturn(new RestaurantMapService.CandidateSnapshot(List.of(), rankingAsOf)); + when(store.save(any())).thenReturn(sessionId); + when(reader.read(any(), any(), anyInt())) + .thenReturn(new RestaurantMapPageReader.Page(List.of(), false, 0)); + when(store.touch(any(), any())).thenReturn(expiresAt); + var service = new RestaurantMapPageService(mapService, reader, store, + new MapCursorCodec(properties), new MapSessionLimits(), new MapCapacityMetrics(registry)); + var request = new RestaurantMapPageRequest(MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, null), + RestaurantMapSort.RECOMMEND, null, null); + + assertThat(service.getPage(request, "caller").querySessionId()).isEqualTo(sessionId.value()); + + assertThat(List.of("admit", "candidates", "save", "read_page", "touch")) + .allSatisfy(stage -> assertThat(registry.get("hashi.restaurant.map.stage.duration") + .tags("operation", "new_query", "stage", stage).timer().count()).isEqualTo(1)); + assertThat(registry.find("hashi.restaurant.map.stage.duration") + .tags("operation", "new_query", "stage", "load_session").timer()).isNull(); + } + + @Test + void 다음페이지와_정렬변경은_세션조회의_성공과실패를_기록하고_원래예외를_유지한다() { + for (boolean nextPage : List.of(false, true)) { + var reader = mock(RestaurantMapPageReader.class); + var store = mock(RedisMapSessionStore.class); + var properties = enabledProperties(); + var registry = new SimpleMeterRegistry(); + var cursors = new MapCursorCodec(properties); + var sessionId = new MapSessionId(UUID.randomUUID()); + var candidate = new RestaurantMapCandidate(1L, BigDecimal.ZERO, 0); + var session = new MapQuerySession(MapQuerySession.SCHEMA_VERSION, sessionId.id(), MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, null), List.of(candidate), + null, Instant.now(), Instant.now().plusSeconds(1800)); + var failure = new IllegalStateException("synthetic session load failure"); + when(store.find(sessionId)).thenReturn(session).thenThrow(failure); + when(reader.read(any(), any(), anyInt())) + .thenReturn(new RestaurantMapPageReader.Page(List.of(), false, 0)); + when(store.touch(any(), any())).thenReturn(Instant.now().plusSeconds(300)); + var service = new RestaurantMapPageService(mock(RestaurantMapService.class), reader, store, + cursors, new MapSessionLimits(), new MapCapacityMetrics(registry)); + var request = nextPage + ? new RestaurantMapPageRequest(null, null, null, + cursors.encode(sessionId, RestaurantMapSort.RECOMMEND, 1)) + : new RestaurantMapPageRequest(null, RestaurantMapSort.RATING, sessionId.value(), null); + String operation = nextPage ? "next_page" : "sort_change"; + + assertThat(service.getPage(request, "caller").content()).isEmpty(); + assertThatThrownBy(() -> service.getPage(request, "caller")).isSameAs(failure); + + assertThat(registry.get("hashi.restaurant.map.stage.duration") + .tags("operation", operation, "stage", "load_session").timer().count()).isEqualTo(2); + } + } + + @Test + void 검색결과의_가장이른좌표만료는_세션절대만료의_상한이다() { + var mapService = mock(RestaurantMapService.class); + var reader = mock(RestaurantMapPageReader.class); + var store = mock(RedisMapSessionStore.class); + var properties = enabledProperties(); + Instant startedAt = Instant.parse("2026-01-01T00:00:00Z"); + Instant rankingAsOf = startedAt.plusSeconds(1); + Instant coordinatesValidUntil = startedAt.plusSeconds(120); + var candidate = new RestaurantMapCandidate(1L, BigDecimal.ZERO, 0); + var extent = new MapSearchResultExtent(1, + new ResultBounds(new BigDecimal("0.5"), new BigDecimal("0.5"), + new BigDecimal("0.5"), new BigDecimal("0.5")), coordinatesValidUntil); + var sessionId = new MapSessionId(UUID.randomUUID()); + when(store.admit(any(), org.mockito.ArgumentMatchers.eq(true))).thenReturn(startedAt); + when(mapService.findCandidates(any(), anyInt())) + .thenReturn(new RestaurantMapService.CandidateSnapshot(List.of(candidate), rankingAsOf, extent)); + when(store.save(any())).thenReturn(sessionId); + when(reader.read(any(), any(), anyInt())) + .thenReturn(new RestaurantMapPageReader.Page(List.of(), false, 0)); + when(store.touch(any(), any())).thenReturn(coordinatesValidUntil); + var service = new RestaurantMapPageService(mapService, reader, store, + new MapCursorCodec(properties), new MapSessionLimits(), + new MapCapacityMetrics(new SimpleMeterRegistry())); + var request = new RestaurantMapPageRequest(MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, "fixture"), + RestaurantMapSort.RECOMMEND, null, null); + + var response = service.getPage(request, "caller"); + + var captor = ArgumentCaptor.forClass(MapQuerySession.class); + verify(store).save(captor.capture()); + assertThat(captor.getValue().expiresAt()).isEqualTo(coordinatesValidUntil); + assertThat(captor.getValue().searchResult()).isEqualTo(extent); + assertThat(response.searchResult().totalCount()).isEqualTo(1); + } + + @Test + void 후보수_상한거절은_별도_내부사유로_집계한다() { + var mapService = mock(RestaurantMapService.class); + var store = mock(RedisMapSessionStore.class); + var properties = enabledProperties(); + var registry = new SimpleMeterRegistry(); + when(store.admit(any(), org.mockito.ArgumentMatchers.eq(true))).thenReturn(Instant.now()); + when(mapService.findCandidates(any(), anyInt())) + .thenThrow(new BusinessException(RestaurantErrorCode.MAP_CAPACITY_EXCEEDED)); + var service = new RestaurantMapPageService(mapService, mock(RestaurantMapPageReader.class), store, + new MapCursorCodec(properties), new MapSessionLimits(), new MapCapacityMetrics(registry)); + var request = new RestaurantMapPageRequest(MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, null), + RestaurantMapSort.RECOMMEND, null, null); + + assertThatThrownBy(() -> service.getPage(request, "caller")) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_CAPACITY_EXCEEDED)); + assertThat(registry.get("hashi.restaurant.map.capacity.rejected") + .tag("reason", "candidate_count").counter().count()).isEqualTo(1); + assertThat(registry.get("hashi.restaurant.map.stage.duration") + .tags("operation", "new_query", "stage", "admit").timer().count()).isEqualTo(1); + assertThat(registry.get("hashi.restaurant.map.stage.duration") + .tags("operation", "new_query", "stage", "candidates").timer().count()).isEqualTo(1); + assertThat(registry.find("hashi.restaurant.map.stage.duration") + .tags("operation", "new_query", "stage", "save").timer()).isNull(); + } + + @Test + void 동시상한은_대기없이_거절하고_성공과_실패_모두_실행자리를_반환한다() throws Exception { + var reader = mock(RestaurantMapPageReader.class); + var store = mock(RedisMapSessionStore.class); + var properties = enabledProperties(); + var limits = new MapSessionLimits(); + limits.setConcurrentRequests(1); + var registry = new SimpleMeterRegistry(); + var service = new RestaurantMapPageService(mock(RestaurantMapService.class), reader, store, + new MapCursorCodec(properties), limits, new MapCapacityMetrics(registry)); + var session = new MapQuerySession(MapQuerySession.SCHEMA_VERSION, UUID.randomUUID(), MapSearchCriteria.of( + MapQueryBounds.parse("0", "1", "0", "1"), null, null, null, null), List.of(), + null, Instant.now(), Instant.now().plusSeconds(1800)); + when(store.find(any())).thenReturn(session); + when(store.touch(any(), any())).thenReturn(Instant.now().plusSeconds(300)); + var request = new RestaurantMapPageRequest(null, RestaurantMapSort.RECOMMEND, session.id().toString(), null); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + when(reader.read(any(), any(), org.mockito.ArgumentMatchers.anyInt())).thenAnswer(invocation -> { + entered.countDown(); + if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test release timeout"); + return new RestaurantMapPageReader.Page(List.of(), false, 0); + }); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(() -> service.getPage(request, "caller-a")); + try { + assertThat(entered.await(10, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> service.getPage(request, "caller-b")) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_CAPACITY_EXCEEDED)); + assertThat(registry.get("hashi.restaurant.map.capacity.rejected") + .tag("reason", "concurrent_requests").counter().count()).isEqualTo(1); + } finally { + release.countDown(); + } + assertThat(first.get(10, TimeUnit.SECONDS).content()).isEmpty(); + } + when(reader.read(any(), any(), org.mockito.ArgumentMatchers.anyInt())) + .thenThrow(new IllegalStateException("synthetic reader failure")) + .thenReturn(new RestaurantMapPageReader.Page(List.of(), false, 0)); + assertThatThrownBy(() -> service.getPage(request, "caller-a")).isInstanceOf(IllegalStateException.class); + assertThat(service.getPage(request, "caller-a").content()).isEmpty(); + } + + private MapSessionProperties enabledProperties() { + var properties = new MapSessionProperties(); + properties.setEnabled(true); + properties.setSigningKey(Base64.getEncoder().encodeToString(new byte[32])); + return properties; + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapQueryIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapQueryIntegrationTest.java new file mode 100644 index 00000000..14e11345 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapQueryIntegrationTest.java @@ -0,0 +1,810 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verifyNoInteractions; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.hibernate.SessionFactory; +import org.hibernate.resource.jdbc.spi.StatementInspector; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.MapBounds; +import org.sopt.hashi.restaurant.domain.MapCoordinates; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapRegion; +import org.sopt.hashi.restaurant.domain.MapRegionRepository; +import org.sopt.hashi.restaurant.domain.MapSearchCriteria; +import org.sopt.hashi.restaurant.domain.PriceCurrency; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantLocationSource; +import org.sopt.hashi.restaurant.domain.RestaurantLocationStatus; +import org.sopt.hashi.restaurant.domain.RestaurantMapCandidate; +import org.sopt.hashi.restaurant.domain.RestaurantMapQueryRepository; +import org.sopt.hashi.restaurant.domain.RestaurantMenu; +import org.sopt.hashi.restaurant.domain.RestaurantPlaceType; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.dto.RestaurantListResponse.RestaurantSummaryResponse; +import org.sopt.hashi.restaurant.dto.RestaurantMapRegionsResponse.RegionResponse; +import org.sopt.hashi.restaurant.internal.map.MapQueryProperties; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.shared.storage.FileStorage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.modulith.test.ApplicationModuleTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@ApplicationModuleTest +@Import(RestaurantMapQueryIntegrationTest.Infrastructure.class) +@TestPropertySource(properties = { + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.generate_statistics=true", + "spring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED", + "spring.flyway.enabled=true", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", + "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test" +}) +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class RestaurantMapQueryIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final LocalDateTime UTC_NOW = LocalDateTime.ofInstant(NOW, ZoneOffset.UTC); + private static final MapQueryBounds BOUNDS = MapQueryBounds.parse("0", "1", "0", "1"); + private static final List SQL = new CopyOnWriteArrayList<>(); + private static final AtomicReference AGGREGATE_BARRIER = new AtomicReference<>(); + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi_map_query").withUsername("hashi").withPassword("hashi") + .withUrlParam("serverTimezone", "Asia/Seoul"); + + @Autowired private RestaurantMapService service; + @Autowired private RestaurantService restaurantService; + @Autowired private RestaurantMapQueryRepository queries; + @Autowired private RestaurantRepository restaurants; + @Autowired private MapRegionRepository regions; + @Autowired private RestaurantPort port; + @Autowired private MapQueryProperties properties; + @Autowired private EntityManager entityManager; + @Autowired private EntityManagerFactory entityManagerFactory; + @Autowired private JdbcTemplate jdbc; + @Autowired private PlatformTransactionManager transactionManager; + @MockitoBean private MediaPort mediaPort; + @MockitoBean private FileStorage fileStorage; + @MockitoBean private RedisTemplate redisTemplate; + + @BeforeEach + void configureSyntheticBounds() { + properties.setInitialBounds(propertyBounds("0", "1")); + properties.setSupportedBounds(propertyBounds("-1", "2")); + } + + @Test + void 실제_MySQL에서_네_경계를_포함하며_정상_0좌표와_미분류도_반환한다() { + List expected = List.of(ready("south", "0", ".5").getId(), + ready("north", "1", ".5").getId(), ready("west", ".5", "0").getId(), + ready("east", ".5", "1").getId(), ready("origin", "0", "0").getId()); + ready("outside", "1.000001", ".5"); + flushAndReset(); + var snapshot = service.findCandidates(criteria(null), 20); + assertThat(snapshot.candidates()).extracting(RestaurantMapCandidate::restaurantId).containsExactlyElementsOf(expected); + assertThat(snapshot.rankingAsOf()).isEqualTo(NOW); + assertThat(snapshot.resultExtent()).isNull(); + assertThat(statistics().getPrepareStatementCount()).isEqualTo(1); + assertThat(statistics().getEntityLoadCount()).isZero(); + verifyNoInteractions(mediaPort, fileStorage); + } + + @Test + void 긴_소수점_입력은_DB에서도_그대로_비교하고_경계를_반올림하지_않는다() { + ready("zero", "0", "0"); + Long inside = ready("micro", ".000001", ".000001").getId(); + ready("one", "1", "1"); + flushAndReset(); + var query = MapSearchCriteria.of(MapQueryBounds.parse("0.000000000001", "0.999999999999", + "0.000000000001", "0.999999999999"), null, null, null, null); + assertThat(service.findCandidates(query, 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(inside); + } + + @Test + void 후보_평점과_리뷰수는_조회시점의_값이며_이후_DB변경이_반환값을_바꾸지_않는다() { + Long id = ready("statistics", ".5", ".5").getId(); + entityManager.flush(); + jdbc.update("update restaurant set rating_sum=9, review_count=2, rating=4.5 where id=?", id); + flushAndReset(); + var snapshot = service.findCandidates(criteria(null), 10); + assertThat(snapshot.candidates().getFirst().rating()).isEqualByComparingTo("4.5"); + assertThat(snapshot.candidates().getFirst().reviewCount()).isEqualTo(2); + jdbc.update("update restaurant set rating_sum=12, review_count=3, rating=4.0 where id=?", id); + var current = service.findMatchingCandidates(criteria(null), List.of(id)).getFirst(); + assertThat(current.rating()).isEqualByComparingTo("4.0"); + assertThat(current.reviewCount()).isEqualTo(3); + assertThat(snapshot.candidates().getFirst().rating()).isEqualByComparingTo("4.5"); + assertThat(snapshot.candidates().getFirst().reviewCount()).isEqualTo(2); + } + + @Test + void DB의_UTC_DATETIME을_연결_시간대와_무관하게_조회하고_직렬화한다() { + Long id = ready("UTC storage", ".5", ".5").getId(); + entityManager.flush(); + jdbc.update(""" + update restaurant_location set obtained_at='2025-12-31 23:00:00.000000', + valid_until='2026-01-01 01:00:00.123456' + where id=(select location_id from restaurant where id=?) + """, id); + flushAndReset(); + assertThat(service.findCandidates(criteria(null), 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(id); + assertThat(port.findActiveMapInfos(List.of(id)).getFirst().location().validUntil()) + .isEqualTo(Instant.parse("2026-01-01T01:00:00.123456Z")); + } + + @Test + void 삭제_위치없음_미준비_주소변경_만료경계는_후보에서_제외한다() { + Restaurant deleted = ready("deleted", ".5", ".5"); + deleted.softDelete(); + restaurants.save(restaurant("no location", RestaurantGenre.SUSHI, RestaurantPlaceType.RESTAURANT)); + Restaurant pending = restaurant("pending", RestaurantGenre.SUSHI, RestaurantPlaceType.RESTAURANT); + pending.requestLocationResolution(); + restaurants.save(pending); + Restaurant rejected = restaurant("rejected", RestaurantGenre.SUSHI, RestaurantPlaceType.RESTAURANT); + rejected.requestLocationResolution(); + rejected.rejectLocation(1, rejected.getLocation().getRequestId(), RestaurantLocationStatus.REVIEW_REQUIRED); + restaurants.save(rejected); + Restaurant changed = ready("changed", ".5", ".5"); + changed.updateBasicInfo(null, null, null, null, "new synthetic address", null, null, null, null, null, null, null); + Restaurant expired = ready("boundary", ".5", ".5"); + Long fresh = ready("micro future", ".5", ".5").getId(); + entityManager.flush(); + jdbc.update("update restaurant_location set valid_until=? where id=?", UTC_NOW, expired.getLocation().getId()); + jdbc.update("update restaurant_location set valid_until=? where id=(select location_id from restaurant where id=?)", + UTC_NOW.plusNanos(1000), fresh); + flushAndReset(); + assertThat(service.findCandidates(criteria(null), 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(fresh); + } + + @ParameterizedTest + @EnumSource(value = RestaurantLocationStatus.class, names = {"PENDING", "RETRY_WAIT", "FAILED", "REVIEW_REQUIRED"}) + void 같은_주소의_갱신_상태와_무관하게_유효_좌표를_모든_지도_조회에서_유지한다(RestaurantLocationStatus status) { + MapRegion region = activeRegion("REFRESH", 0); + Restaurant restaurant = ready("refresh", ".5", ".5"); + restaurant.assignMapRegion(region.getId()); + restaurant.refreshLocation(); + var location = restaurant.getLocation(); + if (status == RestaurantLocationStatus.RETRY_WAIT) { + restaurant.deferLocation(location.getAddressRevision(), location.getRequestId(), UTC_NOW.plusMinutes(5), CLOCK); + } else if (status != RestaurantLocationStatus.PENDING) { + restaurant.rejectLocation(location.getAddressRevision(), location.getRequestId(), status); + } + flushAndReset(); + + Long id = restaurant.getId(); + var filter = MapSearchCriteria.of(BOUNDS, region.getId(), null, null, null); + assertThat(service.findCandidates(criteria(null), 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(id); + assertThat(service.findCandidates(filter, 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(id); + assertThat(service.findMatchingCandidates(filter, List.of(id))) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(id); + assertThat(service.getRegions().regions().getFirst().restaurantCount()).isEqualTo(1); + assertThat(service.getLocation(id).location().latitude()).isEqualByComparingTo(".5"); + assertThat(port.findActiveMapInfos(List.of(id)).getFirst().location().validUntil()) + .isEqualTo(NOW.plusSeconds(3600)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void 갱신중_주소변경과_만료는_모든_지도에서_제외하되_일반목록과_Port의_식당은_유지한다(boolean addressChanged) { + MapRegion region = activeRegion("INVALID", 0); + Restaurant restaurant = ready("retained restaurant", ".5", ".5"); + restaurant.assignMapRegion(region.getId()); + restaurant.refreshLocation(); + if (addressChanged) { + restaurant.updateBasicInfo(null, null, null, null, "changed address", null, + null, null, null, null, null, null); + } + entityManager.flush(); + if (!addressChanged) { + jdbc.update("update restaurant_location set valid_until=? where id=?", UTC_NOW, restaurant.getLocation().getId()); + } + flushAndReset(); + + Long id = restaurant.getId(); + var filter = MapSearchCriteria.of(BOUNDS, region.getId(), null, null, null); + assertThat(service.findCandidates(criteria(null), 10).candidates()).isEmpty(); + assertThat(service.findCandidates(filter, 10).candidates()).isEmpty(); + assertThat(service.findMatchingCandidates(filter, List.of(id))).isEmpty(); + assertThat(service.getRegions().regions().getFirst().restaurantCount()).isZero(); + assertCode(() -> service.getLocation(id), RestaurantErrorCode.MAP_LOCATION_UNAVAILABLE); + assertThat(port.findActiveMapInfos(List.of(id))).singleElement().satisfies(info -> { + assertThat(info.restaurantId()).isEqualTo(id); + assertThat(info.location()).isNull(); + }); + assertThat(restaurantService.getRestaurants(null, null, null, null, null, 10).content()) + .extracting(RestaurantSummaryResponse::restaurantId).containsExactly(id); + } + + @Test + void 식당명과_여러_메뉴가_함께_맞아도_한번만_반환하고_대소문자를_구분하지_않는다() { + Restaurant match = ready("SuShI house", ".5", ".5"); + match.addMenu(menu("sushi first")); + match.addMenu(menu("SUSHI second")); + Restaurant menuOnly = ready("menu only", ".5", ".5"); + menuOnly.addMenu(menu("sushi menu")); + ready("unrelated", ".5", ".5"); + flushAndReset(); + assertThat(service.findCandidates(criteria(" sUsHi "), 2).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(match.getId(), menuOnly.getId()); + assertThat(statistics().getPrepareStatementCount()).isEqualTo(2); + assertThat(statistics().getEntityLoadCount()).isZero(); + } + + @Test + void 여러_검색어는_식당명_메뉴_해시태그에서_OR로_찾고_중복후보를_만들지_않는다() { + Restaurant everyField = ready("SUSHI house", ".5", ".5"); + everyField.addMenu(menu("ramen menu")); + everyField.replaceHashtags(List.of("night-view")); + Restaurant nameOnly = ready("sushi name", ".5", ".5"); + Restaurant menuOnly = ready("menu only", ".5", ".5"); + menuOnly.addMenu(menu("ramen special")); + Restaurant hashtagOnly = ready("tag only", ".5", ".5"); + hashtagOnly.replaceHashtags(List.of("night-view")); + ready("unrelated", ".5", ".5"); + flushAndReset(); + + assertThat(service.findCandidates(criteria("SuShI ramen NIGHT"), 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId) + .containsExactly(everyField.getId(), nameOnly.getId(), menuOnly.getId(), hashtagOnly.getId()); + assertThat(statistics().getPrepareStatementCount()).isEqualTo(2); + assertThat(statistics().getEntityLoadCount()).isZero(); + } + + @Test + void 샵_검색은_해시태그만_대소문자없이_리터럴_부분일치한다() { + Restaurant hashtag = ready("plain", ".5", ".5"); + hashtag.replaceHashtags(List.of("Best_Night%Spot")); + ready("night% name", ".5", ".5"); + Restaurant menu = ready("menu only", ".5", ".5"); + menu.addMenu(menu("night% menu")); + flushAndReset(); + + assertThat(service.findCandidates(criteria("#nIgHt%"), 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId) + .containsExactly(hashtag.getId()); + } + + @Test + void 검색결과가_없으면_전체개수는_0이고_결과경계는_없다() { + ready("unrelated", ".5", ".5"); + flushAndReset(); + + var snapshot = service.findCandidates(criteria("missing"), 10); + + assertThat(snapshot.candidates()).isEmpty(); + assertThat(snapshot.resultExtent().totalCount()).isZero(); + assertThat(snapshot.resultExtent().bounds()).isNull(); + assertThat(snapshot.resultExtent().earliestValidUntil()).isNull(); + } + + @Test + void 검색결과가_하나면_같은_남북동서_좌표를_결과경계로_반환한다() { + Restaurant match = ready("single match", ".25", ".75"); + ready("unrelated", ".1", ".9"); + flushAndReset(); + + var snapshot = service.findCandidates(criteria("match"), 10); + + assertThat(snapshot.candidates()).extracting(RestaurantMapCandidate::restaurantId) + .containsExactly(match.getId()); + assertThat(snapshot.resultExtent().totalCount()).isOne(); + assertThat(snapshot.resultExtent().bounds().south()).isEqualByComparingTo(".25"); + assertThat(snapshot.resultExtent().bounds().north()).isEqualByComparingTo(".25"); + assertThat(snapshot.resultExtent().bounds().west()).isEqualByComparingTo(".75"); + assertThat(snapshot.resultExtent().bounds().east()).isEqualByComparingTo(".75"); + assertThat(snapshot.resultExtent().earliestValidUntil()).isEqualTo(NOW.plusSeconds(3600)); + } + + @Test + void 결과경계는_첫_10개를_넘는_현재영역_카테고리_키워드_전체결과를_포함한다() { + List expected = new ArrayList<>(); + for (int index = 0; index < 11; index++) { + expected.add(ready("match cafe " + index, ".2", ".2", RestaurantPlaceType.CAFE).getId()); + } + Restaurant edge = ready("match cafe edge", ".9", ".8", RestaurantPlaceType.CAFE); + expected.add(edge.getId()); + ready("match wrong category", ".05", ".95", RestaurantPlaceType.BAR); + ready("unrelated cafe", ".01", ".99", RestaurantPlaceType.CAFE); + entityManager.flush(); + jdbc.update("update restaurant_location set valid_until=? where id=?", + UTC_NOW.plusMinutes(30), edge.getLocation().getId()); + flushAndReset(); + + MapSearchCriteria filter = MapSearchCriteria.of(BOUNDS, null, null, "cafe", "match"); + var snapshot = service.findCandidates(filter, 20); + + assertThat(snapshot.candidates()).extracting(RestaurantMapCandidate::restaurantId) + .containsExactlyElementsOf(expected); + assertThat(snapshot.resultExtent().totalCount()).isEqualTo(12); + assertThat(snapshot.resultExtent().bounds().south()).isEqualByComparingTo(".2"); + assertThat(snapshot.resultExtent().bounds().north()).isEqualByComparingTo(".9"); + assertThat(snapshot.resultExtent().bounds().west()).isEqualByComparingTo(".2"); + assertThat(snapshot.resultExtent().bounds().east()).isEqualByComparingTo(".8"); + assertThat(snapshot.resultExtent().earliestValidUntil()).isEqualTo(NOW.plusSeconds(1800)); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void READ_COMMITTED_환경의_동시삭제에도_후보와_검색결과집계는_같은_스냅샷을_본다() throws Exception { + assertThat(jdbc.queryForObject("select @@transaction_isolation", String.class)) + .isEqualTo("READ-COMMITTED"); + Long restaurantId = new TransactionTemplate(transactionManager).execute( + status -> ready("rr-concurrent-fixture", ".25", ".75").getId()); + AggregateBarrier barrier = new AggregateBarrier(new CountDownLatch(1), new CountDownLatch(1)); + assertThat(AGGREGATE_BARRIER.compareAndSet(null, barrier)).isTrue(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + var snapshotFuture = executor.submit( + () -> service.findCandidates(criteria("rr-concurrent-fixture"), 10)); + assertThat(barrier.aggregateReached().await(20, TimeUnit.SECONDS)).isTrue(); + + assertThat(jdbc.update("update restaurant set deleted=true where id=?", restaurantId)).isOne(); + barrier.continueAggregate().countDown(); + + var snapshot = snapshotFuture.get(20, TimeUnit.SECONDS); + assertThat(snapshot.candidates()).extracting(RestaurantMapCandidate::restaurantId) + .containsExactly(restaurantId); + assertThat(snapshot.resultExtent().totalCount()).isOne(); + assertThat(snapshot.resultExtent().bounds().south()).isEqualByComparingTo(".25"); + assertThat(snapshot.resultExtent().bounds().north()).isEqualByComparingTo(".25"); + } finally { + barrier.continueAggregate().countDown(); + AGGREGATE_BARRIER.compareAndSet(barrier, null); + executor.shutdownNow(); + jdbc.update("update restaurant set deleted=true where id=?", restaurantId); + } + } + + @Test + void 퍼센트_밑줄_escape_백슬래시는_리터럴로_검색한다() { + Restaurant literal = ready("100%_!\\ hit", ".5", ".5"); + Restaurant menuLiteral = ready("menu", ".5", ".5"); + menuLiteral.addMenu(menu("100%_!\\ menu")); + ready("100ANY!\\ false", ".5", ".5"); + flushAndReset(); + assertThat(service.findCandidates(criteria("%_!\\"), 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(literal.getId(), menuLiteral.getId()); + } + + @ParameterizedTest + @MethodSource("inheritedSearchKeywords") + void 일반_목록과_지도는_단일_특수문자_검색결과가_같다( + String keyword, String matchingName, String nonMatchingName) { + Restaurant nameMatch = ready(matchingName, ".5", ".5"); + nameMatch.addMenu(menu(matchingName + " first")); + nameMatch.addMenu(menu(matchingName + " second")); + Restaurant menuMatch = ready("menu only", ".5", ".5"); + menuMatch.addMenu(menu(matchingName)); + ready(nonMatchingName, ".5", ".5"); + Restaurant otherMenu = ready("other menu only", ".5", ".5"); + otherMenu.addMenu(menu(nonMatchingName)); + Restaurant deleted = ready(matchingName + " deleted", ".5", ".5"); + deleted.softDelete(); + flushAndReset(); + + assertSearchMatches(keyword, List.of(nameMatch.getId(), menuMatch.getId())); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\u00a0\u3000"}) + void 일반_목록은_빈_검색어를_생략하고_지도는_미지정만_허용한다(String keyword) { + Restaurant first = ready("first", ".5", ".5"); + Restaurant second = ready("second", ".5", ".5"); + Restaurant deleted = ready("deleted", ".5", ".5"); + deleted.softDelete(); + flushAndReset(); + + assertThat(restaurantService.getRestaurants(keyword, null, null, null, null, 10).content()) + .extracting(RestaurantSummaryResponse::restaurantId) + .containsExactlyInAnyOrder(first.getId(), second.getId()); + if (keyword == null) { + assertSearchMatches(null, List.of(first.getId(), second.getId())); + } else { + assertThatThrownBy(() -> service.findCandidates(criteria(keyword), 10)) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(CommonErrorCode.INVALID_INPUT)); + } + } + + private static Stream inheritedSearchKeywords() { + return Stream.of( + Arguments.of("%", "100% house", "100ANY house"), + Arguments.of("_", "under_score", "underXscore"), + Arguments.of("!", "wow! house", "wow house"), + Arguments.of("\\", "slash\\name", "slashname"), + Arguments.of("%_!\\", "100%_!\\ hit", "100ANY!\\ false") + ); + } + + @Test + void 지역_ID와_장르_음식점분류_검색어를_함께_적용한다() { + MapRegion region = activeRegion("FILTER", 0); + Restaurant chosen = ready("chosen", ".5", ".5"); + chosen.assignMapRegion(region.getId()); + Restaurant otherGenre = ready("chosen noodle", ".5", ".5"); + otherGenre.assignMapRegion(region.getId()); + otherGenre.updateBasicInfo(null, null, null, null, null, null, RestaurantGenre.NOODLE, + null, null, null, null, null); + Restaurant otherType = ready("chosen cafe", ".5", ".5"); + otherType.assignMapRegion(region.getId()); + otherType.updateBasicInfo(null, null, null, null, null, null, null, null, RestaurantPlaceType.CAFE, null, null, null); + ready("chosen unclassified", ".5", ".5"); + flushAndReset(); + var filter = MapSearchCriteria.of(BOUNDS, region.getId(), "sushi", "restaurant", "chosen"); + assertThat(service.findCandidates(filter, 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(chosen.getId()); + } + + @Test + void 없는_지역과_비활성_지역은_400이며_빈_유효검색은_성공이다() { + MapRegion inactive = regions.saveAndFlush(MapRegion.create("INACTIVE", "합성 지역", point(".5", ".5"), bounds(), 0)); + for (long id : List.of(inactive.getId(), Long.MAX_VALUE)) { + assertCode(() -> service.findCandidates(MapSearchCriteria.of(BOUNDS, id, null, null, null), 10), + RestaurantErrorCode.MAP_REGION_INVALID); + } + assertThat(service.findCandidates(criteria(null), 10).candidates()).isEmpty(); + } + + @Test + void 후보는_상한_플러스_하나로_초과를_감지하고_잘린_성공을_반환하지_않는다() { + ready("first", ".5", ".5"); + ready("second", ".5", ".5"); + ready("third", ".5", ".5"); + flushAndReset(); + assertThat(queries.findCandidates(criteria(null), NOW, 2)).hasSize(2); + assertCode(() -> service.findCandidates(criteria(null), 2), RestaurantErrorCode.MAP_CAPACITY_EXCEEDED); + assertThat(service.findCandidates(criteria(null), 3).candidates()).hasSize(3); + assertThat(SQL).anySatisfy(sql -> assertThat(sql).containsIgnoringCase("limit")); + } + + @ParameterizedTest + @ValueSource(ints = {500, 501, 620}) + void 호출자가_충분한_용량을_전달하면_500개를_넘는_후보도_누락없이_반환한다(int count) { + List ids = new ArrayList<>(); + for (int index = 0; index < count; index++) { + ids.add(ready("candidate " + index, ".5", ".5").getId()); + } + flushAndReset(); + + assertThat(service.findCandidates(criteria(null), count).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactlyElementsOf(ids); + assertThat(statistics().getPrepareStatementCount()).isEqualTo(1); + assertThat(statistics().getEntityLoadCount()).isZero(); + } + + @Test + void 지정_ID를_같은_조건으로_재검사해_순서와_중복제거를_유지한다() { + Restaurant first = ready("match first", ".5", ".5"); + Restaurant second = ready("match second", ".5", ".5"); + Restaurant removed = ready("match removed", ".5", ".5"); + service.findCandidates(criteria("match"), 10); + removed.softDelete(); + first.updateBasicInfo("renamed", null, null, null, null, null, null, null, null, null, null, null); + Restaurant newMatch = ready("match new", ".5", ".5"); + flushAndReset(); + assertThat(service.findMatchingCandidates(criteria("match"), + List.of(second.getId(), first.getId(), removed.getId(), second.getId()))) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(second.getId()); + assertThat(service.findMatchingCandidates(criteria("match"), List.of(newMatch.getId(), second.getId()))) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(newMatch.getId(), second.getId()); + } + + @Test + void 관광지역은_0건도_표시순서와_ID순서로_반환하며_같은_유효조건을_집계한다() { + MapRegion later = activeRegion("LATER", 2); + MapRegion first = activeRegion("FIRST", 0); + MapRegion tied = activeRegion("TIED", 0); + Restaurant valid = ready("valid", "0", "0"); + valid.assignMapRegion(first.getId()); + Restaurant deleted = ready("deleted", ".5", ".5"); + deleted.assignMapRegion(first.getId()); + deleted.softDelete(); + Restaurant expired = ready("expired", ".5", ".5"); + expired.assignMapRegion(first.getId()); + ready("unclassified", ".5", ".5"); + entityManager.flush(); + jdbc.update("update restaurant_location set valid_until=? where id=?", UTC_NOW, expired.getLocation().getId()); + flushAndReset(); + var response = service.getRegions(); + assertThat(response.regions()).extracting(RegionResponse::mapRegionId) + .containsExactly(first.getId(), tied.getId(), later.getId()); + assertThat(response.regions()).extracting(RegionResponse::restaurantCount).containsExactly(1L, 0L, 0L); + assertThat(statistics().getPrepareStatementCount()).isEqualTo(1); + assertThat(statistics().getEntityLoadCount()).isZero(); + assertThat(service.findCandidates(MapSearchCriteria.of(BOUNDS, first.getId(), null, null, null), 10).candidates()) + .hasSize(1); + } + + @Test + @ExtendWith(OutputCaptureExtension.class) + void 지역_범위밖_매핑은_count에서_제외하되_다른_지역과_유효count를_반환하고_이상건수를_기록한다(CapturedOutput output) { + MapRegion region = activeRegion("MISMATCH", 0); + MapRegion other = activeRegion("OTHER", 1); + Restaurant valid = ready("inside", ".5", ".5"); + valid.assignMapRegion(region.getId()); + Restaurant outside = ready("outside", "1.1", ".5"); + outside.assignMapRegion(region.getId()); + flushAndReset(); + var summary = queries.findActiveRegions(NOW).getFirst(); + assertThat(summary.restaurantCount()).isEqualTo(1); + assertThat(summary.outsideBoundsCount()).isEqualTo(1); + var response = service.getRegions(); + assertThat(response.regions()).extracting(RegionResponse::mapRegionId).containsExactly(region.getId(), other.getId()); + assertThat(response.regions()).extracting(RegionResponse::restaurantCount).containsExactly(1L, 0L); + var widerBounds = MapQueryBounds.parse("0.2", "1.2", "0", "1"); + var regionalCriteria = MapSearchCriteria.of(widerBounds, region.getId(), null, null, null); + assertThat(service.findCandidates(regionalCriteria, 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(valid.getId()); + assertThat(service.findMatchingCandidates(regionalCriteria, List.of(valid.getId(), outside.getId()))) + .extracting(RestaurantMapCandidate::restaurantId).containsExactly(valid.getId()); + assertThat(output).contains("Map region mapping outside bounds: regionId=" + region.getId() + ", count=1"); + } + + @Test + void 실제_지역_seed가_없거나_지역_화면이_제한을_넘으면_503이다() { + assertCode(service::getRegions, RestaurantErrorCode.MAP_CONFIGURATION_UNAVAILABLE); + MapRegion oversized = MapRegion.create("OVERSIZED", "합성 지역", point(".5", ".5"), + MapBounds.of(BigDecimal.ZERO, new BigDecimal("1.1"), BigDecimal.ZERO, BigDecimal.ONE), 0); + oversized.activate(); + regions.saveAndFlush(oversized); + assertCode(service::getRegions, RestaurantErrorCode.MAP_CONFIGURATION_UNAVAILABLE); + } + + @Test + void 공개_Port는_삭제만_제외하고_위치없는_식당과_만료식당은_null_위치로_남긴다() { + Restaurant ready = ready("ready", "0", "0"); + Restaurant missing = restaurants.save(restaurant("missing", RestaurantGenre.ETC, RestaurantPlaceType.CAFE)); + Restaurant expired = ready("expired", ".5", ".5"); + Restaurant deleted = ready("deleted", ".5", ".5"); + deleted.softDelete(); + entityManager.flush(); + jdbc.update("update restaurant_location set valid_until=? where id=?", UTC_NOW, expired.getLocation().getId()); + flushAndReset(); + var infos = port.findActiveMapInfos(List.of(expired.getId(), ready.getId(), missing.getId(), deleted.getId(), ready.getId())); + assertThat(infos).extracting(RestaurantMapInfo::restaurantId).containsExactly(expired.getId(), ready.getId(), missing.getId()); + assertThat(infos.get(0).location()).isNull(); + assertThat(infos.get(1).location().latitude()).isEqualByComparingTo("0"); + assertThat(infos.get(1).location().validUntil()).isEqualTo(NOW.plusSeconds(3600)); + assertThat(infos.get(2).location()).isNull(); + assertThat(infos.get(2).placeType()).isEqualTo("cafe"); + assertThat(infos.get(2).genre()).isEqualTo("etc"); + assertThat(statistics().getPrepareStatementCount()).isEqualTo(1); + assertThat(statistics().getEntityLoadCount()).isZero(); + verifyNoInteractions(mediaPort, fileStorage); + } + + @Test + void 선택식당은_삭제와_위치미준비를_구분하고_설정없이도_현재위치를_반환한다() { + Restaurant ready = ready("ready", "0", "0"); + Restaurant missing = restaurants.save(restaurant("missing", RestaurantGenre.SUSHI, RestaurantPlaceType.RESTAURANT)); + Restaurant deleted = ready("deleted", ".5", ".5"); + deleted.softDelete(); + flushAndReset(); + properties.setInitialBounds(new MapQueryProperties.Bounds()); + assertThat(service.getLocation(ready.getId()).location().validUntil()).isEqualTo(NOW.plusSeconds(3600)); + assertCode(() -> service.getLocation(missing.getId()), RestaurantErrorCode.MAP_LOCATION_UNAVAILABLE); + assertCode(() -> service.getLocation(deleted.getId()), RestaurantErrorCode.NOT_FOUND); + assertCode(() -> service.getLocation(Long.MAX_VALUE), RestaurantErrorCode.NOT_FOUND); + } + + @Test + void 실제_501개_Port와_재검사는_각각_2쿼리이며_item별_조회가_없다() { + List ids = new ArrayList<>(); + for (int index = 0; index < 501; index++) { + ids.add(ready("batch " + index, ".5", ".5").getId()); + } + Collections.reverse(ids); + flushAndReset(); + assertThat(port.findActiveMapInfos(ids)).extracting(RestaurantMapInfo::restaurantId).containsExactlyElementsOf(ids); + assertThat(statistics().getPrepareStatementCount()).isEqualTo(2); + assertThat(statistics().getEntityLoadCount()).isZero(); + statistics().clear(); + assertThat(service.findMatchingCandidates(criteria(null), ids)) + .extracting(RestaurantMapCandidate::restaurantId).containsExactlyElementsOf(ids); + assertThat(statistics().getPrepareStatementCount()).isEqualTo(2); + assertThat(statistics().getEntityLoadCount()).isZero(); + verifyNoInteractions(mediaPort, fileStorage); + } + + @Test + void 실제_후보_SQL의_EXPLAIN을_합성데이터에서_기록한다() { + for (int index = 0; index < 20; index++) { + ready("plan " + index, ".5", ".5"); + } + flushAndReset(); + assertThat(service.findCandidates(criteria(null), 30).candidates()).hasSize(20); + String candidateSql = SQL.stream().filter(sql -> sql.contains("select r.id, r.rating")).findFirst().orElseThrow(); + List> plan = jdbc.queryForList("EXPLAIN " + candidateSql, + "2026-01-01 00:00:00.000000", BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ONE, 31); + assertThat(plan).isNotEmpty(); + System.out.println("MAP candidate EXPLAIN; 20 synthetic rows, not production performance: " + plan); + } + + private void assertSearchMatches(String keyword, List expectedIds) { + assertThat(restaurantService.getRestaurants(keyword, null, null, null, null, 10).content()) + .extracting(RestaurantSummaryResponse::restaurantId) + .containsExactlyInAnyOrderElementsOf(expectedIds); + assertThat(service.findCandidates(criteria(keyword), 10).candidates()) + .extracting(RestaurantMapCandidate::restaurantId) + .containsExactlyInAnyOrderElementsOf(expectedIds); + } + + private Restaurant ready(String name, String latitude, String longitude) { + return ready(name, latitude, longitude, RestaurantPlaceType.RESTAURANT); + } + + private Restaurant ready(String name, String latitude, String longitude, RestaurantPlaceType type) { + Restaurant restaurant = restaurant(name, RestaurantGenre.SUSHI, type); + restaurant.requestLocationResolution(); + restaurant.completeLocation(1, restaurant.getLocation().getRequestId(), point(latitude, longitude), + RestaurantLocationSource.ADMIN, UTC_NOW.minusHours(1), UTC_NOW.plusHours(1), CLOCK); + return restaurants.save(restaurant); + } + + private Restaurant restaurant(String name, RestaurantGenre genre, RestaurantPlaceType type) { + return Restaurant.create(name, "fixture", "fixture", "fixture", "synthetic address", "same display area", + genre, "fixture", type, PriceCurrency.JPY, BigDecimal.ONE, BigDecimal.TEN); + } + + private MapRegion activeRegion(String code, int order) { + MapRegion region = MapRegion.create(code, "합성 지역", point(".5", ".5"), bounds(), order); + region.activate(); + return regions.saveAndFlush(region); + } + + private RestaurantMenu menu(String name) { + return RestaurantMenu.create(name, "fixture", null, PriceCurrency.JPY, BigDecimal.ONE, false); + } + + private MapSearchCriteria criteria(String keyword) { + return MapSearchCriteria.of(BOUNDS, null, null, null, keyword); + } + + private static MapCoordinates point(String latitude, String longitude) { + return MapCoordinates.of(new BigDecimal(latitude), new BigDecimal(longitude)); + } + + private static MapBounds bounds() { + return MapBounds.of(BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ONE); + } + + private MapQueryProperties.Bounds propertyBounds(String minimum, String maximum) { + var bounds = new MapQueryProperties.Bounds(); + bounds.setSouth(minimum); + bounds.setNorth(maximum); + bounds.setWest(minimum); + bounds.setEast(maximum); + return bounds; + } + + private void flushAndReset() { + entityManager.flush(); + entityManager.clear(); + statistics().clear(); + SQL.clear(); + } + + private Statistics statistics() { + return entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + } + + private void assertCode(Runnable query, RestaurantErrorCode code) { + assertThatThrownBy(query::run).isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(code)); + } + + private static void awaitAggregateBarrier(String sql) { + AggregateBarrier barrier = AGGREGATE_BARRIER.get(); + if (barrier == null || !sql.contains("select count(*)") || !sql.contains("min(l.valid_until)")) { + return; + } + barrier.aggregateReached().countDown(); + try { + if (!barrier.continueAggregate().await(20, TimeUnit.SECONDS)) { + throw new IllegalStateException("검색 결과 집계 동시성 테스트가 시간 안에 재개되지 않았습니다"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("검색 결과 집계 동시성 테스트 대기가 중단됐습니다", exception); + } + } + + private record AggregateBarrier(CountDownLatch aggregateReached, CountDownLatch continueAggregate) { + } + + @TestConfiguration(proxyBeanMethods = false) + @EnableJpaAuditing + static class Infrastructure { + /** 기존 backfill 전용 빈은 항상 등록되므로 일반 조회 모듈 테스트에서는 그 빈만 제외한다. */ + @Bean + static BeanFactoryPostProcessor excludeBackfillAttachment() { + return factory -> ((BeanDefinitionRegistry) factory) + .removeBeanDefinition("restaurantMediaBackfillAttachmentService"); + } + + @Bean("japanClock") + Clock clock() { + return CLOCK; + } + + @Bean + HibernatePropertiesCustomizer statementInspector() { + return properties -> properties.put("hibernate.session_factory.statement_inspector", (StatementInspector) sql -> { + SQL.add(sql); + awaitAggregateBarrier(sql); + return sql; + }); + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapServiceTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapServiceTest.java new file mode 100644 index 00000000..cff3145d --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantMapServiceTest.java @@ -0,0 +1,58 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.List; +import java.util.stream.LongStream; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.code.RestaurantErrorCode; +import org.sopt.hashi.restaurant.domain.RestaurantMapQueryRepository; +import org.sopt.hashi.restaurant.internal.map.MapQueryProperties; +import org.sopt.hashi.shared.error.BusinessException; +import org.springframework.dao.DataAccessResourceFailureException; + +class RestaurantMapServiceTest { + + private final RestaurantMapQueryRepository repository = mock(RestaurantMapQueryRepository.class); + private final RestaurantMapService service = new RestaurantMapService(repository, new MapQueryProperties(), + Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC)); + + @Test + void 빈_ID는_쿼리_없이_반환하고_잘못된_ID는_거절한다() { + assertThat(service.findActiveMapInfos(null)).isEmpty(); + assertThat(service.findActiveMapInfos(List.of())).isEmpty(); + assertThatThrownBy(() -> service.findActiveMapInfos(List.of(0L))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.findActiveMapInfos(Arrays.asList(1L, null))) + .isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(repository); + } + + @Test + void 내부_두번째_batch_실패는_전체_실패이며_첫_batch를_성공처럼_반환하지_않는다() { + given(repository.findActiveMapInfos(any(), any())) + .willReturn(List.of(new RestaurantMapInfo(1L, "fixture", "cafe", "etc", null))) + .willThrow(new DataAccessResourceFailureException("synthetic failure")); + assertThatThrownBy(() -> service.findActiveMapInfos(LongStream.rangeClosed(1, 501).boxed().toList())) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(RestaurantErrorCode.MAP_QUERY_UNAVAILABLE)); + } + + @Test + void 지도_설정이_없어도_Port는_삭제제외와_순서_중복제거_계약으로_조회한다() { + given(repository.findActiveMapInfos(any(), any())).willReturn(List.of( + new RestaurantMapInfo(1L, "first", "restaurant", "sushi", null), + new RestaurantMapInfo(2L, "second", "cafe", "etc", null))); + assertThat(service.findActiveMapInfos(List.of(2L, 3L, 1L, 2L))) + .extracting(RestaurantMapInfo::restaurantId).containsExactly(2L, 1L); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantPortImplTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantPortImplTest.java index 20c4db17..b790e2b7 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantPortImplTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantPortImplTest.java @@ -41,11 +41,15 @@ class RestaurantPortImplTest { @Mock private FileStorage fileStorage; + @Mock + private RestaurantLocationService locationService; + private RestaurantPortImpl restaurantPort; @BeforeEach void setUp() { - restaurantPort = new RestaurantPortImpl(restaurantRepository, restaurantService, fileStorage); + restaurantPort = new RestaurantPortImpl(restaurantRepository, restaurantService, fileStorage, + locationService, org.mockito.Mockito.mock(RestaurantMapService.class)); } @Test diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java index 459aebd2..646d2e2f 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java @@ -35,7 +35,7 @@ import org.springframework.transaction.annotation.Transactional; @DataJpaTest -@Import({RestaurantService.class, RestaurantPortImpl.class, TimeConfig.class}) +@Import({RestaurantService.class, RestaurantPortImpl.class, RestaurantLocationService.class, TimeConfig.class}) @TestPropertySource(properties = { "spring.flyway.enabled=false", "spring.jpa.hibernate.ddl-auto=create-drop", @@ -59,6 +59,12 @@ class RestaurantServiceIntegrationTest { @MockitoBean private MediaPort mediaPort; + @MockitoBean + private RestaurantMapService restaurantMapService; + + @MockitoBean + private PlacesLocationService placesLocationService; + @Test void 식당_목록은_음식_분류로_필터링하지_않고_응답에는_음식_분류를_유지한다() { restaurantRepository.saveAllAndFlush(List.of( diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java index d4c397b5..5f630892 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java @@ -88,8 +88,11 @@ class RestaurantServiceTest { @Mock private MediaPort mediaPort; + @Mock + private RestaurantLocationService locationService; + private RestaurantService createRestaurantService() { - return new RestaurantService(restaurantRepository, fileStorage, mediaPort, JAPAN_CLOCK); + return new RestaurantService(restaurantRepository, fileStorage, mediaPort, JAPAN_CLOCK, locationService); } @Test @@ -144,6 +147,17 @@ private RestaurantService createRestaurantService() { verifyNoInteractions(restaurantRepository); } + @Test + void 어드민_식당_등록에서_위치확인주소가_Unicode공백뿐이면_거부한다() { + RestaurantService restaurantService = createRestaurantService(); + AdminRestaurantCommand command = createAdminCommandWithGeocodingAddress("\u00A0\u2007\u202F"); + + assertThatThrownBy(() -> restaurantService.createByAdmin(command)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); + verifyNoInteractions(restaurantRepository); + } + @Test void 어드민_식당_수정에서_해시태그를_빈_목록으로_교체할_수_없다() { RestaurantService restaurantService = createRestaurantService(); @@ -1528,6 +1542,16 @@ private AdminRestaurantCommand createAdminCommand( return createAdminCommand("restaurant", imageKeys, hashtags); } + private AdminRestaurantCommand createAdminCommandWithGeocodingAddress(String geocodingAddress) { + AdminRestaurantCommand command = createAdminCommand("restaurant"); + return new AdminRestaurantCommand( + command.name(), command.localName(), command.summary(), command.description(), command.address(), + geocodingAddress, command.area(), command.genre(), command.foodCategory(), command.placeType(), + command.priceCurrency(), command.minPrice(), command.maxPrice(), command.imageKeys(), + command.imageAssetIds(), command.images(), command.menus(), command.hashtags(), + command.curationTypes(), command.businessHours()); + } + /** 등록 검증(RESTAURANT-006)을 통과하는 완전한 등록 커맨드 — 음식점 분류만 바꿔 쓴다. */ private AdminRestaurantCommand createAdminCommand(String placeType) { return createAdminCommand(placeType, List.of("restaurants/1/thumbnail.jpg"), List.of("스시"), diff --git a/src/test/java/org/sopt/hashi/restaurant/service/ReviewUpdateConcurrencyMySqlTest.java b/src/test/java/org/sopt/hashi/restaurant/service/ReviewUpdateConcurrencyMySqlTest.java index 32606d0d..812ffc14 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/ReviewUpdateConcurrencyMySqlTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/ReviewUpdateConcurrencyMySqlTest.java @@ -99,6 +99,15 @@ class ReviewUpdateConcurrencyMySqlTest { @MockitoBean private RestaurantService restaurantService; + @MockitoBean + private RestaurantMapService restaurantMapService; + + @MockitoBean + private RestaurantLocationService restaurantLocationService; + + @MockitoBean + private PlacesLocationService placesLocationService; + private ExecutorService executor; @BeforeEach diff --git a/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapControllerTest.java b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapControllerTest.java new file mode 100644 index 00000000..0aa87262 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapControllerTest.java @@ -0,0 +1,185 @@ +package org.sopt.hashi.restaurant.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.auth.internal.onboarding.OnboardingJwtIssuer; +import org.sopt.hashi.auth.internal.security.CookieUtil; +import org.sopt.hashi.auth.internal.security.JwtAccessDeniedHandler; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationEntryPoint; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationFilter; +import org.sopt.hashi.auth.internal.security.OriginValidator; +import org.sopt.hashi.auth.internal.security.SecurityConfig; +import org.sopt.hashi.auth.internal.token.OnboardingTokenStore; +import org.sopt.hashi.auth.internal.token.TokenBlacklist; +import org.sopt.hashi.restaurant.RestaurantMapInfo; +import org.sopt.hashi.restaurant.RestaurantMapInfo.LocationInfo; +import org.sopt.hashi.restaurant.domain.MapQueryBounds; +import org.sopt.hashi.restaurant.domain.MapRegionSummary; +import org.sopt.hashi.restaurant.domain.RestaurantMapQueryRepository; +import org.sopt.hashi.restaurant.internal.map.MapQueryProperties; +import org.sopt.hashi.restaurant.service.RestaurantMapService; +import org.sopt.hashi.shared.exception.GlobalExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.CannotCreateTransactionException; + +@WebMvcTest(controllers = RestaurantMapController.class, + excludeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = OnboardingJwtIssuer.class)) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtProvider.class, + CookieUtil.class, OriginValidator.class, JwtAuthenticationEntryPoint.class, JwtAccessDeniedHandler.class, + RestaurantMapService.class, MapQueryProperties.class, RestaurantMapExceptionHandler.class, + GlobalExceptionHandler.class, RestaurantMapControllerTest.Infrastructure.class}) +@TestPropertySource(properties = { + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "jwt.access-token-ttl=30m", "jwt.refresh-token-ttl=14d", "jwt.onboarding-token-ttl=30m", + "kakao.client-id=test-client-id", "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.cors.allowed-origins=https://app.hashi.test", + "springdoc.api-docs.enabled=false", "springdoc.swagger-ui.enabled=false", + "hashi.restaurant.map.initial-bounds.south=0", "hashi.restaurant.map.initial-bounds.north=1", + "hashi.restaurant.map.initial-bounds.west=0", "hashi.restaurant.map.initial-bounds.east=1", + "hashi.restaurant.map.supported-bounds.south=-1", "hashi.restaurant.map.supported-bounds.north=2", + "hashi.restaurant.map.supported-bounds.west=-1", "hashi.restaurant.map.supported-bounds.east=2" +}) +class RestaurantMapControllerTest { + + private static final Instant UNTIL = Instant.parse("2026-01-01T01:00:00.123456Z"); + + @Autowired private MockMvc mvc; + @MockitoBean private RestaurantMapQueryRepository repository; + @MockitoBean private OnboardingTokenStore onboardingTokenStore; + @MockitoBean private TokenBlacklist tokenBlacklist; + + @Test + void 익명_관광지역_응답은_실제_보안필터와_wrapper_직렬화_no_store를_지킨다() throws Exception { + given(repository.findActiveRegions(any())).willReturn(List.of(new MapRegionSummary(1L, "합성 지역", + new BigDecimal(".5"), new BigDecimal(".5"), MapQueryBounds.parse("0", "1", "0", "1"), 0, 0, 0))); + mvc.perform(get("/api/v1/restaurants/map/regions")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.success").value(true)).andExpect(jsonPath("$.code").value("COMMON-200")) + .andExpect(jsonPath("$.message").value("요청에 성공했습니다")) + .andExpect(jsonPath("$.data.initialBounds.south").value(0)) + .andExpect(jsonPath("$.data.queryLimits.maxLatitudeSpan").value(1)) + .andExpect(jsonPath("$.data.queryLimits.supportedBounds.north").value(2)) + .andExpect(jsonPath("$.data.regions[0].restaurantCount").value(0)) + .andExpect(jsonPath("$.data.regions[0].mapRegionId").value(1)) + .andExpect(jsonPath("$.data.regions[0].clusterPosition.latitude").value(.5)) + .andExpect(jsonPath("$.data.regions[0].outsideBoundsCount").doesNotExist()); + } + + @Test + void 선택식당은_좌표와_UTC_만료시각만_노출하고_엔티티_내부값을_노출하지_않는다() throws Exception { + given(repository.findActiveMapInfos(any(), any())).willReturn(List.of(new RestaurantMapInfo(1L, + "합성 식당", "restaurant", "sushi", new LocationInfo(BigDecimal.ZERO, BigDecimal.ZERO, UNTIL)))); + String body = mvc.perform(get("/api/v1/restaurants/1/map-location")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.data.restaurantId").value(1)) + .andExpect(jsonPath("$.data.location.latitude").value(0)) + .andExpect(jsonPath("$.data.location.validUntil").value(UNTIL.toString())) + .andExpect(jsonPath("$.data.name").doesNotExist()).andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain("requestId", "addressRevision", "obtainedAt", "lockVersion", "source", "lease"); + } + + @Test + void 삭제나_없는_식당은_404이고_현재위치만_없는_식당은_409이다() throws Exception { + given(repository.findActiveMapInfos(any(), any())).willReturn(List.of()); + mvc.perform(get("/api/v1/restaurants/1/map-location")).andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESTAURANT-004")) + .andExpect(jsonPath("$.data").isEmpty()).andExpect(jsonPath("$.errors").doesNotExist()); + given(repository.findActiveMapInfos(any(), any())).willReturn(List.of( + new RestaurantMapInfo(1L, "합성 식당", "cafe", "etc", null))); + mvc.perform(get("/api/v1/restaurants/1/map-location")).andExpect(status().isConflict()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("RESTAURANT-018")); + } + + @Test + @ExtendWith(OutputCaptureExtension.class) + void 지역설정_미완료와_DB_실패는_503_코드로_구분하고_안전한_진단만_남긴다(CapturedOutput output) throws Exception { + given(repository.findActiveRegions(any())).willReturn(List.of()); + mvc.perform(get("/api/v1/restaurants/map/regions")).andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("RESTAURANT-017")); + String secret = "synthetic-sql-search-coordinate-credential"; + given(repository.findActiveRegions(any())).willThrow(new DataAccessResourceFailureException( + secret, new IllegalStateException("synthetic-private-cause"))); + String body = mvc.perform(get("/api/v1/restaurants/map/regions")).andExpect(status().isServiceUnavailable()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("RESTAURANT-015")) + .andExpect(jsonPath("$.timestamp").exists()) + .andExpect(jsonPath("$.path").value("/api/v1/restaurants/map/regions")) + .andExpect(jsonPath("$.errors").doesNotExist()).andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain(secret, "synthetic-private-cause"); + assertThat(output.getOut()).contains("operation=restaurant-map-query exceptionType=DataAccessResourceFailureException"); + assertThat(output.getAll()).doesNotContain(secret, "synthetic-private-cause", "Caused by:"); + } + + @Test + void 잘못된_ID는_공통_400_형식과_보안필터의_no_store를_반환한다() throws Exception { + for (long invalidId : new long[]{0L, -1L}) { + mvc.perform(get("/api/v1/restaurants/{restaurantId}/map-location", invalidId)) + .andExpect(status().isBadRequest()) + .andExpect(header().string("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")) + .andExpect(jsonPath("$.code").value("COMMON-400")) + .andExpect(jsonPath("$.data").isEmpty()) + .andExpect(jsonPath("$.errors").doesNotExist()); + } + mvc.perform(get("/api/v1/restaurants/bad/map-location")).andExpect(status().isBadRequest()) + .andExpect(header().string("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")) + .andExpect(jsonPath("$.code").value("COMMON-400")) + .andExpect(jsonPath("$.data").isEmpty()) + .andExpect(jsonPath("$.errors").doesNotExist()); + } + + @Test + void 기존_보호경로의_익명_접근은_계속_차단한다() throws Exception { + mvc.perform(get("/api/v1/admin/restaurants")).andExpect(status().isUnauthorized()); + mvc.perform(get("/api/v1/users/me")).andExpect(status().isUnauthorized()); + } + + @Test + @ExtendWith(OutputCaptureExtension.class) + void transaction_실패도_지도전용_503과_안전한_진단만_남긴다(CapturedOutput output) throws Exception { + given(repository.findActiveMapInfos(any(), any())) + .willThrow(new CannotCreateTransactionException("synthetic-private-connection", + new IllegalStateException("synthetic-private-cause"))); + String body = mvc.perform(get("/api/v1/restaurants/1/map-location")) + .andExpect(status().isServiceUnavailable()).andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("RESTAURANT-015")) + .andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain("synthetic-private-connection", "synthetic-private-cause"); + assertThat(output.getOut()).contains("operation=restaurant-map-query exceptionType=CannotCreateTransactionException"); + assertThat(output.getAll()).doesNotContain("synthetic-private-connection", "synthetic-private-cause", "Caused by:"); + } + + @TestConfiguration(proxyBeanMethods = false) + static class Infrastructure { + @Bean("japanClock") + Clock clock() { + return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapPageFailureTest.java b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapPageFailureTest.java new file mode 100644 index 00000000..bd623b9e --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapPageFailureTest.java @@ -0,0 +1,134 @@ +package org.sopt.hashi.restaurant.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.auth.internal.onboarding.OnboardingJwtIssuer; +import org.sopt.hashi.auth.internal.security.CookieUtil; +import org.sopt.hashi.auth.internal.security.JwtAccessDeniedHandler; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationEntryPoint; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationFilter; +import org.sopt.hashi.auth.internal.security.OriginValidator; +import org.sopt.hashi.auth.internal.security.SecurityConfig; +import org.sopt.hashi.auth.internal.token.OnboardingTokenStore; +import org.sopt.hashi.auth.internal.token.TokenBlacklist; +import org.sopt.hashi.restaurant.dto.RestaurantMapPageRequest; +import org.sopt.hashi.restaurant.service.RestaurantMapPageService; +import org.sopt.hashi.shared.exception.GlobalExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.CannotCreateTransactionException; + +@WebMvcTest(controllers = RestaurantMapPageController.class, + excludeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = OnboardingJwtIssuer.class)) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtProvider.class, + CookieUtil.class, OriginValidator.class, JwtAuthenticationEntryPoint.class, JwtAccessDeniedHandler.class, + RestaurantMapExceptionHandler.class, GlobalExceptionHandler.class}) +@TestPropertySource(properties = { + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "jwt.access-token-ttl=30m", "jwt.refresh-token-ttl=14d", "jwt.onboarding-token-ttl=30m", + "kakao.client-id=test-client-id", "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.cors.allowed-origins=https://app.hashi.test", + "springdoc.api-docs.enabled=false", "springdoc.swagger-ui.enabled=false" +}) +class RestaurantMapPageFailureTest { + + private static final String FAILURE_DETAIL = "synthetic-private-sql SELECT private_menu " + + "keyword=private-keyword latitude=35.123456 longitude=139.123456"; + private static final String CAUSE_DETAIL = "synthetic-private-cause jdbc:mysql://private-db " + + "password=private-password apiKey=private-api-key"; + private static final String[] PRIVATE_VALUES = {"synthetic-private-sql", "SELECT private_menu", + "private-keyword", "35.123456", "139.123456", "synthetic-private-cause", "private-db", + "private-password", "private-api-key", "private-query-input"}; + + @Autowired private MockMvc mvc; + @MockitoBean private RestaurantMapPageService service; + @MockitoBean private OnboardingTokenStore onboardingTokenStore; + @MockitoBean private TokenBlacklist tokenBlacklist; + + @Test + void 명시적인_빈_검색어는_기존_지도_계약대로_400이다() throws Exception { + for (String keyword : new String[]{"", " ", "\u00a0\u3000"}) { + mvc.perform(get("/api/v1/restaurants/map") + .param("south", "0").param("north", "1") + .param("west", "0").param("east", "1") + .param("keyword", keyword)) + .andExpect(status().isBadRequest()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("COMMON-400")) + .andExpect(jsonPath("$.errors").doesNotExist()); + } + verifyNoInteractions(service); + } + + @Test + void 페이지_DB_접근실패는_503과_안전한_WARN만_남긴다() throws Exception { + assertSafeFailure(new DataAccessResourceFailureException( + FAILURE_DETAIL, new IllegalStateException(CAUSE_DETAIL))); + } + + @Test + void 페이지_transaction_시작실패는_503과_안전한_WARN만_남긴다() throws Exception { + assertSafeFailure(new CannotCreateTransactionException( + FAILURE_DETAIL, new IllegalStateException(CAUSE_DETAIL))); + } + + private void assertSafeFailure(RuntimeException failure) throws Exception { + given(service.getPage(any(RestaurantMapPageRequest.class), any(String.class))).willThrow(failure); + Logger logger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + String body = mvc.perform(get("/api/v1/restaurants/map") + .param("south", "0").param("north", "1") + .param("west", "0").param("east", "1") + .param("keyword", "private-query-input")) + .andExpect(status().isServiceUnavailable()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value("RESTAURANT-015")) + .andExpect(jsonPath("$.message").value("지도 정보를 조회할 수 없습니다.")) + .andExpect(jsonPath("$.timestamp").exists()) + .andExpect(jsonPath("$.path").value("/api/v1/restaurants/map")) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).doesNotContain(PRIVATE_VALUES); + assertThat(appender.list).filteredOn(event -> Level.WARN.equals(event.getLevel())) + .singleElement().satisfies(event -> { + assertThat(event.getFormattedMessage()).isEqualTo( + "Map query failed. operation=restaurant-map-query exceptionType=" + + failure.getClass().getSimpleName()); + assertThat(event.getArgumentArray()).containsExactly(failure.getClass().getSimpleName()); + }); + assertThat(appender.list).allSatisfy(event -> { + assertThat(event.getFormattedMessage()).doesNotContain(PRIVATE_VALUES); + assertThat(event.getThrowableProxy()).isNull(); + }); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapSessionGateTest.java b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapSessionGateTest.java new file mode 100644 index 00000000..309ae2c9 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantMapSessionGateTest.java @@ -0,0 +1,138 @@ +package org.sopt.hashi.restaurant.web; + +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Base64; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sopt.hashi.auth.internal.jwt.JwtProvider; +import org.sopt.hashi.auth.internal.onboarding.OnboardingJwtIssuer; +import org.sopt.hashi.auth.internal.security.CookieUtil; +import org.sopt.hashi.auth.internal.security.JwtAccessDeniedHandler; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationEntryPoint; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationFilter; +import org.sopt.hashi.auth.internal.security.OriginValidator; +import org.sopt.hashi.auth.internal.security.SecurityConfig; +import org.sopt.hashi.auth.internal.token.OnboardingTokenStore; +import org.sopt.hashi.auth.internal.token.TokenBlacklist; +import org.sopt.hashi.restaurant.domain.RestaurantMapSort; +import org.sopt.hashi.restaurant.internal.map.MapCursorCodec; +import org.sopt.hashi.restaurant.internal.map.MapCapacityMetrics; +import org.sopt.hashi.restaurant.internal.map.MapSessionId; +import org.sopt.hashi.restaurant.internal.map.MapSessionProperties; +import org.sopt.hashi.restaurant.internal.map.MapSessionLimits; +import org.sopt.hashi.restaurant.internal.map.RedisMapSessionStore; +import org.sopt.hashi.restaurant.service.RestaurantMapPageReader; +import org.sopt.hashi.restaurant.service.RestaurantMapPageService; +import org.sopt.hashi.restaurant.service.RestaurantMapService; +import org.sopt.hashi.shared.exception.GlobalExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +/** 실제 Controller, Service, Codec, 설정 빈 및 SecurityFilterChain을 통과한다. */ +@WebMvcTest(controllers = RestaurantMapPageController.class, + excludeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = OnboardingJwtIssuer.class)) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtProvider.class, + CookieUtil.class, OriginValidator.class, JwtAuthenticationEntryPoint.class, JwtAccessDeniedHandler.class, + RestaurantMapExceptionHandler.class, GlobalExceptionHandler.class, + RestaurantMapPageService.class, MapCursorCodec.class, RestaurantMapSessionGateTest.Configuration.class}) +@EnableConfigurationProperties({MapSessionProperties.class, MapSessionLimits.class}) +@TestPropertySource(properties = { + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "jwt.access-token-ttl=30m", "jwt.refresh-token-ttl=14d", "jwt.onboarding-token-ttl=30m", + "kakao.client-id=test-client-id", "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.cors.allowed-origins=https://app.hashi.test", + "springdoc.api-docs.enabled=false", "springdoc.swagger-ui.enabled=false" +}) +class RestaurantMapSessionGateTest { + private static final String PATH = "/api/v1/restaurants/map"; + @Autowired MockMvc mvc; + @Autowired MapSessionProperties properties; + @Autowired MapCursorCodec cursors; + @MockitoBean RestaurantMapService mapService; + @MockitoBean RestaurantMapPageReader reader; + @MockitoBean RedisMapSessionStore store; + @MockitoBean MapCapacityMetrics mapCapacityMetrics; + @MockitoBean OnboardingTokenStore onboardingTokenStore; + @MockitoBean TokenBlacklist tokenBlacklist; + + @BeforeEach + void prepareValidKeyWithoutActivation() { + properties.setEnabled(false); + properties.setSigningKey(Base64.getEncoder().encodeToString( + "synthetic-map-test-key-32-bytes-only".getBytes(StandardCharsets.UTF_8))); + } + + @ParameterizedTest + @EnumSource(QueryMode.class) + void 비활성화는_유효한_키와_정상요청도_DB와_Redis_호출전에_차단한다(QueryMode mode) throws Exception { + assertUnavailable(request(mode)); + verifyNoInteractions(mapService, reader, store); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"synthetic-private-key!", "c2hvcnQ="}) + void 활성화해도_키가_없거나_무효이면_모든_조회모드가_503이다(String key) throws Exception { + // 키 변경 전에 유효한 cursor를 만들어 설정 실패가 cursor 오류에 가려지지 않게 한다. + var requests = new MockHttpServletRequestBuilder[]{request(QueryMode.NEW), request(QueryMode.NEXT), request(QueryMode.RESORT)}; + properties.setEnabled(true); + properties.setSigningKey(key); + for (var request : requests) assertUnavailable(request); + verifyNoInteractions(mapService, reader, store); + } + + @Test + void 비활성화해도_잘못된_입력의_기존_400_경계를_유지한다() throws Exception { + mvc.perform(get(PATH).param("cursor", "")) + .andExpect(status().isBadRequest()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("COMMON-400")); + verifyNoInteractions(mapService, reader, store); + } + + private MockHttpServletRequestBuilder request(QueryMode mode) { + var id = new MapSessionId(new UUID(0, 1)); + return switch (mode) { + case NEW -> get(PATH).param("south", "0").param("north", "1").param("west", "0").param("east", "1"); + case NEXT -> get(PATH).param("cursor", cursors.encode(id, RestaurantMapSort.RECOMMEND, 10)); + case RESORT -> get(PATH).param("querySessionId", id.value()).param("sort", "rating"); + }; + } + + private void assertUnavailable(MockHttpServletRequestBuilder request) throws Exception { + mvc.perform(request).andExpect(status().isServiceUnavailable()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.code").value("RESTAURANT-014")) + .andExpect(jsonPath("$.errors").doesNotExist()); + } + + enum QueryMode { NEW, NEXT, RESORT } + + @TestConfiguration(proxyBeanMethods = false) + static class Configuration { + @Bean("japanClock") + Clock japanClock() { return Clock.systemUTC(); } + } +} diff --git a/src/test/java/org/sopt/hashi/user/collection/domain/CollectionVersionMigrationTest.java b/src/test/java/org/sopt/hashi/user/collection/domain/CollectionVersionMigrationTest.java new file mode 100644 index 00000000..aacbf167 --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/collection/domain/CollectionVersionMigrationTest.java @@ -0,0 +1,50 @@ +package org.sopt.hashi.user.collection.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.DriverManager; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** V38~V40 선행 결합 후 실행한다. missing V40을 target 설정만으로 통과 처리하지 않는다. */ +@Testcontainers(disabledWithoutDocker = true) +class CollectionVersionMigrationTest { + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi").withUsername("hashi").withPassword("hashi"); + + @Test + void V40_기존_컬렉션과_관계를_보존하며_V41_변경번호를_초기화한다() throws Exception { + Flyway throughV40 = Flyway.configure().dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration").target("40").cleanDisabled(false).load(); + throughV40.clean(); + throughV40.migrate(); + assertThat(throughV40.info().current().getVersion().getVersion()).isEqualTo("40"); + try (var connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); + var statement = connection.createStatement()) { + statement.executeUpdate("insert into restaurant_collection(id,user_id,name,color,description,visibility) " + + "values (1,1,' 여행 ','RED',' ','PUBLIC')"); + statement.executeUpdate("insert into saved_restaurant(collection_id,restaurant_id) values (1,101),(1,102)"); + } + Flyway latest = Flyway.configure().dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration").validateOnMigrate(true).load(); + latest.migrate(); + assertThat(latest.validateWithResult().validationSuccessful).isTrue(); + assertThat(latest.info().current().getVersion().getVersion()).isEqualTo("41"); + try (var connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); + var statement = connection.createStatement(); + var result = statement.executeQuery("select name,description,collection_version," + + "(select count(*) from saved_restaurant where collection_id=1) as savedCount " + + "from restaurant_collection where id=1")) { + assertThat(result.next()).isTrue(); + assertThat(result.getString("name")).isEqualTo(" 여행 "); + assertThat(result.getString("description")).isEqualTo(" "); + assertThat(result.getLong("collection_version")).isZero(); + assertThat(result.wasNull()).isFalse(); + assertThat(result.getLong("savedCount")).isEqualTo(2); + } + } +} diff --git a/src/test/java/org/sopt/hashi/user/collection/service/CollectionMapQueryServiceTest.java b/src/test/java/org/sopt/hashi/user/collection/service/CollectionMapQueryServiceTest.java new file mode 100644 index 00000000..2b79b0f6 --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/collection/service/CollectionMapQueryServiceTest.java @@ -0,0 +1,141 @@ +package org.sopt.hashi.user.collection.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.error.CommonErrorCode; +import org.sopt.hashi.shared.error.ErrorCode; +import org.sopt.hashi.user.code.UserErrorCode; +import org.sopt.hashi.user.collection.service.CollectionMapSnapshotStore.Snapshot; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.transaction.CannotCreateTransactionException; + +class CollectionMapQueryServiceTest { + private static final Long COLLECTION_ID = 987654321L; + private static final String PRIVATE_DETAIL = "collectionId=987654321 SELECT private_sql key=synthetic-secret"; + private static final String PRIVATE_CAUSE = "private-address 35.654321 139.123456"; + private static final String REQUEST_ID = "existing-request-context"; + private final CollectionMapSnapshotStore snapshots = mock(CollectionMapSnapshotStore.class); + private final RestaurantPort restaurants = mock(RestaurantPort.class); + private final CurrentUserProvider currentUser = mock(CurrentUserProvider.class); + private final CollectionMapQueryService service = new CollectionMapQueryService(snapshots, restaurants, + currentUser, Clock.fixed(Instant.parse("2026-10-01T00:00:00Z"), ZoneOffset.UTC)); + private final Logger logger = (Logger) LoggerFactory.getLogger(CollectionMapQueryService.class); + private final ListAppender logs = new ListAppender<>(); + private Level previousLevel; + private Map previousMdc; + + @BeforeEach + void 진단_로그와_기존_요청_문맥을_준비한다() { + previousLevel = logger.getLevel(); + previousMdc = MDC.getCopyOfContextMap(); + MDC.put("requestId", REQUEST_ID); + logger.setLevel(Level.WARN); + logs.start(); + logger.addAppender(logs); + } + + @AfterEach + void 로그와_요청_문맥을_복원한다() { + logger.detachAppender(logs); + logs.stop(); + logger.setLevel(previousLevel); + if (previousMdc == null) { + MDC.clear(); + } else { + MDC.setContextMap(previousMdc); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void snapshot_읽기와_최종검사의_DB_실패는_원인을_보존하고_안전한_WARN_한건을_남긴다(boolean validate) { + assertSnapshotFailure(validate, new DataAccessResourceFailureException( + PRIVATE_DETAIL, new IllegalStateException(PRIVATE_CAUSE))); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void snapshot_읽기와_최종검사의_transaction_실패는_원인을_보존하고_안전한_WARN_한건을_남긴다(boolean validate) { + assertSnapshotFailure(validate, new CannotCreateTransactionException( + PRIVATE_DETAIL, new IllegalStateException(PRIVATE_CAUSE))); + } + + @ParameterizedTest + @MethodSource("expectedSnapshotErrors") + void snapshot의_예상된_접근권한_버전_상한_예외는_변경하거나_장애로_기록하지_않는다( + boolean validate, ErrorCode code) { + BusinessException failure = new BusinessException(code); + failSnapshot(validate, failure); + + assertThatThrownBy(() -> service.getMarkers(COLLECTION_ID)).isSameAs(failure); + assertThat(logs.list).isEmpty(); + verifyNoInteractions(restaurants); + } + + private void assertSnapshotFailure(boolean validate, RuntimeException failure) { + failSnapshot(validate, failure); + + assertThatThrownBy(() -> service.getMarkers(COLLECTION_ID)) + .isInstanceOfSatisfying(BusinessException.class, error -> { + assertThat(error.getErrorCode()).isEqualTo(UserErrorCode.COLLECTION_MAP_UNAVAILABLE); + assertThat(error.getCause()).isSameAs(failure); + }); + assertThat(logs.list).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).isEqualTo( + "Collection map failed. operation=collection-map-snapshot exceptionType=" + + failure.getClass().getSimpleName()); + assertThat(event.getArgumentArray()).containsExactly(failure.getClass().getSimpleName()); + assertThat(event.getThrowableProxy()).isNull(); + assertThat(event.getFormattedMessage()).doesNotContain(PRIVATE_DETAIL, PRIVATE_CAUSE, + COLLECTION_ID.toString(), "private_sql", "synthetic-secret", "35.654321", "139.123456"); + assertThat(event.getMDCPropertyMap()).containsEntry("requestId", REQUEST_ID); + }); + assertThat(MDC.get("requestId")).isEqualTo(REQUEST_ID); + verifyNoInteractions(restaurants); + } + + private void failSnapshot(boolean validate, RuntimeException failure) { + if (validate) { + Snapshot snapshot = new Snapshot(COLLECTION_ID, 7L, List.of()); + given(snapshots.read(COLLECTION_ID, null)).willReturn(snapshot); + doThrow(failure).when(snapshots).validate(snapshot, null); + } else { + given(snapshots.read(COLLECTION_ID, null)).willThrow(failure); + } + } + + private static Stream expectedSnapshotErrors() { + return Stream.of( + Arguments.of(false, UserErrorCode.COLLECTION_NOT_FOUND), + Arguments.of(false, UserErrorCode.COLLECTION_MAP_UNAVAILABLE), + Arguments.of(true, UserErrorCode.COLLECTION_NOT_FOUND), + Arguments.of(true, CommonErrorCode.CONFLICT)); + } +} diff --git a/src/test/java/org/sopt/hashi/user/collection/service/CollectionMySqlIntegrationTest.java b/src/test/java/org/sopt/hashi/user/collection/service/CollectionMySqlIntegrationTest.java new file mode 100644 index 00000000..31b17ca6 --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/collection/service/CollectionMySqlIntegrationTest.java @@ -0,0 +1,147 @@ +package org.sopt.hashi.user.collection.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import jakarta.persistence.EntityManagerFactory; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.user.code.UserErrorCode; +import org.sopt.hashi.user.collection.domain.CollectionColor; +import org.sopt.hashi.user.collection.domain.CollectionVisibility; +import org.sopt.hashi.user.collection.domain.RestaurantCollection; +import org.sopt.hashi.user.collection.domain.RestaurantCollectionRepository; +import org.sopt.hashi.user.collection.domain.SavedRestaurantRepository; +import org.sopt.hashi.user.collection.dto.MoveSavedRestaurantsRequest; +import org.sopt.hashi.user.collection.dto.SaveRestaurantRequest; +import org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest; +import org.springframework.orm.jpa.EntityManagerHolder; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** H2 회귀 전체를 실제 MySQL/Flyway에서도 실행한다. Docker가 없으면 성공으로 간주하지 않고 skip을 보고한다. */ +@Testcontainers(disabledWithoutDocker = true) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@TestPropertySource(properties = {"spring.flyway.enabled=true", "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.generate_statistics=true"}) +class CollectionMySqlIntegrationTest extends RestaurantCollectionIntegrationTest { + @Container + @ServiceConnection + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi").withUsername("hashi").withPassword("hashi"); + + @Autowired RestaurantCollectionService service; + @Autowired RestaurantCollectionRepository collections; + @Autowired SavedRestaurantRepository saved; + @Autowired RestaurantPort restaurants; + @Autowired CollectionMapSnapshotStore snapshots; + @Autowired EntityManagerFactory entityManagerFactory; + + @Test + void 반대_방향_동시_이동은_ID순_부모_잠금으로_둘다_완료한다() throws Exception { + RestaurantCollection first = RestaurantCollection.create(1L, "첫째", CollectionColor.RED, null, CollectionVisibility.PUBLIC); + RestaurantCollection second = RestaurantCollection.create(1L, "둘째", CollectionColor.RED, null, CollectionVisibility.PUBLIC); + first.save(101L); + second.save(102L); + Long firstId = collections.saveAndFlush(first).getId(); + Long secondId = collections.saveAndFlush(second).getId(); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + var forward = executor.submit(() -> { + start.await(); + return service.moveRestaurants(firstId, new MoveSavedRestaurantsRequest(secondId, List.of(101L))); + }); + var backward = executor.submit(() -> { + start.await(); + return service.moveRestaurants(secondId, new MoveSavedRestaurantsRequest(firstId, List.of(102L))); + }); + start.countDown(); + forward.get(20, TimeUnit.SECONDS); + backward.get(20, TimeUnit.SECONDS); + } + assertThat(saved.findAllByCollection_IdOrderByIdDesc(firstId)) + .extracting(item -> item.getRestaurantId()).containsExactly(102L); + assertThat(saved.findAllByCollection_IdOrderByIdDesc(secondId)) + .extracting(item -> item.getRestaurantId()).containsExactly(101L); + } + + @Test + void OSIV처럼_EntityManager가_재사용돼도_최종검사는_현재_비공개_상태를_읽는다() throws Exception { + Long id = collections.saveAndFlush(RestaurantCollection.create( + 1L, "OSIV", CollectionColor.RED, null, CollectionVisibility.PUBLIC)).getId(); + var entityManager = entityManagerFactory.createEntityManager(); + TransactionSynchronizationManager.bindResource(entityManagerFactory, new EntityManagerHolder(entityManager)); + try (var executor = Executors.newSingleThreadExecutor()) { + var snapshot = snapshots.read(id, null); + executor.submit(() -> service.update(id, + new UpdateRestaurantCollectionRequest(null, null, null, "private"))).get(20, TimeUnit.SECONDS); + // 기존 managed Entity는 stale임을 먼저 증명한다. native scalar current read만 최신 상태를 본다. + assertThat(entityManager.find(RestaurantCollection.class, id).isPublic()).isTrue(); + assertThatThrownBy(() -> snapshots.validate(snapshot, null)) + .isInstanceOfSatisfying(BusinessException.class, + error -> assertThat(error.getErrorCode()).isEqualTo(UserErrorCode.COLLECTION_NOT_FOUND)); + } finally { + TransactionSynchronizationManager.unbindResource(entityManagerFactory); + entityManager.close(); + } + } + + @Test + void 마지막_한자리_동시_저장은_상한을_넘기지_않는다() throws Exception { + RestaurantCollection collection = RestaurantCollection.create(1L, "상한", CollectionColor.RED, null, CollectionVisibility.PUBLIC); + for (long id = 1; id < 1000; id++) { + collection.save(id); + } + Long collectionId = collections.saveAndFlush(collection).getId(); + given(restaurants.existsActiveById(1000L)).willReturn(true); + given(restaurants.existsActiveById(1001L)).willReturn(true); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> saveResult(start, collectionId, 1000L)); + var second = executor.submit(() -> saveResult(start, collectionId, 1001L)); + start.countDown(); + assertThat(List.of(first.get(30, TimeUnit.SECONDS), second.get(30, TimeUnit.SECONDS))) + .containsExactlyInAnyOrder("saved", "USER-011"); + } + assertThat(saved.findAllByCollection_IdOrderByIdDesc(collectionId)).hasSize(1000); + } + + private String saveResult(CountDownLatch start, Long collectionId, Long restaurantId) throws InterruptedException { + start.await(); + try { + service.saveRestaurant(collectionId, new SaveRestaurantRequest(restaurantId)); + return "saved"; + } catch (BusinessException error) { + return error.getErrorCode().getCode(); + } + } + + @Test + void 전체_ID_snapshot과_최종검사는_저장수와_무관하게_각각_두개와_한개_SQL이다() { + RestaurantCollection collection = RestaurantCollection.create(1L, "쿼리", CollectionColor.RED, null, CollectionVisibility.PUBLIC); + for (long id = 1; id <= 1000; id++) { + collection.save(id); + } + Long collectionId = collections.saveAndFlush(collection).getId(); + var statistics = entityManagerFactory.unwrap(org.hibernate.SessionFactory.class).getStatistics(); + statistics.clear(); + var snapshot = snapshots.read(collectionId, null); + assertThat(snapshot.restaurantIds()).hasSize(1000); + assertThat(statistics.getPrepareStatementCount()).isEqualTo(2); + statistics.clear(); + snapshots.validate(snapshot, null); + assertThat(statistics.getPrepareStatementCount()).isEqualTo(1); + } +} diff --git a/src/test/java/org/sopt/hashi/user/collection/service/RestaurantCollectionIntegrationTest.java b/src/test/java/org/sopt/hashi/user/collection/service/RestaurantCollectionIntegrationTest.java index 3091b1ba..4b00e1fb 100644 --- a/src/test/java/org/sopt/hashi/user/collection/service/RestaurantCollectionIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/user/collection/service/RestaurantCollectionIntegrationTest.java @@ -5,7 +5,13 @@ import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.BDDMockito.given; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDate; import java.util.Collection; import java.util.List; @@ -13,10 +19,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import org.sopt.hashi.auth.CurrentUserProvider; import org.sopt.hashi.config.JpaAuditingConfig; +import org.sopt.hashi.config.TimeConfig; import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.restaurant.RestaurantCardInfo; +import org.sopt.hashi.restaurant.RestaurantMapInfo; import org.sopt.hashi.restaurant.RestaurantPort; import org.sopt.hashi.shared.error.BusinessException; import org.sopt.hashi.shared.error.ErrorCode; @@ -49,7 +58,9 @@ */ @DataJpaTest @Import({RestaurantCollectionService.class, SavedRestaurantQueryService.class, - RestaurantCollectionFinder.class, SavedRestaurantEnricher.class, JpaAuditingConfig.class}) + RestaurantCollectionFinder.class, SavedRestaurantEnricher.class, JpaAuditingConfig.class, + RestaurantSaveSummaryService.class, CollectionMapSnapshotStore.class, CollectionMapQueryService.class, + TimeConfig.class}) @TestPropertySource(properties = { "spring.flyway.enabled=false", "spring.jpa.hibernate.ddl-auto=create-drop", @@ -67,6 +78,18 @@ class RestaurantCollectionIntegrationTest { @Autowired private SavedRestaurantQueryService queryService; + @Autowired + private RestaurantSaveSummaryService summaryService; + + @Autowired + private CollectionMapSnapshotStore snapshotStore; + + @Autowired + private CollectionMapQueryService mapService; + + @MockitoBean(name = "japanClock") + private Clock mapClock; + @Autowired private RestaurantCollectionRepository restaurantCollectionRepository; @@ -88,6 +111,7 @@ class RestaurantCollectionIntegrationTest { @BeforeEach void setUp() { loginAs(OWNER_ID); + given(mapClock.instant()).willReturn(Instant.parse("2026-10-01T00:00:00Z")); given(restaurantPort.findActiveCards(anyCollection())).willAnswer(invocation -> { Collection restaurantIds = invocation.getArgument(0); return restaurantIds.stream().sorted().map(this::card).toList(); @@ -205,6 +229,277 @@ sourceId, new MoveSavedRestaurantsRequest(targetId, List.of(101L)))) assertThat(secondPage.nextCursor()).isNull(); } + @Test + void 같은_사용자의_공개와_비공개_저장은_하나로_세고_마지막_제거만_저장수를_줄인다() { + Long first = saveCollection(OWNER_ID, "공개", CollectionVisibility.PUBLIC, 101L); + Long second = saveCollection(OWNER_ID, "비공개", CollectionVisibility.PRIVATE, 101L); + saveCollection(OTHER_USER_ID, "다른 회원", CollectionVisibility.PRIVATE, 101L); + + assertThat(summaryService.getSaveCounts(List.of(101L)).restaurants().getFirst().saveCount()).isEqualTo(2); + assertThat(summaryService.getMySaves(List.of(101L)).restaurants().getFirst().saved()).isTrue(); + collectionService.removeRestaurants(first, List.of(101L)); + assertThat(summaryService.getSaveCounts(List.of(101L)).restaurants().getFirst().saveCount()).isEqualTo(2); + collectionService.delete(second); + assertThat(summaryService.getSaveCounts(List.of(101L)).restaurants().getFirst().saveCount()).isEqualTo(1); + assertThat(summaryService.getMySaves(List.of(101L)).restaurants().getFirst().saved()).isFalse(); + } + + @Test + void 삭제_미존재_식당은_요약에서_제외하고_요청_순서를_유지한다() { + saveCollection(OWNER_ID, "저장", CollectionVisibility.PUBLIC, 101L, 102L); + given(restaurantPort.findActiveCards(anyCollection())).willReturn(List.of(card(101L), card(103L))); + + assertThat(summaryService.getSaveCounts(List.of(103L, 102L, 101L)).restaurants()) + .extracting(item -> item.restaurantId()).containsExactly(103L, 101L); + assertThat(summaryService.getMySaves(List.of(103L, 102L, 101L)).restaurants()) + .extracting(item -> item.saved()).containsExactly(false, true); + } + + @Test + void 요약은_중복_ID와_빈목록_음수_상한초과를_거부한다() { + for (List ids : List.of(List.of(), List.of(1L, 1L), List.of(0L), List.of(-1L), + java.util.stream.LongStream.rangeClosed(1, 101).boxed().toList())) { + assertBusinessError(() -> summaryService.getSaveCounts(ids), + org.sopt.hashi.shared.error.CommonErrorCode.INVALID_INPUT); + } + } + + @Test + void 식당_Port_실패를_저장수_0이나_미저장으로_숨기지_않는다() { + given(restaurantPort.findActiveCards(anyCollection())).willThrow(new IllegalStateException("unavailable")); + assertThatThrownBy(() -> summaryService.getSaveCounts(List.of(1L))).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> summaryService.getMySaves(List.of(1L))).isInstanceOf(IllegalStateException.class); + } + + @Test + void 생성과_수정은_이름과_설명의_앞뒤_공백만_제거한다() { + User active = saveUser("공백검증회원", "01011110003", "whitespace@hashi.test"); + loginAs(active.getId()); + var created = collectionService.create(new org.sopt.hashi.user.collection.dto.CreateRestaurantCollectionRequest( + "\u00a0 도쿄 맛집\u3000", "red", "\t 여행 첫날 \u0085", "public")); + assertThat(created.name()).isEqualTo("도쿄 맛집"); + assertThat(created.description()).isEqualTo("여행 첫날"); + var updated = collectionService.update(created.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest( + "\u3000 교토 맛집\u00a0", null, "\n 첫째 날\u3000", null)); + assertThat(updated.name()).isEqualTo("교토 맛집"); + assertThat(updated.description()).isEqualTo("첫째 날"); + var stored = restaurantCollectionRepository.findById(created.collectionId()).orElseThrow(); + assertThat(stored.getName()).isEqualTo("교토 맛집"); + assertThat(stored.getDescription()).isEqualTo("첫째 날"); + assertThat(collectionService.update(created.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(null, null, null, null)) + .description()).isEqualTo("첫째 날"); + assertThat(collectionService.update(created.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(null, null, " \u00a0\u3000 ", null)) + .description()).isNull(); + assertThat(collectionService.update(created.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(null, null, "", null)) + .description()).isNull(); + assertThat(collectionService.create(new CreateRestaurantCollectionRequest( + "설명 없는 컬렉션", "red", " \u00a0\u3000 ", "public")).description()).isNull(); + } + + @Test + void 공백만인_이름은_거부하고_앞뒤_공백이_다른_중복이름도_409이다() { + User active = saveUser("중복검증회원", "01011110004", "duplicate@hashi.test"); + loginAs(active.getId()); + var second = collectionService.create(new CreateRestaurantCollectionRequest( + "교토", "red", null, "public")); + for (String name : List.of("", " ", "\u00a0\u3000", "\n\t", "\u0085")) { + assertBusinessError(() -> collectionService.create( + new org.sopt.hashi.user.collection.dto.CreateRestaurantCollectionRequest(name, "red", null, "public")), + org.sopt.hashi.shared.error.CommonErrorCode.INVALID_INPUT); + assertBusinessError(() -> collectionService.update(second.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(name, null, null, null)), + org.sopt.hashi.shared.error.CommonErrorCode.INVALID_INPUT); + } + var first = collectionService.create(new org.sopt.hashi.user.collection.dto.CreateRestaurantCollectionRequest( + " 도쿄 ", "red", null, "public")); + assertBusinessError(() -> collectionService.create( + new org.sopt.hashi.user.collection.dto.CreateRestaurantCollectionRequest("도쿄\u00a0", "red", null, "public")), + UserErrorCode.DUPLICATE_COLLECTION_NAME); + assertBusinessError(() -> collectionService.update(second.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest("\u3000도쿄", null, null, null)), + UserErrorCode.DUPLICATE_COLLECTION_NAME); + assertThat(collectionService.update(first.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(" 도쿄 ", null, null, null)) + .name()).isEqualTo("도쿄"); + assertThat(restaurantCollectionRepository.findById(second.collectionId()).orElseThrow().getName()) + .isEqualTo("교토"); + } + + @Test + void 기존_공백_포함_이름은_자동_수정하지_않고_이름_수정시_중복이면_409이다() { + Long legacyId = saveCollection(OWNER_ID, " 도쿄 ", CollectionVisibility.PUBLIC); + saveCollection(OWNER_ID, "도쿄", CollectionVisibility.PUBLIC); + + var unchanged = collectionService.update(legacyId, + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(null, "blue", null, null)); + assertThat(unchanged.name()).isEqualTo(" 도쿄 "); + assertBusinessError(() -> collectionService.update(legacyId, + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(" 도쿄 ", null, null, null)), + UserErrorCode.DUPLICATE_COLLECTION_NAME); + assertThat(restaurantCollectionRepository.findById(legacyId).orElseThrow().getName()).isEqualTo(" 도쿄 "); + } + + @Test + void 길이_제한은_공백_제거_전_입력에_적용하고_초과하면_기존값을_유지한다() { + User active = saveUser("길이검증회원", "01011110005", "length@hashi.test"); + loginAs(active.getId()); + String maxName = "가".repeat(20); + String maxDescription = "나".repeat(100); + var created = collectionService.create(new CreateRestaurantCollectionRequest( + maxName, "red", maxDescription, "public")); + assertThat(created.name()).isEqualTo(maxName); + assertThat(created.description()).isEqualTo(maxDescription); + assertBusinessError(() -> collectionService.create(new CreateRestaurantCollectionRequest( + " " + maxName, "red", null, "public")), + org.sopt.hashi.shared.error.CommonErrorCode.INVALID_INPUT); + assertBusinessError(() -> collectionService.create(new CreateRestaurantCollectionRequest( + "긴 설명", "red", maxDescription + " ", "public")), + org.sopt.hashi.shared.error.CommonErrorCode.INVALID_INPUT); + assertBusinessError(() -> collectionService.update(created.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest( + maxName + " ", null, null, null)), + org.sopt.hashi.shared.error.CommonErrorCode.INVALID_INPUT); + assertBusinessError(() -> collectionService.update(created.collectionId(), + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest( + "새 이름", null, " " + maxDescription, null)), + org.sopt.hashi.shared.error.CommonErrorCode.INVALID_INPUT); + var stored = restaurantCollectionRepository.findById(created.collectionId()).orElseThrow(); + assertThat(stored.getName()).isEqualTo(maxName); + assertThat(stored.getDescription()).isEqualTo(maxDescription); + } + + @Test + void 지도_snapshot_이후_비공개_변경과_삭제는_404이고_멤버십_변경은_409이다() { + Long id = saveCollection(OWNER_ID, "지도", CollectionVisibility.PUBLIC, 102L, 101L); + var snapshot = snapshotStore.read(id, null); + assertThat(snapshot.restaurantIds()).containsExactly(101L, 102L); + snapshotStore.validate(snapshot, null); + collectionService.removeRestaurants(id, List.of(101L)); + assertBusinessError(() -> snapshotStore.validate(snapshot, null), + org.sopt.hashi.shared.error.CommonErrorCode.CONFLICT); + var current = snapshotStore.read(id, null); + collectionService.update(id, + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(null, null, null, "private")); + assertBusinessError(() -> snapshotStore.validate(current, null), UserErrorCode.COLLECTION_NOT_FOUND); + var owned = snapshotStore.read(id, OWNER_ID); + collectionService.delete(id); + assertBusinessError(() -> snapshotStore.validate(owned, OWNER_ID), UserErrorCode.COLLECTION_NOT_FOUND); + } + + @Test + void 저장_23개_중_좌표없는_2개는_관계를_보존하고_핀만_빠진다() { + Long id = saveCollection(OWNER_ID, "전체 지도", CollectionVisibility.PUBLIC, + java.util.stream.LongStream.rangeClosed(1, 23).boxed().toArray(Long[]::new)); + given(restaurantPort.findActiveMapInfos(anyCollection())).willReturn( + java.util.stream.LongStream.rangeClosed(1, 23) + .mapToObj(restaurantId -> mapInfo(restaurantId, restaurantId <= 21)).toList()); + var response = mapService.getMarkers(id); + assertThat(response.visibleRestaurantCount()).isEqualTo(23); + assertThat(response.content().size()).isEqualTo(21); + assertThat(response.locationUnavailableCount()).isEqualTo(2); + assertThat(response.content()).extracting(marker -> marker.restaurantId()) + .containsExactlyElementsOf(java.util.stream.LongStream.rangeClosed(1, 21).boxed().toList()); + assertThat(savedRestaurantIds(id)).hasSize(23); + } + + @Test + void 전체_1000개를_한_Port_호출로_조회하고_상한초과는_503이다() { + Long id = saveCollection(OWNER_ID, "상한 지도", CollectionVisibility.PUBLIC, + java.util.stream.LongStream.rangeClosed(1, 1000).boxed().toArray(Long[]::new)); + given(restaurantPort.findActiveMapInfos(anyCollection())).willAnswer(invocation -> { + Collection ids = invocation.getArgument(0); + return ids.stream().map(restaurantId -> mapInfo(restaurantId, true)).toList(); + }); + assertThat(mapService.getMarkers(id).content().size()).isEqualTo(1000); + org.mockito.Mockito.verify(restaurantPort).findActiveMapInfos( + java.util.stream.LongStream.rangeClosed(1, 1000).boxed().toList()); + Long overflow = saveCollection(OWNER_ID, "과량 지도", CollectionVisibility.PUBLIC, + java.util.stream.LongStream.rangeClosed(1, 1001).boxed().toArray(Long[]::new)); + assertBusinessError(() -> mapService.getMarkers(overflow), UserErrorCode.COLLECTION_MAP_UNAVAILABLE); + } + + @Test + void 조회중_공개범위_변경은_404이고_저장관계_이동은_409이다() { + Long id = saveCollection(OWNER_ID, "공유 지도", CollectionVisibility.PUBLIC, 101L); + org.mockito.Mockito.doAnswer(invocation -> { + collectionService.update(id, + new org.sopt.hashi.user.collection.dto.UpdateRestaurantCollectionRequest(null, null, null, "private")); + given(currentUserProvider.isAuthenticatedUser()).willReturn(false); + return List.of(mapInfo(101L, true)); + }).when(restaurantPort).findActiveMapInfos(anyCollection()); + // 요청 시작 시 비회원이므로 owner ID가 final 검사에 유입되지 않는다. + given(currentUserProvider.isAuthenticatedUser()).willReturn(false); + assertBusinessError(() -> mapService.getMarkers(id), UserErrorCode.COLLECTION_NOT_FOUND); + loginAs(OWNER_ID); + Long target = saveCollection(OWNER_ID, "이동 대상", CollectionVisibility.PUBLIC); + org.mockito.Mockito.doAnswer(invocation -> { + collectionService.moveRestaurants(id, new MoveSavedRestaurantsRequest(target, List.of(101L))); + return List.of(mapInfo(101L, true)); + }).when(restaurantPort).findActiveMapInfos(anyCollection()); + assertBusinessError(() -> mapService.getMarkers(id), org.sopt.hashi.shared.error.CommonErrorCode.CONFLICT); + } + + @Test + void 조회중_삭제와_Port_오류는_정상_핀_응답이_아니다() { + Long id = saveCollection(OWNER_ID, "삭제 지도", CollectionVisibility.PUBLIC, 101L); + given(restaurantPort.findActiveMapInfos(anyCollection())).willAnswer(invocation -> { + collectionService.delete(id); + return List.of(mapInfo(101L, true)); + }); + assertBusinessError(() -> mapService.getMarkers(id), UserErrorCode.COLLECTION_NOT_FOUND); + Long failed = saveCollection(OWNER_ID, "실패 지도", CollectionVisibility.PUBLIC, 101L); + String privateDetail = "collectionId=" + failed + " SELECT private_sql key=synthetic-secret"; + String privateCause = "private-address 35.654321 139.123456"; + var failure = new IllegalStateException(privateDetail, new IllegalArgumentException(privateCause)); + org.mockito.Mockito.doThrow(failure).when(restaurantPort).findActiveMapInfos(anyCollection()); + Logger logger = (Logger) LoggerFactory.getLogger(CollectionMapQueryService.class); + Level previousLevel = logger.getLevel(); + ListAppender logs = new ListAppender<>(); + logger.setLevel(Level.WARN); + logs.start(); + logger.addAppender(logs); + try { + assertThatThrownBy(() -> mapService.getMarkers(failed)) + .isInstanceOfSatisfying(BusinessException.class, error -> { + assertThat(error.getErrorCode()).isEqualTo(UserErrorCode.COLLECTION_MAP_UNAVAILABLE); + assertThat(error.getCause()).isSameAs(failure); + }); + assertThat(logs.list).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).isEqualTo( + "Collection map failed. operation=collection-map-port exceptionType=IllegalStateException"); + assertThat(event.getArgumentArray()).containsExactly("IllegalStateException"); + assertThat(event.getThrowableProxy()).isNull(); + assertThat(event.getFormattedMessage()).doesNotContain(privateDetail, privateCause, + "collectionId=", "private_sql", "synthetic-secret", "35.654321", "139.123456"); + }); + } finally { + logger.detachAppender(logs); + logs.stop(); + logger.setLevel(previousLevel); + } + } + + @Test + void Port_조회중_만료된_좌표는_응답직전_다시_제외한다() { + Long id = saveCollection(OWNER_ID, "만료 지도", CollectionVisibility.PUBLIC, 101L); + given(restaurantPort.findActiveMapInfos(anyCollection())).willAnswer(invocation -> { + given(mapClock.instant()).willReturn(Instant.parse("2026-10-02T00:00:00Z")); + return List.of(mapInfo(101L, true)); + }); + assertThat(mapService.getMarkers(id).content().size()).isZero(); + } + + private RestaurantMapInfo mapInfo(Long id, boolean located) { + return new RestaurantMapInfo(id, "식당 " + id, "restaurant", "japanese", located + ? new RestaurantMapInfo.LocationInfo(new BigDecimal("35.6"), new BigDecimal("139.7"), + Instant.parse("2026-10-02T00:00:00Z")) : null); + } + private Long saveCollection(Long userId, String name, CollectionVisibility visibility, Long... restaurantIds) { RestaurantCollection collection = RestaurantCollection.create( userId, name, CollectionColor.RED, null, visibility); diff --git a/src/test/resources/logback-maintenance-test.xml b/src/test/resources/logback-maintenance-test.xml new file mode 100644 index 00000000..a49033a6 --- /dev/null +++ b/src/test/resources/logback-maintenance-test.xml @@ -0,0 +1,3 @@ + + +