From df48ed78eb7d6e02a744d97bba56e4dfeec8048f Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 18:57:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=9E=A5=EC=86=8C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=EC=99=80=20=EA=B0=99=EC=9D=80=20=EA=B0=80?= =?UTF-8?q?=EA=B2=8C=20=EC=A4=91=EB=B3=B5=20=EB=B3=91=ED=95=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색·지도·상세 화면이 장소 DB 를 쓰도록 조회 API 를 연다(비로그인 허용). - GET /api/places: 이름 검색, 좌표+반경 거리순, 분류·반려동물·실내 필터, 키셋 커서 페이지네이션(정렬값+id), 전체 개수, 항목별 "지금 영업 중"(KST) - GET /api/places/{id}: 영업시간 원문·해석 상태·출처, 반려동물 크기·조건, 이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기. 병합된 id 로 들어오면 병합 대상 장소를, 숨김·폐업 추정이면 RES_001 을 준다 - 같은 가게 병합: 관광공사 쪽이 카페·식당·주점이고 100m 안이며 상호가 같거나 포함(짧은 쪽 2글자 이상)이면 상가정보 쪽을 merged_into_id 로 관광공사 쪽에 합친다. tour-kor·store 가 끝날 때 자동으로, place:sync -- dedupe 로 따로도 돈다 - 사후면세점 브랜드 매장(SH040300)은 목록·필터에서 빼고 이름 검색에만 낸다 (반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제) - 공공 반려동물 조건 원문의 글머리표·줄바꿈 정리 e2e(test/place.e2e-spec.ts)는 시작할 때 마이그레이션을 적용하고 동해 좌표에 테스트 장소를 만들어 노출 규칙·필터·커서·상세·오류 코드를 검증한다. 2026-11-02 대구 실측: 같은 가게 21곳 병합, 도심 반려동물 필터 7곳. Co-Authored-By: Claude Opus 5.5 --- backend/src/commands/place-sync.ts | 6 +- ...3606583333-AddPlaceMergeAndListingFlags.ts | 123 +++++++ .../src/modules/place/dto/place-detail.dto.ts | 120 +++++++ .../modules/place/dto/place-list-item.dto.ts | 128 +++++++ .../place/dto/place-search-query.dto.ts | 126 +++++++ .../place/entities/place-listing.entity.ts | 30 +- .../modules/place/entities/place.entity.ts | 13 + backend/src/modules/place/pet-note.spec.ts | 28 ++ backend/src/modules/place/pet-note.ts | 26 ++ .../src/modules/place/place-cursor.spec.ts | 36 ++ backend/src/modules/place/place-cursor.ts | 39 +++ backend/src/modules/place/place.constants.ts | 37 +++ backend/src/modules/place/place.controller.ts | 46 +++ backend/src/modules/place/place.module.ts | 8 +- backend/src/modules/place/place.service.ts | 238 +++++++++++++ backend/src/modules/place/place.sql.spec.ts | 13 + backend/src/modules/place/place.sql.ts | 41 +++ backend/src/modules/place/shop-name.spec.ts | 38 +++ backend/src/modules/place/shop-name.ts | 26 ++ .../src/modules/place/sync/place-dedupe.ts | 89 +++++ .../modules/place/sync/place-sync.module.ts | 4 +- backend/src/modules/place/sync/store.sync.ts | 8 +- .../src/modules/place/sync/tour-kor.sync.ts | 6 + backend/src/shared/utils/kst.spec.ts | 12 +- backend/src/shared/utils/kst.ts | 9 + backend/src/shared/utils/sql-like.spec.ts | 11 + backend/src/shared/utils/sql-like.ts | 7 + backend/test/place.e2e-spec.ts | 313 ++++++++++++++++++ docs/design/place-db.md | 38 ++- 29 files changed, 1604 insertions(+), 15 deletions(-) create mode 100644 backend/src/migrations/1793606583333-AddPlaceMergeAndListingFlags.ts create mode 100644 backend/src/modules/place/dto/place-detail.dto.ts create mode 100644 backend/src/modules/place/dto/place-list-item.dto.ts create mode 100644 backend/src/modules/place/dto/place-search-query.dto.ts create mode 100644 backend/src/modules/place/pet-note.spec.ts create mode 100644 backend/src/modules/place/pet-note.ts create mode 100644 backend/src/modules/place/place-cursor.spec.ts create mode 100644 backend/src/modules/place/place-cursor.ts create mode 100644 backend/src/modules/place/place.controller.ts create mode 100644 backend/src/modules/place/place.service.ts create mode 100644 backend/src/modules/place/place.sql.spec.ts create mode 100644 backend/src/modules/place/place.sql.ts create mode 100644 backend/src/modules/place/shop-name.spec.ts create mode 100644 backend/src/modules/place/shop-name.ts create mode 100644 backend/src/modules/place/sync/place-dedupe.ts create mode 100644 backend/src/shared/utils/sql-like.spec.ts create mode 100644 backend/src/shared/utils/sql-like.ts create mode 100644 backend/test/place.e2e-spec.ts diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts index 1a7de31..4e13d21 100644 --- a/backend/src/commands/place-sync.ts +++ b/backend/src/commands/place-sync.ts @@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { WinstonModule } from 'nest-winston'; +import { PlaceDedupe } from '@/modules/place/sync/place-dedupe'; import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status'; import { DEFAULT_STORE_SIGNGU, @@ -30,10 +31,11 @@ const USAGE = `사용법: place:sync <작업> [옵션] tour-pet 관광공사 반려동물 동반 정보 반영 (전국 약 10회 호출) store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다 --signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구) + dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행) status 지역별 적재 현황과 오늘 쓴 호출 수 --region=27`; -const JOBS = ['tour-kor', 'tour-pet', 'store', 'status'] as const; +const JOBS = ['tour-kor', 'tour-pet', 'store', 'dedupe', 'status'] as const; type Job = (typeof JOBS)[number]; function parseArgs(argv: string[]): { @@ -84,6 +86,8 @@ async function main(): Promise { await app.get(TourPetSync).run(); } else if (job === 'store') { await app.get(StoreSync).run({ signguCodes: signgu }); + } else if (job === 'dedupe') { + await app.get(PlaceDedupe).run(); } else { await app.get(PlaceSyncStatus).report(region); } diff --git a/backend/src/migrations/1793606583333-AddPlaceMergeAndListingFlags.ts b/backend/src/migrations/1793606583333-AddPlaceMergeAndListingFlags.ts new file mode 100644 index 0000000..f4c6bef --- /dev/null +++ b/backend/src/migrations/1793606583333-AddPlaceMergeAndListingFlags.ts @@ -0,0 +1,123 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPlaceMergeAndListingFlags1793606583333 implements MigrationInterface { + name = 'AddPlaceMergeAndListingFlags1793606583333'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'place_listing', 'public'], + ); + await queryRunner.query(`DROP VIEW "place_listing"`); + await queryRunner.query(`ALTER TABLE "place" ADD "merged_into_id" uuid`); + await queryRunner.query( + `ALTER TABLE "place" ADD CONSTRAINT "FK_d525a5b0e0dccec5c1165637dc7" FOREIGN KEY ("merged_into_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query(`CREATE VIEW "place_listing" AS +SELECT + p.id, + p.source, + p.source_id, + p.source_category, + p.category, + p.name, + p.branch_name, + p.address_road, + p.lat, + p.lng, + p.ldong_regn_cd, + p.ldong_signgu_cd, + p.image_url, + p.image_thumb_url, + p.image_license, + p.status, + c.verdict, + c.verified_at, + c.setting, + CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours, + CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text, + CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin, + CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy, + CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin, + CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes, + CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions, + (p.status = 'active' AND p.merged_into_id IS NULL) AS is_searchable, + COALESCE(p.source_category = 'SH040300' AND p.name !~ '^(현대백화점|더현대|롯데백화점|대구백화점|신세계|대구신세계|동아백화점)', false) AS is_brand_store, + (p.status = 'active' AND p.merged_into_id IS NULL AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick, + CASE + WHEN NOT (p.status = 'active' AND p.merged_into_id IS NULL) THEN NULL + WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1 + WHEN c.verdict = 'not_recommend' THEN NULL + WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2 + END AS ai_tier +FROM place p +LEFT JOIN place_curation c ON c.place_id = p.id +`); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'place_listing', + "SELECT\n p.id,\n p.source,\n p.source_id,\n p.source_category,\n p.category,\n p.name,\n p.branch_name,\n p.address_road,\n p.lat,\n p.lng,\n p.ldong_regn_cd,\n p.ldong_signgu_cd,\n p.image_url,\n p.image_thumb_url,\n p.image_license,\n p.status,\n c.verdict,\n c.verified_at,\n c.setting,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,\n (p.status = 'active' AND p.merged_into_id IS NULL) AS is_searchable,\n COALESCE(p.source_category = 'SH040300' AND p.name !~ '^(현대백화점|더현대|롯데백화점|대구백화점|신세계|대구신세계|동아백화점)', false) AS is_brand_store,\n (p.status = 'active' AND p.merged_into_id IS NULL AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,\n CASE\n WHEN NOT (p.status = 'active' AND p.merged_into_id IS NULL) THEN NULL\n WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1\n WHEN c.verdict = 'not_recommend' THEN NULL\n WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2\n END AS ai_tier\nFROM place p\nLEFT JOIN place_curation c ON c.place_id = p.id", + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'place_listing', 'public'], + ); + await queryRunner.query(`DROP VIEW "place_listing"`); + await queryRunner.query( + `ALTER TABLE "place" DROP CONSTRAINT "FK_d525a5b0e0dccec5c1165637dc7"`, + ); + await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "merged_into_id"`); + await queryRunner.query(`CREATE VIEW "place_listing" AS SELECT + p.id, + p.source, + p.source_id, + p.category, + p.name, + p.branch_name, + p.address_road, + p.lat, + p.lng, + p.ldong_regn_cd, + p.ldong_signgu_cd, + p.image_url, + p.image_thumb_url, + p.image_license, + p.status, + c.verdict, + c.verified_at, + c.setting, + CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours, + CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text, + CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin, + CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy, + CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin, + CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes, + CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions, + (p.status = 'active') AS is_searchable, + (p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick, + CASE + WHEN p.status <> 'active' THEN NULL + WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1 + WHEN c.verdict = 'not_recommend' THEN NULL + WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2 + END AS ai_tier +FROM place p +LEFT JOIN place_curation c ON c.place_id = p.id`); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'place_listing', + "SELECT\n p.id,\n p.source,\n p.source_id,\n p.category,\n p.name,\n p.branch_name,\n p.address_road,\n p.lat,\n p.lng,\n p.ldong_regn_cd,\n p.ldong_signgu_cd,\n p.image_url,\n p.image_thumb_url,\n p.image_license,\n p.status,\n c.verdict,\n c.verified_at,\n c.setting,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,\n (p.status = 'active') AS is_searchable,\n (p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,\n CASE\n WHEN p.status <> 'active' THEN NULL\n WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1\n WHEN c.verdict = 'not_recommend' THEN NULL\n WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2\n END AS ai_tier\nFROM place p\nLEFT JOIN place_curation c ON c.place_id = p.id", + ], + ); + } +} diff --git a/backend/src/modules/place/dto/place-detail.dto.ts b/backend/src/modules/place/dto/place-detail.dto.ts new file mode 100644 index 0000000..bc232ba --- /dev/null +++ b/backend/src/modules/place/dto/place-detail.dto.ts @@ -0,0 +1,120 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { PET_SIZES } from '../place.constants'; +import type { PetSize } from '../place.constants'; +import { PlaceListItemDto } from './place-list-item.dto'; + +const HOURS_STATUSES = ['parsed', 'always_open', 'varies', 'unknown'] as const; + +export class PlaceHoursDto { + @ApiProperty({ + description: + '영업시간 원문(운영자 확인 값이 있으면 그것, 없으면 공공데이터)', + nullable: true, + example: '11:00~21:00 (준비 시간 15:30~17:00)', + }) + text!: string | null; + + @ApiProperty({ + description: '쉬는 날 원문', + nullable: true, + example: '매주 월요일', + }) + restText!: string | null; + + @ApiProperty({ + description: + '해석 결과 — varies(점포별 상이)·unknown 이면 시간을 단정하지 않는다', + enum: HOURS_STATUSES, + }) + status!: (typeof HOURS_STATUSES)[number]; +} + +export class PlacePetDto { + @ApiProperty({ + description: '반려견 크기(운영자 확인)', + enum: PET_SIZES, + isArray: true, + nullable: true, + }) + sizes!: PetSize[] | null; + + @ApiProperty({ + description: + '조건 — 운영자 확인 값 또는 공공데이터 원문(예: "목줄 착용", "켄넬 필수")', + nullable: true, + example: '전 견종 동반 가능 · 목줄 착용', + }) + conditions!: string | null; +} + +export class PlaceCurationInfoDto { + @ApiProperty({ + description: '추천 이유(운영자가 직접 쓴 글)', + nullable: true, + example: '골목 벽화를 따라 30분 산책하기 좋아요.', + }) + reason!: string | null; + + @ApiProperty({ description: '업체 공식 채널', nullable: true }) + officialUrl!: string | null; +} + +export class PlaceAttributionDto { + @ApiProperty({ + description: '정보 출처 표기', + example: '한국관광공사', + }) + provider!: string; + + @ApiProperty({ + description: + '원본 기준 시점(관광공사 수정일 YYYY-MM-DD · 상가정보 기준년월 YYYYMM)', + nullable: true, + example: '2026-10-15', + }) + asOf!: string | null; +} + +/** 장소 상세 — 목록 필드에 상세 정보를 더한다 */ +export class PlaceDetailDto extends PlaceListItemDto { + @ApiProperty({ description: '지번 주소', nullable: true }) + addressJibun!: string | null; + + @ApiProperty({ description: '전화', nullable: true }) + phone!: string | null; + + @ApiProperty({ description: '홈페이지', nullable: true }) + homepage!: string | null; + + @ApiProperty({ description: '소개(공공데이터)', nullable: true }) + overview!: string | null; + + @ApiProperty({ description: '큰 사진 URL', nullable: true }) + imageUrl!: string | null; + + @ApiProperty({ type: PlaceHoursDto }) + hours!: PlaceHoursDto; + + @ApiProperty({ type: PlacePetDto }) + pet!: PlacePetDto; + + @ApiProperty({ + description: '이용 안내(입장료·주차·문의처·대표 메뉴 등 공공데이터 원문)', + type: 'object', + additionalProperties: { type: 'string' }, + nullable: true, + example: { fee: '무료', parking: '가능' }, + }) + extra!: Record | null; + + @ApiProperty({ + type: PlaceCurationInfoDto, + nullable: true, + description: '운영자 추천 정보 — 추천(O)하고 확인한 장소만', + }) + curation!: PlaceCurationInfoDto | null; + + @ApiProperty({ type: PlaceAttributionDto }) + attribution!: PlaceAttributionDto; +} diff --git a/backend/src/modules/place/dto/place-list-item.dto.ts b/backend/src/modules/place/dto/place-list-item.dto.ts new file mode 100644 index 0000000..a443f65 --- /dev/null +++ b/backend/src/modules/place/dto/place-list-item.dto.ts @@ -0,0 +1,128 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { + IMAGE_LICENSES, + PET_POLICIES, + PLACE_CATEGORIES, + PLACE_SETTINGS, + PLACE_SOURCES, +} from '../place.constants'; +import type { + ImageLicense, + PetPolicy, + PlaceCategory, + PlaceSetting, + PlaceSource, +} from '../place.constants'; + +/** 값의 출처 — 화면 표기("11월 2일 확인" / "출처 한국관광공사" / "미확인")를 고른다 */ +const VALUE_ORIGINS = ['curation', 'public', 'unknown'] as const; +export type ValueOriginDto = (typeof VALUE_ORIGINS)[number]; + +/** 검색·지도 목록의 장소 한 건 */ +export class PlaceListItemDto { + @ApiProperty({ + description: '장소 id', + example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', + }) + id!: string; + + @ApiProperty({ description: '이름', example: '김광석다시그리기길' }) + name!: string; + + @ApiProperty({ description: '지점명', nullable: true, example: null }) + branchName!: string | null; + + @ApiProperty({ + description: '분류', + enum: PLACE_CATEGORIES, + example: 'attraction', + }) + category!: PlaceCategory; + + @ApiProperty({ + description: '데이터 출처(tour 한국관광공사 · store 소상공인 상가정보)', + enum: PLACE_SOURCES, + }) + source!: PlaceSource; + + @ApiProperty({ + description: '주소', + nullable: true, + example: '대구광역시 중구 달구벌대로 2238', + }) + address!: string | null; + + @ApiProperty({ description: '위도', example: 35.8597 }) + lat!: number; + + @ApiProperty({ description: '경도', example: 128.6067 }) + lng!: number; + + @ApiProperty({ + description: '기준 좌표에서의 거리(m). lat·lng 없이 조회하면 null', + nullable: true, + example: 1057, + }) + distanceM!: number | null; + + @ApiProperty({ description: '작은 사진 URL', nullable: true }) + imageThumbUrl!: string | null; + + @ApiProperty({ + description: + '사진 공공누리 유형 — 화면에 출처를 표시해야 한다(Type3 은 변경 금지)', + enum: IMAGE_LICENSES, + nullable: true, + }) + imageLicense!: ImageLicense | null; + + @ApiProperty({ + description: + '영업시간 출처(curation 운영자 확인 · public 공공데이터 · unknown 미확인)', + enum: VALUE_ORIGINS, + }) + hoursOrigin!: ValueOriginDto; + + @ApiProperty({ + description: + '지금(한국 시간) 영업 중인지. 알 수 없으면 null — "영업시간 미확인 · 방문 전 확인"', + nullable: true, + example: true, + }) + openNow!: boolean | null; + + @ApiProperty({ + description: '반려동물 동반', + enum: PET_POLICIES, + example: 'allowed', + }) + petPolicy!: PetPolicy; + + @ApiProperty({ description: '반려동물 정보 출처', enum: VALUE_ORIGINS }) + petOrigin!: ValueOriginDto; + + @ApiProperty({ + description: '실내/실외(운영자가 확인한 장소만)', + enum: PLACE_SETTINGS, + nullable: true, + }) + setting!: PlaceSetting | null; + + @ApiProperty({ + description: '운영자 확인일(YYYY-MM-DD). 확인하지 않았으면 null', + nullable: true, + example: null, + }) + verifiedAt!: string | null; +} + +/** 장소 목록 응답 본문(인터셉터가 { success, data } 로 한 번 더 감싼다) */ +export class PlaceListResponseDto { + @ApiProperty({ type: [PlaceListItemDto] }) + items!: PlaceListItemDto[]; + + @ApiProperty({ type: PageInfoDto }) + pageInfo!: PageInfoDto; +} diff --git a/backend/src/modules/place/dto/place-search-query.dto.ts b/backend/src/modules/place/dto/place-search-query.dto.ts new file mode 100644 index 0000000..f536532 --- /dev/null +++ b/backend/src/modules/place/dto/place-search-query.dto.ts @@ -0,0 +1,126 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsIn, + IsInt, + IsLatitude, + IsLongitude, + IsOptional, + IsString, + Length, + Max, + Min, + ValidateIf, +} from 'class-validator'; + +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { PLACE_CATEGORIES } from '../place.constants'; +import type { PlaceCategory } from '../place.constants'; + +/** 반경 기본값·한도(m) */ +export const PLACE_RADIUS_DEFAULT_M = 1_500; +export const PLACE_RADIUS_MIN_M = 100; +export const PLACE_RADIUS_MAX_M = 20_000; + +/** 쿼리 문자열 'true'/'false' → boolean. 그 밖의 값은 그대로 두어 검증에서 걸리게 한다 */ +const toBoolean = ({ value }: { value: unknown }): unknown => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; +}; + +/** 'cafe,food' → ['cafe', 'food'] (같은 키를 여러 번 보내도 된다) */ +const toList = ({ value }: { value: unknown }): unknown => + typeof value === 'string' + ? value + .split(',') + .map((v) => v.trim()) + .filter(Boolean) + : value; + +/** 장소 검색·근처 목록 조건 */ +export class PlaceSearchQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: + '이름 검색어(1~50자). 있으면 사후면세점 브랜드 매장도 검색된다', + example: '김광석', + }) + @IsOptional() + @IsString({ message: '검색어는 문자열로 입력해 주세요.' }) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) + @Length(1, 50, { message: '검색어는 1~50자로 입력해 주세요.' }) + q?: string; + + @ApiPropertyOptional({ + description: '분류(쉼표로 여러 개)', + enum: PLACE_CATEGORIES, + isArray: true, + example: 'cafe,food', + }) + @IsOptional() + @Transform(toList) + @IsArray({ message: '분류 값이 올바르지 않습니다.' }) + @IsIn(PLACE_CATEGORIES, { + each: true, + message: '분류 값이 올바르지 않습니다.', + }) + category?: PlaceCategory[]; + + @ApiPropertyOptional({ + description: '기준 위도 — lng 와 함께 보내면 거리순으로 반경 안만 찾는다', + example: 35.865, + }) + @ValidateIf( + (o: PlaceSearchQueryDto) => o.lat !== undefined || o.lng !== undefined, + ) + @Type(() => Number) + @IsLatitude({ message: '위도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.' }) + lat?: number; + + @ApiPropertyOptional({ description: '기준 경도', example: 128.5966 }) + @ValidateIf( + (o: PlaceSearchQueryDto) => o.lat !== undefined || o.lng !== undefined, + ) + @Type(() => Number) + @IsLongitude({ + message: '경도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.', + }) + lng?: number; + + @ApiPropertyOptional({ + description: `반경(m, ${PLACE_RADIUS_MIN_M}~${PLACE_RADIUS_MAX_M}). lat·lng 가 있을 때만 쓴다`, + default: PLACE_RADIUS_DEFAULT_M, + }) + @IsOptional() + @Type(() => Number) + @IsInt({ message: '반경은 정수(m)로 입력해 주세요.' }) + @Min(PLACE_RADIUS_MIN_M, { + message: `반경은 ${PLACE_RADIUS_MIN_M}m 이상이어야 합니다.`, + }) + @Max(PLACE_RADIUS_MAX_M, { + message: `반경은 ${PLACE_RADIUS_MAX_M}m 이하여야 합니다.`, + }) + radius?: number; + + @ApiPropertyOptional({ + description: '반려동물 동반 가능(전 구역·일부 구역) 장소만', + example: true, + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean({ message: 'pet 은 true 또는 false 로 입력해 주세요.' }) + pet?: boolean; + + @ApiPropertyOptional({ + description: '실내(실내·실내외) 장소만 — 운영자가 확인한 장소만 해당한다', + example: false, + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean({ message: 'indoor 는 true 또는 false 로 입력해 주세요.' }) + indoor?: boolean; +} diff --git a/backend/src/modules/place/entities/place-listing.entity.ts b/backend/src/modules/place/entities/place-listing.entity.ts index 94b878b..b5d48aa 100644 --- a/backend/src/modules/place/entities/place-listing.entity.ts +++ b/backend/src/modules/place/entities/place-listing.entity.ts @@ -1,7 +1,13 @@ import { ViewColumn, ViewEntity } from 'typeorm'; import type { ParsedOpeningHours } from '@/shared/utils/opening-hours'; -import { AI_TIER2_CATEGORIES, sqlValues } from '../place.constants'; +import { + AI_TIER2_CATEGORIES, + BRAND_STORE_CATEGORY, + MALL_NAME_PREFIXES, + sqlAlternation, + sqlValues, +} from '../place.constants'; import type { CurationVerdict, ImageLicense, @@ -22,12 +28,17 @@ const PUBLIC_HOURS = `p.public_hours->>'status' IN ('parsed', 'always_open')`; const CURATED_PET = `c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown'`; const PUBLIC_PET = `p.public_pet_policy IS NOT NULL`; const HOME_PICK = `c.verdict = 'recommend' AND c.verified_at IS NOT NULL`; +// 노출 중 = 숨김·폐업 추정이 아니고, 다른 출처의 같은 가게로 병합되지도 않은 장소 +const LISTED = `p.status = 'active' AND p.merged_into_id IS NULL`; +// 사후면세점 브랜드 매장(백화점·쇼핑몰 자체는 제외) — 목록·필터에서 빼고 이름 검색에만 낸다 +const BRAND_STORE = `p.source_category = ${sqlValues([BRAND_STORE_CATEGORY])} AND p.name !~ '^(${sqlAlternation(MALL_NAME_PREFIXES)})'`; /** * "앱이 보는 값" — 공공 값과 큐레이션 값을 합치고 노출 플래그를 계산한다. * 우선순위(큐레이션 > 공공 > 미확인)와 노출 규칙은 **여기서만** 정한다. API·AI 는 이 뷰를 읽는다. * 노출 규칙(2026-11-02 합의, docs/design/place-db.md §3): - * - is_searchable: 노출 중인 장소는 모두 검색·지도에 나온다(확인 안 된 상가정보 매장 포함) + * - is_searchable: 노출 중인 장소는 모두 검색·지도에 나온다(확인 안 된 상가정보 매장 포함, 병합된 중복은 제외) + * - is_brand_store: 사후면세점 브랜드 매장 — 조회 API 가 목록·필터에서는 빼고 이름 검색에만 낸다 * - is_home_pick: 추천(O) + 확인일이 있는 장소만 홈 동네 추천에 나온다 * - ai_tier: 1 = 추천(O) + 확인 / 2 = 관광공사 장소(코스 분류, X 아님) / NULL = 그 외 — 확인 안 된 상가정보 매장은 AI 후보가 아니다 */ @@ -36,6 +47,7 @@ SELECT p.id, p.source, p.source_id, + p.source_category, p.category, p.name, p.branch_name, @@ -58,10 +70,11 @@ SELECT CASE WHEN ${CURATED_PET} THEN 'curation' WHEN ${PUBLIC_PET} THEN 'public' ELSE 'unknown' END AS pet_origin, CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes, CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions, - (p.status = 'active') AS is_searchable, - (p.status = 'active' AND COALESCE(${HOME_PICK}, false)) AS is_home_pick, + (${LISTED}) AS is_searchable, + COALESCE(${BRAND_STORE}, false) AS is_brand_store, + (${LISTED} AND COALESCE(${HOME_PICK}, false)) AS is_home_pick, CASE - WHEN p.status <> 'active' THEN NULL + WHEN NOT (${LISTED}) THEN NULL WHEN ${HOME_PICK} THEN 1 WHEN c.verdict = 'not_recommend' THEN NULL WHEN p.source = 'tour' AND p.category IN (${sqlValues(AI_TIER2_CATEGORIES)}) THEN 2 @@ -81,6 +94,10 @@ export class PlaceListing { @ViewColumn() sourceId!: string; + /** 원본 분류 코드(lclsSystm3 · indsSclsCd) */ + @ViewColumn() + sourceCategory!: string | null; + @ViewColumn() category!: PlaceCategory; @@ -151,6 +168,9 @@ export class PlaceListing { @ViewColumn() isSearchable!: boolean; + @ViewColumn() + isBrandStore!: boolean; + @ViewColumn() isHomePick!: boolean; diff --git a/backend/src/modules/place/entities/place.entity.ts b/backend/src/modules/place/entities/place.entity.ts index 78bc027..10843ca 100644 --- a/backend/src/modules/place/entities/place.entity.ts +++ b/backend/src/modules/place/entities/place.entity.ts @@ -4,6 +4,8 @@ import { CreateDateColumn, Entity, Index, + JoinColumn, + ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique, @@ -167,6 +169,17 @@ export class Place { @Column({ type: 'timestamptz', nullable: true }) detailSyncedAt!: Date | null; + /** + * 같은 가게를 다른 출처가 이미 가지고 있으면 그 장소의 id(상가정보 → 관광공사). + * 값이 있으면 검색·추천에 내보내지 않고, 상세로 들어오면 병합 대상 장소를 보여 준다. 동기화가 끝날 때마다 다시 계산한다. + */ + @Column({ type: 'uuid', nullable: true }) + mergedIntoId!: string | null; + + @ManyToOne(() => Place, { nullable: true }) + @JoinColumn({ name: 'merged_into_id' }) + mergedInto?: Relation | null; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/backend/src/modules/place/pet-note.spec.ts b/backend/src/modules/place/pet-note.spec.ts new file mode 100644 index 0000000..370806d --- /dev/null +++ b/backend/src/modules/place/pet-note.spec.ts @@ -0,0 +1,28 @@ +import { publicPetNote } from './pet-note'; + +describe('publicPetNote', () => { + it('글머리표·줄바꿈을 정리해 한 줄로 잇는다(김광석다시그리기길 실제 원문)', () => { + expect( + publicPetNote({ + acmpyTypeCd: '전구역 동반가능', + acmpyPsblCpam: '전 견종 동반 가능', + acmpyNeedMtr: '목줄 착용', + etcAcmpyInfo: + '- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참 및 배변처리 필수', + }), + ).toBe( + '전 견종 동반 가능 · 목줄 착용 · 맹견의 경우, 입마개 착용 필수 · 배변봉투 지참 및 배변처리 필수', + ); + }); + + it('같은 문장이 여러 필드에 반복되면 한 번만 쓴다', () => { + expect( + publicPetNote({ acmpyNeedMtr: '목줄 착용', etcAcmpyInfo: '※ 목줄 착용' }), + ).toBe('목줄 착용'); + }); + + it('조건 문장이 없으면 null', () => { + expect(publicPetNote({ acmpyTypeCd: '전구역 동반가능' })).toBeNull(); + expect(publicPetNote(null)).toBeNull(); + }); +}); diff --git a/backend/src/modules/place/pet-note.ts b/backend/src/modules/place/pet-note.ts new file mode 100644 index 0000000..b9383ea --- /dev/null +++ b/backend/src/modules/place/pet-note.ts @@ -0,0 +1,26 @@ +/** + * 관광공사 반려동물 동반 정보 원문 → 화면에 보여 줄 조건 문장 (순수 함수). + * 원문에는 글머리표('- ')와 줄바꿈이 섞여 온다(예: "- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참"). + */ + +/** 조건으로 보여 줄 필드(동반 구분 acmpyTypeCd 는 petPolicy 로 따로 나간다) */ +const NOTE_FIELDS = ['acmpyPsblCpam', 'acmpyNeedMtr', 'etcAcmpyInfo']; + +/** 줄 앞의 글머리표·번호 표시 */ +const BULLET_RE = /^\s*(?:[-*•·※]|\d+[.)])\s*/; + +export function publicPetNote( + raw: Record | null, +): string | null { + if (!raw) return null; + const parts: string[] = []; + for (const key of NOTE_FIELDS) { + const value = raw[key]; + if (typeof value !== 'string') continue; + for (const line of value.split(/\n+/)) { + const text = line.replace(BULLET_RE, '').trim(); + if (text !== '' && !parts.includes(text)) parts.push(text); + } + } + return parts.length > 0 ? parts.join(' · ') : null; +} diff --git a/backend/src/modules/place/place-cursor.spec.ts b/backend/src/modules/place/place-cursor.spec.ts new file mode 100644 index 0000000..4523cae --- /dev/null +++ b/backend/src/modules/place/place-cursor.spec.ts @@ -0,0 +1,36 @@ +import { decodePlaceCursor, encodePlaceCursor } from './place-cursor'; + +const ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'; + +describe('place cursor', () => { + it('거리·이름 커서를 인코딩했다가 그대로 되돌린다', () => { + const byDistance = encodePlaceCursor({ k: 812.4567, id: ID }); + expect(decodePlaceCursor(byDistance, 'number')).toEqual({ + k: 812.4567, + id: ID, + }); + const byName = encodePlaceCursor({ k: '김광석다시그리기길', id: ID }); + expect(decodePlaceCursor(byName, 'string')).toEqual({ + k: '김광석다시그리기길', + id: ID, + }); + }); + + it('정렬 방식과 맞지 않는 커서는 받지 않는다', () => { + const byName = encodePlaceCursor({ k: '가', id: ID }); + expect(decodePlaceCursor(byName, 'number')).toBeNull(); + }); + + it('형식이 틀린 커서는 받지 않는다', () => { + expect(decodePlaceCursor('아무거나', 'number')).toBeNull(); + expect( + decodePlaceCursor( + Buffer.from('{"k":1,"id":"x"}').toString('base64url'), + 'number', + ), + ).toBeNull(); + expect( + decodePlaceCursor(Buffer.from('null').toString('base64url'), 'number'), + ).toBeNull(); + }); +}); diff --git a/backend/src/modules/place/place-cursor.ts b/backend/src/modules/place/place-cursor.ts new file mode 100644 index 0000000..87bb4c3 --- /dev/null +++ b/backend/src/modules/place/place-cursor.ts @@ -0,0 +1,39 @@ +/** + * 장소 목록 커서 (순수 함수) — 정렬 기준값과 id 로 다음 페이지 시작점을 가리킨다(키셋 페이지네이션). + * 거리순이면 k 는 거리(m, 숫자), 이름순이면 k 는 이름(문자열)이다. + */ +export interface PlaceCursor { + k: number | string; + id: string; +} + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function encodePlaceCursor(cursor: PlaceCursor): string { + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); +} + +/** + * 커서를 해석한다. 형식이 틀리거나 정렬 방식과 맞지 않으면(거리순인데 문자열 등) null. + * null 이면 호출하는 쪽이 VAL_001 로 응답한다. + */ +export function decodePlaceCursor( + raw: string, + kind: 'number' | 'string', +): PlaceCursor | null { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); + } catch { + return null; + } + if (parsed === null || typeof parsed !== 'object') return null; + const { k, id } = parsed as Record; + if (typeof id !== 'string' || !UUID_RE.test(id)) return null; + if (kind === 'number' && typeof k === 'number' && Number.isFinite(k)) { + return { k, id }; + } + if (kind === 'string' && typeof k === 'string') return { k, id }; + return null; +} diff --git a/backend/src/modules/place/place.constants.ts b/backend/src/modules/place/place.constants.ts index 3124eb1..fb1a297 100644 --- a/backend/src/modules/place/place.constants.ts +++ b/backend/src/modules/place/place.constants.ts @@ -105,6 +105,43 @@ export const AI_TIER2_CATEGORIES = [ 'cafe', ] as const satisfies readonly PlaceCategory[]; +/** + * 사후면세점(관광공사 SH040300) — 2026-11-02 대구 도심 쇼핑 135곳 대부분이 더현대 등에 입점한 브랜드 매장이었다. + * 목록·필터 결과에는 내보내지 않고 이름 검색(q)에만 나온다. 백화점·쇼핑몰 자체는 예외(아래 이름으로 시작). + */ +export const BRAND_STORE_CATEGORY = 'SH040300'; +export const MALL_NAME_PREFIXES = [ + '현대백화점', + '더현대', + '롯데백화점', + '대구백화점', + '신세계', + '대구신세계', + '동아백화점', +] as const; + +/** + * 같은 가게를 관광공사·상가정보가 둘 다 가지고 있을 때(2026-11-02 대구 약 21곳) 상가정보 쪽을 관광공사 쪽에 병합한다. + * 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게. + */ +export const MERGE_TARGET_CATEGORIES = [ + 'cafe', + 'food', + 'bar', +] as const satisfies readonly PlaceCategory[]; +/** 같은 가게로 볼 최대 거리(m) */ +export const MERGE_MAX_DISTANCE_M = 100; + +/** 정규식 대안 목록 `a|b|c` — 한글·영문·숫자 단어만 허용한다(뷰 SQL 에 들어간다) */ +export function sqlAlternation(words: readonly string[]): string { + for (const w of words) { + if (!/^[가-힣A-Za-z0-9]+$/.test(w)) { + throw new Error(`SQL 정규식 단어에 허용되지 않는 문자가 있습니다: ${w}`); + } + } + return words.join('|'); +} + /** * CHECK 제약·뷰에 넣을 SQL 값 목록 — `'a', 'b'`. * 코드 상수로만 만들지만, 혹시라도 따옴표가 섞이면 SQL 이 깨지므로 즉시 실패시킨다. diff --git a/backend/src/modules/place/place.controller.ts b/backend/src/modules/place/place.controller.ts new file mode 100644 index 0000000..fe644c0 --- /dev/null +++ b/backend/src/modules/place/place.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { PlaceDetailDto } from './dto/place-detail.dto'; +import { PlaceListResponseDto } from './dto/place-list-item.dto'; +import { PlaceSearchQueryDto } from './dto/place-search-query.dto'; +import { PlaceService } from './place.service'; + +/** + * 장소 조회 — 로그인 없이 쓴다(둘러보기·검색은 비로그인 허용, 2026-10-28 합의). + * 라우팅과 입출력만 담당하고 조회 규칙은 PlaceService·place_listing 뷰가 정한다. + */ +@ApiTags('Place') +@Controller('places') +export class PlaceController { + constructor(private readonly placeService: PlaceService) {} + + @Get() + @ApiOperation({ + summary: '장소 검색·근처 목록 (커서 페이지네이션)', + description: + 'lat·lng 를 주면 반경 안을 거리순으로, 없으면 이름순으로 준다. 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다.', + }) + @ApiStandardResponse(PlaceListResponseDto, { description: '장소 목록' }) + @ApiErrorResponse('VAL_001', '검색 조건 오류(좌표·반경·분류·커서 형식)') + search(@Query() query: PlaceSearchQueryDto) { + return this.placeService.search(query); + } + + @Get(':id') + @ApiOperation({ + summary: '장소 상세', + description: + '같은 가게로 병합된 장소 id 로 들어오면 병합 대상 장소를 준다. 숨김·폐업 추정 장소는 찾을 수 없음으로 응답한다.', + }) + @ApiStandardResponse(PlaceDetailDto, { description: '장소 상세' }) + @ApiErrorResponse('VAL_001', 'id 형식 오류(UUID 가 아님)') + @ApiErrorResponse('RES_001', '장소를 찾을 수 없음') + findOne(@Param('id', new ParseUUIDPipe()) id: string) { + return this.placeService.findOne(id); + } +} diff --git a/backend/src/modules/place/place.module.ts b/backend/src/modules/place/place.module.ts index 6655e9c..4740dd4 100644 --- a/backend/src/modules/place/place.module.ts +++ b/backend/src/modules/place/place.module.ts @@ -7,10 +7,12 @@ import { Place } from './entities/place.entity'; import { PlaceI18n } from './entities/place-i18n.entity'; import { PlaceListing } from './entities/place-listing.entity'; import { PlaceSyncRun } from './entities/place-sync-run.entity'; +import { PlaceController } from './place.controller'; +import { PlaceService } from './place.service'; /** * 장소·축제 데이터 모듈 (설계: docs/design/place-db.md). - * 지금은 스키마만 있다 — 동기화 명령·큐레이션 가져오기·조회 API 는 다음 단계에서 이 모듈에 추가한다. + * 조회 API(검색·근처 목록·상세)를 제공한다. 동기화는 CLI 전용 PlaceSyncModule(sync/)이 맡는다. */ @Module({ imports: [ @@ -23,6 +25,8 @@ import { PlaceSyncRun } from './entities/place-sync-run.entity'; PlaceSyncRun, ]), ], - exports: [TypeOrmModule], + controllers: [PlaceController], + providers: [PlaceService], + exports: [TypeOrmModule, PlaceService], }) export class PlaceModule {} diff --git a/backend/src/modules/place/place.service.ts b/backend/src/modules/place/place.service.ts new file mode 100644 index 0000000..7adbb43 --- /dev/null +++ b/backend/src/modules/place/place.service.ts @@ -0,0 +1,238 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { + PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { kstClock } from '@/shared/utils/kst'; +import { isOpenAt } from '@/shared/utils/opening-hours'; +import { escapeLike } from '@/shared/utils/sql-like'; +import { PlaceDetailDto } from './dto/place-detail.dto'; +import { PlaceListItemDto } from './dto/place-list-item.dto'; +import { + PLACE_RADIUS_DEFAULT_M, + PlaceSearchQueryDto, +} from './dto/place-search-query.dto'; +import { PlaceListing } from './entities/place-listing.entity'; +import { Place } from './entities/place.entity'; +import { publicPetNote } from './pet-note'; +import { decodePlaceCursor, encodePlaceCursor } from './place-cursor'; +import { boundingBox, sqlDistanceM } from './place.sql'; + +/** 출처 표기 */ +const PROVIDER_LABEL: Record = { + tour: '한국관광공사', + store: '소상공인시장진흥공단 상가(상권)정보', +}; +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +/** + * 장소 조회 — 검색·근처 목록과 상세. 노출 규칙·값 우선순위는 place_listing 뷰가 정하고, 여기서는 뷰를 읽기만 한다. + */ +@Injectable() +export class PlaceService { + constructor( + @InjectRepository(PlaceListing) + private readonly listings: Repository, + @InjectRepository(Place) private readonly places: Repository, + ) {} + + /** + * 검색·근처 목록. 좌표가 있으면 반경 안을 거리순으로, 없으면 이름순으로 준다(키셋 커서). + * 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다. + */ + async search( + query: PlaceSearchQueryDto, + now = new Date(), + ): Promise> { + const near = + query.lat !== undefined && query.lng !== undefined + ? { + lat: query.lat, + lng: query.lng, + radius: query.radius ?? PLACE_RADIUS_DEFAULT_M, + } + : null; + const cursor = query.cursor + ? decodePlaceCursor(query.cursor, near ? 'number' : 'string') + : null; + if (query.cursor && !cursor) { + throw new BusinessException('VAL_001', { + context: { reason: '커서 형식 오류' }, + }); + } + + const qb = this.listings + .createQueryBuilder('v') + .where('v.is_searchable = true'); + if (query.q) { + qb.andWhere('v.name ILIKE :q', { q: `%${escapeLike(query.q)}%` }); + } else { + qb.andWhere('v.is_brand_store = false'); + } + if (query.category?.length) { + qb.andWhere('v.category IN (:...categories)', { + categories: query.category, + }); + } + if (query.pet) { + qb.andWhere('v.pet_policy IN (:...pets)', { + pets: ['allowed', 'partial'], + }); + } + if (query.indoor) { + qb.andWhere('v.setting IN (:...settings)', { + settings: ['indoor', 'both'], + }); + } + + // 괄호로 감싸야 TypeORM 이 엔티티 속성 선택으로 해석하지 않는다(그러면 결과 객체가 비어 버린다) + let sortKey = '(v.name)'; + if (near) { + sortKey = sqlDistanceM(':lat', ':lng', 'v.lat', 'v.lng'); + const box = boundingBox(near.lat, near.lng, near.radius); + qb.andWhere('v.lat BETWEEN :latMin AND :latMax', box) + .andWhere('v.lng BETWEEN :lngMin AND :lngMax', box) + .andWhere(`${sortKey} <= :radius`) + .setParameters({ lat: near.lat, lng: near.lng, radius: near.radius }); + } + + const total = await qb.clone().getCount(); + + if (cursor) { + qb.andWhere(`(${sortKey}, v.id) > (:ck, :cid)`, { + ck: cursor.k, + cid: cursor.id, + }); + } + const { entities, raw } = await qb + .addSelect(sortKey, 'sort_key') + .orderBy('sort_key', 'ASC') + .addOrderBy('v.id', 'ASC') + .limit(query.limit + 1) + .getRawAndEntities<{ sort_key: number | string }>(); + + const hasNext = entities.length > query.limit; + const page = entities.slice(0, query.limit); + const clock = kstClock(now); + const distanceAt = (i: number) => (near ? Number(raw[i]?.sort_key) : null); + const items = page.map((v, i) => this.toListItem(v, distanceAt(i), clock)); + + const last = page.at(-1); + const nextCursor = + hasNext && last + ? encodePlaceCursor({ + k: near ? (distanceAt(page.length - 1) ?? 0) : last.name, + id: last.id, + }) + : null; + return toPaginatedResult(items, { hasNext, nextCursor, total }); + } + + /** + * 상세. 다른 장소로 병합된 id(같은 가게의 상가정보 쪽)로 들어오면 병합 대상 장소를 보여 준다. + * 없거나 숨김·폐업 추정이면 RES_001. + */ + async findOne(id: string, now = new Date()): Promise { + const first = await this.places.findOne({ + where: { id }, + relations: { curation: true }, + }); + const place = + first?.mergedIntoId != null + ? await this.places.findOne({ + where: { id: first.mergedIntoId }, + relations: { curation: true }, + }) + : first; + const listing = place + ? await this.listings.findOneBy({ id: place.id }) + : null; + if (!place || !listing?.isSearchable) { + throw new BusinessException('RES_001', { context: { placeId: id } }); + } + + const curation = place.curation ?? null; + const curatedHours = listing.hoursOrigin === 'curation'; + const hoursStatus = curatedHours + ? curation?.hours?.status + : place.publicHours?.status; + + return { + ...this.toListItem(listing, null, kstClock(now)), + addressJibun: place.addressJibun, + phone: place.phone, + homepage: place.homepage, + overview: place.overview, + imageUrl: place.imageUrl, + hours: { + text: curatedHours + ? (curation?.hoursText ?? null) + : place.publicHoursText, + restText: curatedHours + ? (curation?.restText ?? null) + : place.publicRestText, + status: hoursStatus ?? 'unknown', + }, + pet: { + sizes: listing.petOrigin === 'curation' ? listing.petSizes : null, + conditions: + listing.petOrigin === 'curation' + ? (curation?.petConditions ?? null) + : listing.petOrigin === 'public' + ? publicPetNote(place.publicPet) + : null, + }, + extra: place.publicExtra, + curation: listing.isHomePick + ? { + reason: curation?.reasonKo ?? null, + officialUrl: curation?.officialUrl ?? null, + } + : null, + attribution: { + provider: PROVIDER_LABEL[place.source], + asOf: + place.source === 'tour' + ? place.sourceModifiedAt + ? new Date(place.sourceModifiedAt.getTime() + KST_OFFSET_MS) + .toISOString() + .slice(0, 10) + : null + : place.sourceSnapshot, + }, + }; + } + + private toListItem( + v: PlaceListing, + distance: number | null, + clock: { weekday: number; minutes: number }, + ): PlaceListItemDto { + return { + id: v.id, + name: v.name, + branchName: v.branchName, + category: v.category, + source: v.source, + address: v.addressRoad, + lat: v.lat, + lng: v.lng, + distanceM: + distance === null || !Number.isFinite(distance) + ? null + : Math.round(distance), + imageThumbUrl: v.imageThumbUrl, + imageLicense: v.imageLicense, + hoursOrigin: v.hoursOrigin, + openNow: v.hours ? isOpenAt(v.hours, clock.weekday, clock.minutes) : null, + petPolicy: v.petPolicy, + petOrigin: v.petOrigin, + setting: v.setting, + verifiedAt: v.verifiedAt, + }; + } +} diff --git a/backend/src/modules/place/place.sql.spec.ts b/backend/src/modules/place/place.sql.spec.ts new file mode 100644 index 0000000..3b25030 --- /dev/null +++ b/backend/src/modules/place/place.sql.spec.ts @@ -0,0 +1,13 @@ +import { boundingBox } from './place.sql'; + +describe('boundingBox', () => { + it('반경을 담는 위도·경도 범위를 만든다(대구 위도에서 경도 폭이 더 넓다)', () => { + const box = boundingBox(35.865, 128.5966, 1500); + const dLat = box.latMax - box.latMin; + const dLng = box.lngMax - box.lngMin; + expect(dLat).toBeCloseTo((2 * 1500) / 111_320, 6); + expect(dLng).toBeGreaterThan(dLat); + expect(box.latMin).toBeLessThan(35.865); + expect(box.lngMax).toBeGreaterThan(128.5966); + }); +}); diff --git a/backend/src/modules/place/place.sql.ts b/backend/src/modules/place/place.sql.ts new file mode 100644 index 0000000..64f6274 --- /dev/null +++ b/backend/src/modules/place/place.sql.ts @@ -0,0 +1,41 @@ +/** + * 장소 조회에 쓰는 SQL 조각. 인자는 SQL 식(컬럼 이름·바인딩 파라미터)이며 사용자 입력을 직접 넣지 않는다. + */ + +/** 위도 1도의 거리(m) */ +export const METERS_PER_LAT_DEGREE = 111_320; + +/** 두 좌표 사이 거리(m) — 하버사인. 부동소수점 오차로 asin 범위를 넘지 않게 1 로 자른다 */ +export function sqlDistanceM( + lat1: string, + lng1: string, + lat2: string, + lng2: string, +): string { + return ( + `(2 * 6371000 * asin(LEAST(1, sqrt(` + + `power(sin(radians(${lat2} - ${lat1}) / 2), 2) + ` + + `cos(radians(${lat1})) * cos(radians(${lat2})) * power(sin(radians(${lng2} - ${lng1}) / 2), 2)` + + `))))` + ); +} + +/** + * 반경 검색 1차 거르기용 사각형(위도·경도 범위). 인덱스(lat, lng)를 타게 한 뒤 정확한 거리로 다시 거른다. + */ +export function boundingBox( + lat: number, + lng: number, + radiusM: number, +): { latMin: number; latMax: number; lngMin: number; lngMax: number } { + const dLat = radiusM / METERS_PER_LAT_DEGREE; + const dLng = + radiusM / + (METERS_PER_LAT_DEGREE * Math.max(Math.cos((lat * Math.PI) / 180), 0.01)); + return { + latMin: lat - dLat, + latMax: lat + dLat, + lngMin: lng - dLng, + lngMax: lng + dLng, + }; +} diff --git a/backend/src/modules/place/shop-name.spec.ts b/backend/src/modules/place/shop-name.spec.ts new file mode 100644 index 0000000..112f7c4 --- /dev/null +++ b/backend/src/modules/place/shop-name.spec.ts @@ -0,0 +1,38 @@ +import { isSameShopName, normalizeShopName } from './shop-name'; + +// 2026-11-02 대구 적재 데이터에서 관광공사·상가정보가 겹친 실제 사례 +describe('isSameShopName', () => { + it('띄어쓰기·괄호·영문 병기가 달라도 같은 가게로 본다', () => { + expect(isSameShopName('커피명가 라핀카', '커피명가라핀카')).toBe(true); + expect(isSameShopName('아눅 앞산', '아눅앞산')).toBe(true); + expect( + isSameShopName( + '낫온리커피어반델리(NOT ONLY COFFEE URBAN DELI)', + '낫온리커피어반델리', + ), + ).toBe(true); + }); + + it('한쪽이 다른 쪽을 포함하면(두 글자 이상) 같은 가게로 본다', () => { + expect(isSameShopName('카페 접속', '접속')).toBe(true); + expect(isSameShopName('롤러커피', '롤러커피롤러커피플러스')).toBe(true); + expect(isSameShopName('원조아삼겹 본점', '원조아삼겹')).toBe(true); + }); + + it('한 글자 상호는 완전히 같을 때만 같은 가게다', () => { + expect(isSameShopName('류', '류')).toBe(true); + expect(isSameShopName('올리브영 대구동성로광장점', '동')).toBe(false); + expect(isSameShopName('정호승 문학관', '문')).toBe(false); + }); + + it('이름이 전혀 다르면 다른 가게다', () => { + expect(isSameShopName('김진수로스터리카페', '오마모리')).toBe(false); + expect(isSameShopName('', '오마모리')).toBe(false); + }); +}); + +describe('normalizeShopName', () => { + it('소문자 한글·영문·숫자만 남긴다', () => { + expect(normalizeShopName('Cafe 1979 (교동)')).toBe('cafe1979교동'); + }); +}); diff --git a/backend/src/modules/place/shop-name.ts b/backend/src/modules/place/shop-name.ts new file mode 100644 index 0000000..ec72194 --- /dev/null +++ b/backend/src/modules/place/shop-name.ts @@ -0,0 +1,26 @@ +/** + * 상호 비교 (순수 함수) — 같은 가게를 관광공사·상가정보가 둘 다 가지고 있는지 판단할 때 쓴다. + */ + +/** 비교용 정규화: 소문자로, 한글·영문·숫자만 남긴다(공백·괄호·기호 제거) */ +export function normalizeShopName(name: string): string { + return name.toLowerCase().replace(/[^0-9a-z가-힣]/g, ''); +} + +/** 한쪽이 다른 쪽을 포함할 때, 짧은 쪽이 이 글자 수 이상이어야 같은 가게로 본다(한 글자 상호 '동'·'문' 오탐 방지) */ +const MIN_CONTAINED_LENGTH = 2; + +/** + * 두 상호가 같은 가게로 보이는지. + * - 정규화한 이름이 같으면 같다 ('커피명가 라핀카' = '커피명가라핀카', '류' = '류') + * - 한쪽이 다른 쪽을 포함하고 짧은 쪽이 2글자 이상이면 같다 ('카페 접속' ⊃ '접속', '롤러커피롤러커피플러스' ⊃ '롤러커피') + * 거리·분류 조건은 호출하는 쪽에서 따로 건다(이름만으로는 '이디야커피 국립대구박물관점' ⊃ '국립대구박물관'도 같다고 나온다). + */ +export function isSameShopName(a: string, b: string): boolean { + const x = normalizeShopName(a); + const y = normalizeShopName(b); + if (x === '' || y === '') return false; + if (x === y) return true; + const [short, long] = x.length <= y.length ? [x, y] : [y, x]; + return short.length >= MIN_CONTAINED_LENGTH && long.includes(short); +} diff --git a/backend/src/modules/place/sync/place-dedupe.ts b/backend/src/modules/place/sync/place-dedupe.ts new file mode 100644 index 0000000..d930a40 --- /dev/null +++ b/backend/src/modules/place/sync/place-dedupe.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Place } from '../entities/place.entity'; +import { + MERGE_MAX_DISTANCE_M, + MERGE_TARGET_CATEGORIES, + sqlValues, +} from '../place.constants'; +import { METERS_PER_LAT_DEGREE, sqlDistanceM } from '../place.sql'; +import { isSameShopName } from '../shop-name'; + +/** 1차 거르기 사각형 — 한국 최북단 위도(약 39.5°)에서도 반경을 담도록 경도 폭을 넉넉히 잡는다 */ +const BOX_LAT = MERGE_MAX_DISTANCE_M / METERS_PER_LAT_DEGREE; +const BOX_LNG = + MERGE_MAX_DISTANCE_M / + (METERS_PER_LAT_DEGREE * Math.cos((39.5 * Math.PI) / 180)); + +interface CandidatePair { + store_id: string; + store_name: string; + tour_id: string; + tour_name: string; + distance_m: number; +} + +export interface DedupeResult { + candidates: number; + merged: number; +} + +/** + * 같은 가게를 관광공사·상가정보가 둘 다 가진 경우, 상가정보 쪽을 관광공사 쪽에 병합한다(merged_into_id). + * 관광공사 쪽에 영업시간·사진·소개가 있으므로 그쪽을 남긴다. + * 규칙: 관광공사 쪽이 카페·식당·주점이고, 100m 안이며, 상호가 같거나 한쪽이 다른 쪽을 포함(2글자 이상). + * 매번 전부 다시 계산한다(관광공사 장소가 숨겨지거나 이름이 바뀌면 병합도 풀린다). + */ +@Injectable() +export class PlaceDedupe { + private readonly logger = new Logger(PlaceDedupe.name); + + constructor( + @InjectRepository(Place) private readonly places: Repository, + ) {} + + async run(): Promise { + const dist = sqlDistanceM('t.lat', 't.lng', 's.lat', 's.lng'); + const pairs = await this.places.manager.query( + `SELECT s.id AS store_id, s.name AS store_name, t.id AS tour_id, t.name AS tour_name, ${dist} AS distance_m + FROM place s + JOIN place t + ON t.source = 'tour' AND t.status = 'active' + AND t.category IN (${sqlValues(MERGE_TARGET_CATEGORIES)}) + AND abs(s.lat - t.lat) < $1 AND abs(s.lng - t.lng) < $2 + WHERE s.source = 'store' AND s.status = 'active' AND ${dist} <= $3`, + [BOX_LAT, BOX_LNG, MERGE_MAX_DISTANCE_M], + ); + + // 상가정보 업소마다 상호가 같은 가장 가까운 관광공사 장소 하나 + const best = new Map(); + for (const p of pairs) { + if (!isSameShopName(p.store_name, p.tour_name)) continue; + const prev = best.get(p.store_id); + const distance = Number(p.distance_m); + if (!prev || distance < prev.distance) { + best.set(p.store_id, { tourId: p.tour_id, distance }); + } + } + + await this.places.manager.transaction(async (tx) => { + // 원본 updated_at 을 건드리지 않도록 직접 UPDATE 한다 + await tx.query( + `UPDATE place SET merged_into_id = NULL WHERE source = 'store' AND merged_into_id IS NOT NULL`, + ); + for (const [storeId, { tourId }] of best) { + await tx.query(`UPDATE place SET merged_into_id = $1 WHERE id = $2`, [ + tourId, + storeId, + ]); + } + }); + + this.logger.log( + `중복 병합: 후보 ${pairs.length}쌍 중 같은 가게 ${best.size}곳을 관광공사 장소에 병합`, + ); + return { candidates: pairs.length, merged: best.size }; + } +} diff --git a/backend/src/modules/place/sync/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts index 29469a2..6e77911 100644 --- a/backend/src/modules/place/sync/place-sync.module.ts +++ b/backend/src/modules/place/sync/place-sync.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Place } from '../entities/place.entity'; import { PlaceSyncRun } from '../entities/place-sync-run.entity'; +import { PlaceDedupe } from './place-dedupe'; import { PlaceSyncStatus } from './place-sync-status'; import { SyncRunRecorder } from './sync-run.recorder'; import { PublicDataApiFactory } from './public-data.factory'; @@ -22,8 +23,9 @@ import { TourPetSync } from './tour-pet.sync'; TourKorSync, TourPetSync, StoreSync, + PlaceDedupe, PlaceSyncStatus, ], - exports: [TourKorSync, TourPetSync, StoreSync, PlaceSyncStatus], + exports: [TourKorSync, TourPetSync, StoreSync, PlaceDedupe, PlaceSyncStatus], }) export class PlaceSyncModule {} diff --git a/backend/src/modules/place/sync/store.sync.ts b/backend/src/modules/place/sync/store.sync.ts index f80b1bc..603e0bc 100644 --- a/backend/src/modules/place/sync/store.sync.ts +++ b/backend/src/modules/place/sync/store.sync.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { Place } from '../entities/place.entity'; import { BudgetExceededError, CallBudget } from './call-budget'; +import { PlaceDedupe } from './place-dedupe'; import { PublicDataApiFactory } from './public-data.factory'; import type { StoreItem } from './store-api.client'; import { @@ -56,6 +57,8 @@ export interface StoreSyncResult { inserted: number; updated: number; gone: number; + /** 관광공사 장소에 병합된(같은 가게로 본) 업소 수 */ + merged: number; skipped: Partial>; snapshot: string | null; groups: StoreGroupResult[]; @@ -76,6 +79,7 @@ export class StoreSync { @InjectRepository(Place) private readonly places: Repository, private readonly recorder: SyncRunRecorder, private readonly apiFactory: PublicDataApiFactory, + private readonly dedupe: PlaceDedupe, ) {} async run(options: { signguCodes: string[] }): Promise { @@ -93,6 +97,7 @@ export class StoreSync { inserted: 0, updated: 0, gone: 0, + merged: 0, skipped: {}, snapshot: null, groups: [], @@ -185,6 +190,7 @@ export class StoreSync { } await this.upsert([...rows.values()]); await this.markGone(existing, result, rowsPerGroup, runStart); + result.merged = (await this.dedupe.run()).merged; } catch (error) { result.errors.unshift( error instanceof Error ? error.message : String(error), @@ -269,7 +275,7 @@ export class StoreSync { .map(([k, v]) => `${k} ${v}`) .join(', '); this.logger.log( - `상가정보(기준 ${r.snapshot ?? '?'}) ${r.fetched}건 → 새 업소 ${r.inserted} · 갱신 ${r.updated} · 폐업 추정 ${r.gone}` + + `상가정보(기준 ${r.snapshot ?? '?'}) ${r.fetched}건 → 새 업소 ${r.inserted} · 갱신 ${r.updated} · 폐업 추정 ${r.gone} · 중복 병합 ${r.merged}` + (skipped ? ` · 건너뜀(${skipped})` : ''), ); const incomplete = r.groups.filter((g) => !g.complete); diff --git a/backend/src/modules/place/sync/tour-kor.sync.ts b/backend/src/modules/place/sync/tour-kor.sync.ts index 830b97c..688df15 100644 --- a/backend/src/modules/place/sync/tour-kor.sync.ts +++ b/backend/src/modules/place/sync/tour-kor.sync.ts @@ -5,6 +5,7 @@ import { IsNull, Repository } from 'typeorm'; import { Place } from '../entities/place.entity'; import type { PlaceCategory } from '../place.constants'; import { BudgetExceededError, CallBudget } from './call-budget'; +import { PlaceDedupe } from './place-dedupe'; import { SyncRunRecorder } from './sync-run.recorder'; import type { TourApi, TourItem } from './tour-api.client'; import { PublicDataApiFactory } from './public-data.factory'; @@ -79,6 +80,8 @@ export interface TourKorResult { detailed: number; detailFailed: number; detailRemaining: number; + /** 이 장소들에 병합된 상가정보 업소 수 */ + merged: number; stoppedByBudget: boolean; calls: Record; errors: string[]; @@ -98,6 +101,7 @@ export class TourKorSync { @InjectRepository(Place) private readonly places: Repository, private readonly recorder: SyncRunRecorder, private readonly apiFactory: PublicDataApiFactory, + private readonly dedupe: PlaceDedupe, ) {} async run(options: TourKorOptions): Promise { @@ -118,6 +122,7 @@ export class TourKorSync { detailed: 0, detailFailed: 0, detailRemaining: 0, + merged: 0, stoppedByBudget: false, calls: {}, errors: [], @@ -128,6 +133,7 @@ export class TourKorSync { if (!options.skipDetails && !result.stoppedByBudget) { await this.syncDetails(api, budget, regionCode, result); } + result.merged = (await this.dedupe.run()).merged; result.detailRemaining = await this.places.countBy({ source: 'tour', ldongRegnCd: regionCode, diff --git a/backend/src/shared/utils/kst.spec.ts b/backend/src/shared/utils/kst.spec.ts index b015f52..d35ead0 100644 --- a/backend/src/shared/utils/kst.spec.ts +++ b/backend/src/shared/utils/kst.spec.ts @@ -1,4 +1,4 @@ -import { kstStartOfDay, parseKstTimestamp } from './kst'; +import { kstClock, kstStartOfDay, parseKstTimestamp } from './kst'; describe('kstStartOfDay', () => { it('한국 날짜의 0시를 돌려준다', () => { @@ -36,3 +36,13 @@ describe('parseKstTimestamp', () => { expect(parseKstTimestamp(null)).toBeNull(); }); }); + +describe('kstClock', () => { + it('한국 시간의 요일과 자정 이후 분을 돌려준다', () => { + // 2026-11-01(일) 15:30 UTC = 2026-11-02(월) 00:30 KST + expect(kstClock(new Date('2026-11-01T15:30:00Z'))).toEqual({ + weekday: 1, + minutes: 30, + }); + }); +}); diff --git a/backend/src/shared/utils/kst.ts b/backend/src/shared/utils/kst.ts index 302ec38..5829223 100644 --- a/backend/src/shared/utils/kst.ts +++ b/backend/src/shared/utils/kst.ts @@ -34,3 +34,12 @@ export function parseKstTimestamp(value: unknown): Date | null { const back = new Date(date.getTime() + KST_OFFSET_MS); return back.getUTCDate() === d ? date : null; } + +/** 한국 시간 기준 요일(0=일요일 … 6=토요일)과 자정 이후 분 — "지금 영업 중" 판단에 쓴다 */ +export function kstClock(now: Date): { weekday: number; minutes: number } { + const kst = new Date(now.getTime() + KST_OFFSET_MS); + return { + weekday: kst.getUTCDay(), + minutes: kst.getUTCHours() * 60 + kst.getUTCMinutes(), + }; +} diff --git a/backend/src/shared/utils/sql-like.spec.ts b/backend/src/shared/utils/sql-like.spec.ts new file mode 100644 index 0000000..d360782 --- /dev/null +++ b/backend/src/shared/utils/sql-like.spec.ts @@ -0,0 +1,11 @@ +import { escapeLike } from './sql-like'; + +describe('escapeLike', () => { + it('와일드카드와 이스케이프 문자를 글자 그대로 찾게 한다', () => { + expect(escapeLike('100%_카페\\')).toBe('100\\%\\_카페\\\\'); + }); + + it('일반 글자는 그대로 둔다', () => { + expect(escapeLike('김광석 길')).toBe('김광석 길'); + }); +}); diff --git a/backend/src/shared/utils/sql-like.ts b/backend/src/shared/utils/sql-like.ts new file mode 100644 index 0000000..958f46c --- /dev/null +++ b/backend/src/shared/utils/sql-like.ts @@ -0,0 +1,7 @@ +/** + * LIKE/ILIKE 패턴에 넣을 사용자 입력을 글자 그대로 찾게 한다 (순수 함수). + * `%`·`_`(와일드카드)와 `\`(PostgreSQL 기본 이스케이프 문자)를 이스케이프한다. 값은 반드시 바인딩 파라미터로 넘긴다. + */ +export function escapeLike(input: string): string { + return input.replace(/[\\%_]/g, (c) => `\\${c}`); +} diff --git a/backend/test/place.e2e-spec.ts b/backend/test/place.e2e-spec.ts new file mode 100644 index 0000000..882eff3 --- /dev/null +++ b/backend/test/place.e2e-spec.ts @@ -0,0 +1,313 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import type { Response } from 'supertest'; +import { App } from 'supertest/types'; +import { DataSource } from 'typeorm'; + +import { AppModule } from '@/app.module'; +import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; +import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; +import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; +import { PlaceCuration } from '@/modules/place/entities/place-curation.entity'; +import { Place } from '@/modules/place/entities/place.entity'; +import { parseOpeningHours } from '@/shared/utils/opening-hours'; + +/** + * 장소 조회 API 계약 — 노출 규칙(병합·숨김·브랜드 매장)·필터·거리순 커서·상세를 실제 DB 로 검증한다. + * 시작할 때 마이그레이션을 적용하므로 빈 DB(CI)에서도 돈다. 테스트 장소는 실제 데이터와 섞이지 않게 + * 동해 바다 위 좌표에 만들고(source_id 'e2e-'), 시작·끝에 지운다. + */ +const C = { lat: 36.5, lng: 130.5 }; +const at = (dLat: number) => ({ lat: C.lat + dLat, lng: C.lng }); +const near = `lat=${C.lat}&lng=${C.lng}&radius=2000`; + +interface ListItem { + id: string; + name: string; + distanceM: number | null; + openNow: boolean | null; + hoursOrigin: string; + petPolicy: string; +} +interface ListBody { + success: boolean; + data: { + items: ListItem[]; + pageInfo: { + hasNext: boolean; + nextCursor: string | null; + total: number | null; + }; + }; +} +interface DetailBody { + success: boolean; + data: { + id: string; + name: string; + hoursOrigin: string; + setting: string | null; + hours: { text: string | null; status: string }; + pet: { sizes: string[] | null; conditions: string | null }; + curation: { reason: string | null } | null; + attribution: { provider: string; asOf: string | null }; + }; + error?: { code: string }; +} +const list = (res: Response) => res.body as ListBody; +const detail = (res: Response) => res.body as DetailBody; +const names = (res: Response) => list(res).data.items.map((i) => i.name); + +describe('장소 조회 API (e2e)', () => { + let app: INestApplication; + let ds: DataSource; + const ids: Record = {}; + + async function cleanup(): Promise { + await ds.query( + `UPDATE place SET merged_into_id = NULL WHERE source_id LIKE 'e2e-%'`, + ); + await ds.query( + `DELETE FROM place_curation WHERE place_id IN (SELECT id FROM place WHERE source_id LIKE 'e2e-%')`, + ); + await ds.query(`DELETE FROM place WHERE source_id LIKE 'e2e-%'`); + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); + await app.init(); + + ds = app.get(DataSource); + await ds.runMigrations(); + await cleanup(); + + const places = ds.getRepository(Place); + const save = async (key: string, p: Partial) => { + const saved = await places.save(places.create(p)); + ids[key] = saved.id; + return saved; + }; + await save('T1', { + source: 'tour', + sourceId: 'e2e-T1', + category: 'attraction', + name: '이투이 근대골목', + ...at(0.001), + publicHoursText: '상시 개방', + publicHours: parseOpeningHours('상시 개방'), + publicPet: { acmpyTypeCd: '전구역 동반가능', acmpyNeedMtr: '목줄 착용' }, + publicPetPolicy: 'allowed', + sourceModifiedAt: new Date('2026-10-15T00:30:00Z'), + }); + await save('M1', { + source: 'tour', + sourceId: 'e2e-M1', + category: 'shopping', + sourceCategory: 'SH040300', + name: '더현대 이투이점', + ...at(0.0018), + }); + const t2 = await save('T2', { + source: 'tour', + sourceId: 'e2e-T2', + category: 'cafe', + name: '이투이카페', + ...at(0.002), + publicHoursText: '10:00~22:00', + publicHours: parseOpeningHours('10:00~22:00', '연중무휴'), + }); + await save('S1', { + source: 'store', + sourceId: 'e2e-S1', + category: 'cafe', + name: '이투이카페', + ...at(0.00201), + mergedIntoId: t2.id, + }); + await save('S2', { + source: 'store', + sourceId: 'e2e-S2', + category: 'cafe', + name: '이투이 골목카페', + ...at(0.003), + sourceSnapshot: '202609', + }); + await save('B1', { + source: 'tour', + sourceId: 'e2e-B1', + category: 'shopping', + sourceCategory: 'SH040300', + name: '이투이 브랜드매장', + ...at(0.0015), + publicPet: { acmpyTypeCd: '전구역 동반가능' }, + publicPetPolicy: 'allowed', + }); + await save('H1', { + source: 'tour', + sourceId: 'e2e-H1', + category: 'attraction', + name: '이투이 숨김', + ...at(0.0005), + status: 'hidden', + }); + await save('F1', { + source: 'store', + sourceId: 'e2e-F1', + category: 'cafe', + name: '이투이 먼카페', + ...at(0.1), + }); + await ds.getRepository(PlaceCuration).save({ + placeId: ids.S2, + verdict: 'recommend', + reasonKo: '골목 끝 조용한 2층 카페', + hoursText: '11:00~20:00', + hours: parseOpeningHours('11:00~20:00', '연중무휴'), + petPolicy: 'partial', + petSizes: ['small'], + petConditions: '테라스석만', + setting: 'indoor', + verifiedMethod: 'phone', + verifiedAt: '2026-11-02', + }); + }); + + afterAll(async () => { + await cleanup(); + await app.close(); + }); + + const get = (path: string) => request(app.getHttpServer()).get(path); + + describe('GET /api/places', () => { + it('반경 안을 거리순으로 주고, 숨김·병합·브랜드 매장·반경 밖은 뺀다', async () => { + const res = await get(`/api/places?${near}`).expect(200); + expect(names(res)).toEqual([ + '이투이 근대골목', + '더현대 이투이점', // 쇼핑몰 자체는 브랜드 매장이 아니다 + '이투이카페', // 관광공사 쪽만 남고 같은 가게의 상가정보는 병합되어 빠진다 + '이투이 골목카페', + ]); + expect(list(res).data.pageInfo.total).toBe(4); + const first = list(res).data.items[0]; + expect(first.distanceM).toBeGreaterThan(100); + expect(first.distanceM).toBeLessThan(120); + expect(first.openNow).toBe(true); // 상시 개방 + }); + + it('커서로 이어 받으면 겹치거나 빠지는 장소가 없다', async () => { + const p1 = await get(`/api/places?${near}&limit=2`).expect(200); + expect(names(p1)).toEqual(['이투이 근대골목', '더현대 이투이점']); + expect(list(p1).data.pageInfo.hasNext).toBe(true); + const cursor = list(p1).data.pageInfo.nextCursor ?? ''; + const p2 = await get( + `/api/places?${near}&limit=2&cursor=${encodeURIComponent(cursor)}`, + ).expect(200); + expect(names(p2)).toEqual(['이투이카페', '이투이 골목카페']); + expect(list(p2).data.pageInfo).toMatchObject({ + hasNext: false, + nextCursor: null, + }); + }); + + it('반려동물 필터는 동반 가능 장소만 주고 브랜드 매장은 빼다', async () => { + const res = await get(`/api/places?${near}&pet=true`).expect(200); + expect(names(res)).toEqual(['이투이 근대골목', '이투이 골목카페']); + }); + + it('이름 검색이면 브랜드 매장도 나온다', async () => { + const res = await get( + `/api/places?${near}&q=${encodeURIComponent('브랜드매장')}`, + ).expect(200); + expect(names(res)).toEqual(['이투이 브랜드매장']); + }); + + it('실내 필터는 운영자가 실내로 확인한 장소만 준다', async () => { + const res = await get(`/api/places?${near}&indoor=true`).expect(200); + expect(names(res)).toEqual(['이투이 골목카페']); + }); + + it('분류 필터', async () => { + const res = await get(`/api/places?${near}&category=cafe`).expect(200); + expect(names(res)).toEqual(['이투이카페', '이투이 골목카페']); + }); + + it('좌표 없이 이름 검색하면 이름순으로 반경 제한 없이 준다', async () => { + const res = await get( + `/api/places?q=${encodeURIComponent('이투이')}`, + ).expect(200); + const found = names(res); + expect(found).toContain('이투이 먼카페'); + expect(found).toContain('이투이 브랜드매장'); + expect(found).not.toContain('이투이 숨김'); + expect(found.filter((n) => n === '이투이카페')).toHaveLength(1); + expect([...found].sort()).toEqual(found); + }); + + it.each([ + ['lat 만 보낸 경우', 'lat=36.5'], + ['반경이 너무 작은 경우', `${near.replace('radius=2000', 'radius=5')}`], + ['없는 분류', 'category=pub'], + ['형식이 틀린 커서', `${near}&cursor=abc`], + ['true/false 가 아닌 필터', 'pet=yes'], + ])('%s 는 VAL_001(400)', async (_, query) => { + const res = await get(`/api/places?${query}`).expect(400); + expect(detail(res).error?.code).toBe('VAL_001'); + }); + }); + + describe('GET /api/places/:id', () => { + it('공공데이터 장소 상세 — 영업시간·반려동물 조건·출처', async () => { + const res = await get(`/api/places/${ids.T1}`).expect(200); + const d = detail(res).data; + expect(d.hoursOrigin).toBe('public'); + expect(d.hours.status).toBe('always_open'); + expect(d.pet.conditions).toBe('목줄 착용'); + expect(d.attribution).toEqual({ + provider: '한국관광공사', + asOf: '2026-10-15', + }); + expect(d.curation).toBeNull(); + }); + + it('큐레이션한 장소는 확인한 값과 추천 이유를 준다', async () => { + const res = await get(`/api/places/${ids.S2}`).expect(200); + const d = detail(res).data; + expect(d.hoursOrigin).toBe('curation'); + expect(d.hours.text).toBe('11:00~20:00'); + expect(d.pet).toEqual({ sizes: ['small'], conditions: '테라스석만' }); + expect(d.setting).toBe('indoor'); + expect(d.curation?.reason).toBe('골목 끝 조용한 2층 카페'); + expect(d.attribution.asOf).toBe('202609'); + }); + + it('병합된 장소 id 로 들어오면 병합 대상 장소를 준다', async () => { + const res = await get(`/api/places/${ids.S1}`).expect(200); + expect(detail(res).data.id).toBe(ids.T2); + }); + + it('숨김 장소·없는 id 는 RES_001(404), UUID 가 아니면 VAL_001(400)', async () => { + const hidden = await get(`/api/places/${ids.H1}`).expect(404); + expect(detail(hidden).error?.code).toBe('RES_001'); + await get('/api/places/00000000-0000-4000-8000-000000000000').expect(404); + const bad = await get('/api/places/abc').expect(400); + expect(detail(bad).error?.code).toBe('VAL_001'); + }); + }); +}); diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 1571612..ed1ea6f 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -1,7 +1,7 @@ -# 장소 DB 설계 (v1.3, 2026-11-02) +# 장소 DB 설계 (v1.4, 2026-11-02) WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다. -1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`. +1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령), 중복 병합, 조회 API(§11)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`. 이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다. ## 0. 전제 (이미 정한 것) @@ -106,6 +106,7 @@ erDiagram | `source_modified_at` | timestamptz | 관광공사 modifiedtime | | `source_snapshot` | text | 상가정보 기준년월(stdrYm) | | `last_seen_at` | timestamptz | 마지막 동기화에서 확인된 시각 | +| `merged_into_id` | uuid | 같은 가게를 다른 출처가 이미 가지고 있으면 그 장소 id(상가정보 → 관광공사). 값이 있으면 검색·추천에 나오지 않는다 — §4.2 | | `detail_synced_at` | timestamptz | 상세(개요·영업시간)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다 — 하루 한도로 나뉜 적재를 이어서 받는 기준 | | `created_at`, `updated_at` | timestamptz | | @@ -210,7 +211,8 @@ erDiagram | 플래그 | 조건 | 쓰는 곳 | | --- | --- | --- | -| `is_searchable` | `status = active` | 검색·지도·상세 | +| `is_searchable` | `status = active` 이고 `merged_into_id` 없음 | 검색·지도·상세 | +| `is_brand_store` | 사후면세점(`SH040300`)이고 백화점·쇼핑몰 자체가 아님 | 목록·필터에서 빼고 이름 검색(q)에만 낸다 | | `is_home_pick` | `verdict = recommend` **이고 `verified_at` 있음** | 홈 동네 추천 | | `ai_tier` | 1: `verdict = recommend` 이고 `verified_at` 있음 / 2: `source = tour` 이고 코스 분류(숙박·쇼핑 매장 제외)이며 `verdict` 가 `not_recommend` 가 아님 / null: 그 외(**상가정보 미확인 매장 포함**) | AI 후보 조회 | @@ -246,6 +248,7 @@ dc exec backend npm run migration:run # 스키마 먼저 dc exec backend npm run place:sync -- tour-kor # 대구(27) 목록 → 상세. 한도에 닿으면 멈춘다 dc exec backend npm run place:sync -- tour-pet # 반려동물 동반 정보(약 10회) dc exec backend npm run place:sync -- store # 상가정보 데이트 업종(대구 중구·남구·수성구, 30회 안팎). 분기마다 +dc exec backend npm run place:sync -- dedupe # 같은 가게 병합(키 불필요). tour-kor·store 가 끝날 때도 자동으로 돈다 dc exec backend npm run place:sync -- status # 적재 현황·오늘 쓴 호출 수 # 다음 날 같은 tour-kor 명령을 다시 실행하면 남은 상세를 이어서 받는다 @@ -264,6 +267,18 @@ node dist/commands/place-sync.js tour-kor - 2026-11-02 임시 DB + 가짜 상가정보 API 로 확인: 첫 적재 → 다음 분기 폐업 추정·업종 변경·신규 → 급감 시 폐업 처리 건너뜀 → 한도 0 이면 아무것도 바꾸지 않음. - 2026-11-02 임시 DB + 가짜 API 로 확인한 흐름: 한도 3회로 멈춤 → 같은 날 재실행 시 추가 호출 없음 → 다음 날 이어받기 → 수정 1곳만 재조회·사라진 1곳 숨김 → 반려동물 반영. +### 4.2 중복 병합 (같은 가게) + +같은 가게를 관광공사·상가정보가 둘 다 가진 경우(2026-11-02 대구 약 21곳) 상가정보 쪽 `merged_into_id` 에 관광공사 장소 id 를 넣는다. +관광공사 쪽에 영업시간·사진·소개가 있어서 그쪽을 남긴다. 규칙(`modules/place/shop-name.ts`, `sync/place-dedupe.ts`): + +- 관광공사 쪽이 카페·식당·주점일 때만 — 박물관·공원·경기장 안의 카페("이디야커피 국립대구박물관점")를 그 시설과 합치지 않는다 +- 100m 안 +- 상호가 같거나(공백·괄호·기호 무시), 한쪽이 다른 쪽을 포함하고 짧은 쪽이 2글자 이상 — 한 글자 상호 '동'·'문' 오탐 방지 +- 후보가 여럿이면 가장 가까운 곳. 매번 전부 다시 계산한다(관광공사 장소가 숨겨지면 병합도 풀린다) +- 상세 API 는 병합된 id 로 들어와도 병합 대상 장소를 보여 준다(공유 링크가 깨지지 않게) +- ⚠️ 큐레이션 가져오기(4단계)는 병합된 상가정보 행을 가리키는 큐레이션을 병합 대상 장소로 옮겨야 한다 + ## 5. 위치 검색 - `lat`·`lng` double precision + 인덱스. 조회는 "경계 사각형으로 1차 필터 → 하버사인 거리로 정렬". @@ -311,5 +326,20 @@ node dist/commands/place-sync.js tour-kor 2. ✅ 관광공사 국문 적재 명령(대구) + 반려동물 동기화 + 현황 명령 (2026-11-02, 실제 적재는 개발 계정으로 2일) 3. ✅ 상가정보 적재 명령(대구 중구·남구·수성구 데이트 업종) (2026-11-02) 4. 큐레이션 CSV 가져오기 명령(미리보기 → 반영) -5. 조회 API: `GET /api/places`(검색·필터·커서 페이지네이션), `GET /api/places/{id}`, `GET /api/events` +5. ✅ 조회 API: `GET /api/places`(검색·근처·필터·커서), `GET /api/places/{id}` + 중복 병합 (2026-11-02). `GET /api/events` 는 축제 동기화와 함께 6. 일·주·분기 동기화 + 폐업 대조 + +## 11. 조회 API (구현됨) + +비로그인으로 쓴다(둘러보기·검색 비로그인 허용 합의). 노출 규칙은 `place_listing` 뷰가 정하고 API 는 읽기만 한다. + +| 엔드포인트 | 내용 | +| --- | --- | +| `GET /api/places` | `q`(이름) · `lat`+`lng`+`radius`(기본 1,500m, 100~20,000) · `category`(쉼표 구분) · `pet` · `indoor` · `cursor` · `limit`. 좌표가 있으면 거리순, 없으면 이름순. 키셋 커서(정렬값+id). 응답 `{items, pageInfo}` 에 `total` 포함 | +| `GET /api/places/{id}` | 상세 — 영업시간(원문·해석 상태·출처), 반려동물(크기·조건), 이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기. 병합된 id 면 대상 장소, 숨김·폐업 추정이면 RES_001 | + +- 목록 항목의 `openNow` 는 한국 시간 기준 영업 중 여부다. 알 수 없으면 null → 화면은 "영업시간 미확인 · 방문 전 확인". +- 사후면세점 브랜드 매장은 이름 검색일 때만 나온다(반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제). +- 실패 코드: 검색 조건·커서·id 형식 오류 VAL_001(400), 없음 RES_001(404). 새 코드는 만들지 않았다. +- 계약 테스트: `backend/test/place.e2e-spec.ts` — 시작할 때 마이그레이션을 적용하고 동해 좌표에 테스트 장소를 만들어 검증한다(16개). +- 아직 없는 것: 영어 등 다국어 응답(`place_i18n`), 지도 화면용 사각형(bbox) 조회, 축제 API.