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/build.gradle b/build.gradle index 4eebaa6b..086f9d2d 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,6 +104,9 @@ tasks.named('jar') { } tasks.named('test') { + // 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() testLogging { exceptionFormat = 'full' 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/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..fefdbd2a 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,13 +56,13 @@ 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 비공개 유지 | @@ -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..bb716df8 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) @@ -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)는 @@ -282,7 +301,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 +331,7 @@ timestamp는 기존 LocalDateTime 형식이며, 새 data의 UTC 시각과 혼동 ``` 좌표를 반환하는 API와 개인/컬렉션 응답은 `Cache-Control: no-store`를 사용한다. -FE는 좌표를 영구 저장소에 남기지 않고 각 validUntil에 폐기한다. 세션 15분 TTL이 좌표의 +FE는 좌표를 영구 저장소에 남기지 않고 각 validUntil에 폐기한다. 조회 세션 TTL이 좌표의 보관 허가/수명을 연장하지 않는다. 정상/실패와 무관하게 로그에 좌표·검색어·Google 원문을 남기지 않는다. ## 8. Google 출처·보존과 운영 전환 @@ -348,11 +367,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 +384,4 @@ backfill을 활성화하지 않고 fake adapter로 개발한다. MySQL에 넣어 허용 보존 기간, 키 제한, timeout·quota· 재시도 한도, Redis 인증 데이터와의 용량 경쟁을 확인한다. backfill은 대상 수·주소 품질·예상 호출량을 **조회 전용 dry-run**으로 확인한 뒤 checkpoint·중단·복구 기준과 별도 운영 승인을 갖춘다. -이 PR은 Java·dependency·migration·유료 호출·운영 데이터 변경·배포를 포함하지 않는다. +이 계약 문서는 기능 구현·유료 호출·운영 데이터 변경·배포 완료의 증거가 아니다. 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-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/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/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..19442002 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantMapInfo.java @@ -0,0 +1,13 @@ +package org.sopt.hashi.restaurant; + +import java.math.BigDecimal; +import java.time.Instant; + +/** 공개 식당의 최소 값 정보. 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) { + } +} 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..924dbab2 100644 --- a/src/main/java/org/sopt/hashi/restaurant/RestaurantPort.java +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantPort.java @@ -31,6 +31,14 @@ public interface RestaurantPort { */ List findActiveDetails(Collection restaurantIds); + /** + * 컬렉션 목록/핀용 공개 식당 정보. 삭제/없는 식당은 생략하고 위치 미준비/만료 식당은 location=null로 남긴다. + * 최초 입력 순서를 유지하고 중복 ID는 제거한다. null/빈 입력은 빈 목록, null/0/음수 ID는 잘못된 입력이다. + * 내부 500개 단위로 조회하며 전체 결과를 모은 뒤 반환한다. 중간 조회 실패는 부분 결과 없이 전파한다. + * 반환된 좌표도 validUntil부터 사용할 수 없으며 호출자는 응답 직전 자신의 권한/컬렉션 버전을 재검사한다. + */ + List findActiveMapInfos(Collection restaurantIds); + /** * 사용자 노출용 식당 카드 목록 — 저장 컬렉션처럼 카드 표시와 평점·리뷰 수 정렬, 음식점 분류 필터가 필요한 enrich용(#216). * 상세와 달리 영업시간·이미지 전체·가격대는 읽지 않는다. 삭제된 식당은 제외하고 존재하는 식당만 반환하며 순서는 보장하지 않는다. @@ -49,6 +57,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 +74,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..b0ebd65d 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,25 @@ 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", "지도 조회 요청이 많습니다. 잠시 후 다시 시도해주세요."), + 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..e8a298d3 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMapQueryRepository.java @@ -0,0 +1,225 @@ +package org.sopt.hashi.restaurant.domain; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +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.springframework.stereotype.Repository; + +/** 지도 전용 값 projection. 모든 SQL은 restaurant 모듈 안의 테이블만 사용한다. */ +@Repository +public class RestaurantMapQueryRepository { + + 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') + 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])))) + .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); + } +} 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/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/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/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/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/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/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/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/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/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/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 @@ + + +