feat(backend): 장소·축제·공연·전시 데이터 기반(장소 DB·동기화·조회 API) #1

Merged
ttipo merged 30 commits from feat/place-db into main 2026-11-04 13:25:51 +09:00
29 changed files with 1604 additions and 15 deletions
Showing only changes of commit df48ed78eb - Show all commits
+5 -1
View File
@@ -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<void> {
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);
}
@@ -0,0 +1,123 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlaceMergeAndListingFlags1793606583333 implements MigrationInterface {
name = 'AddPlaceMergeAndListingFlags1793606583333';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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",
],
);
}
}
@@ -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<string, string> | null;
@ApiProperty({
type: PlaceCurationInfoDto,
nullable: true,
description: '운영자 추천 정보 — 추천(O)하고 확인한 장소만',
})
curation!: PlaceCurationInfoDto | null;
@ApiProperty({ type: PlaceAttributionDto })
attribution!: PlaceAttributionDto;
}
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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<Place> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@@ -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();
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* 관광공사 반려동물 동반 정보 원문 → 화면에 보여 줄 조건 문장 (순수 함수).
* 원문에는 글머리표('- ')와 줄바꿈이 섞여 온다(예: "- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참").
*/
/** 조건으로 보여 줄 필드(동반 구분 acmpyTypeCd 는 petPolicy 로 따로 나간다) */
const NOTE_FIELDS = ['acmpyPsblCpam', 'acmpyNeedMtr', 'etcAcmpyInfo'];
/** 줄 앞의 글머리표·번호 표시 */
const BULLET_RE = /^\s*(?:[-*•·※]|\d+[.)])\s*/;
export function publicPetNote(
raw: Record<string, string> | 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;
}
@@ -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();
});
});
+39
View File
@@ -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<string, unknown>;
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;
}
@@ -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 이 깨지므로 즉시 실패시킨다.
@@ -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);
}
}
+6 -2
View File
@@ -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 {}
+238
View File
@@ -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<Place['source'], string> = {
tour: '한국관광공사',
store: '소상공인시장진흥공단 상가(상권)정보',
};
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/**
* 장소 조회 — 검색·근처 목록과 상세. 노출 규칙·값 우선순위는 place_listing 뷰가 정하고, 여기서는 뷰를 읽기만 한다.
*/
@Injectable()
export class PlaceService {
constructor(
@InjectRepository(PlaceListing)
private readonly listings: Repository<PlaceListing>,
@InjectRepository(Place) private readonly places: Repository<Place>,
) {}
/**
* 검색·근처 목록. 좌표가 있으면 반경 안을 거리순으로, 없으면 이름순으로 준다(키셋 커서).
* 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다.
*/
async search(
query: PlaceSearchQueryDto,
now = new Date(),
): Promise<PaginatedResult<PlaceListItemDto>> {
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<PlaceDetailDto> {
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,
};
}
}
@@ -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);
});
});
+41
View File
@@ -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,
};
}
@@ -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교동');
});
});
+26
View File
@@ -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);
}
@@ -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<Place>,
) {}
async run(): Promise<DedupeResult> {
const dist = sqlDistanceM('t.lat', 't.lng', 's.lat', 's.lng');
const pairs = await this.places.manager.query<CandidatePair[]>(
`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<string, { tourId: string; distance: number }>();
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 };
}
}
@@ -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 {}
+7 -1
View File
@@ -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<Record<StoreSkipReason, number>>;
snapshot: string | null;
groups: StoreGroupResult[];
@@ -76,6 +79,7 @@ export class StoreSync {
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
private readonly dedupe: PlaceDedupe,
) {}
async run(options: { signguCodes: string[] }): Promise<StoreSyncResult> {
@@ -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);
@@ -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<string, number>;
errors: string[];
@@ -98,6 +101,7 @@ export class TourKorSync {
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
private readonly dedupe: PlaceDedupe,
) {}
async run(options: TourKorOptions): Promise<TourKorResult> {
@@ -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,
+11 -1
View File
@@ -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,
});
});
});
+9
View File
@@ -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(),
};
}
+11
View File
@@ -0,0 +1,11 @@
import { escapeLike } from './sql-like';
describe('escapeLike', () => {
it('와일드카드와 이스케이프 문자를 글자 그대로 찾게 한다', () => {
expect(escapeLike('100%_카페\\')).toBe('100\\%\\_카페\\\\');
});
it('일반 글자는 그대로 둔다', () => {
expect(escapeLike('김광석 길')).toBe('김광석 길');
});
});
+7
View File
@@ -0,0 +1,7 @@
/**
* LIKE/ILIKE 패턴에 넣을 사용자 입력을 글자 그대로 찾게 한다 (순수 함수).
* `%`·`_`(와일드카드)와 `\`(PostgreSQL 기본 이스케이프 문자)를 이스케이프한다. 값은 반드시 바인딩 파라미터로 넘긴다.
*/
export function escapeLike(input: string): string {
return input.replace(/[\\%_]/g, (c) => `\\${c}`);
}
+313
View File
@@ -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<App>;
let ds: DataSource;
const ids: Record<string, string> = {};
async function cleanup(): Promise<void> {
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<Place>) => {
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');
});
});
});
+34 -4
View File
@@ -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.