feat(backend): 장소 DB 스키마와 마이그레이션 추가

공공데이터 장소(place)와 운영자 큐레이션(place_curation)을 테이블로 나눠
동기화가 사람이 확인한 값을 덮어쓰지 않게 한다. 앱이 보는 값(큐레이션 >
공공 > 미확인)과 노출 규칙은 place_listing 뷰 한 곳에서만 정한다.

- 노출 규칙(2026-11-02 합의): 노출 중인 장소는 모두 검색, 홈 추천은
  추천(O)+확인일, AI 후보는 1순위 큐레이션 추천·2순위 관광공사 장소.
  확인 안 된 상가정보 매장은 AI 후보가 아니다
- 축제·공연·행사(festival_event), 언어별 텍스트(place_i18n),
  동기화 기록(place_sync_run) 포함
- 코드값은 place.constants.ts 하나로 CHECK 제약·뷰가 공유
- 전역 SnakeNamingStrategy(DB snake_case ↔ TS camelCase), uuid 는 pgcrypto

임시 Postgres 15 에서 생성 → 적용 → 재생성 무차이 → revert 를 확인했고,
뷰 규칙은 8가지 경우로 검증했다. 설계: docs/design/place-db.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 14:51:03 +09:00
parent 52de9917e4
commit 2d0d760d98
17 changed files with 1400 additions and 0 deletions
@@ -0,0 +1,105 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import {
EVENT_KINDS,
EVENT_STATUSES,
IMAGE_LICENSES,
sqlIn,
} from '../place.constants';
import type { EventKind, EventStatus, ImageLicense } from '../place.constants';
import { Place } from './place.entity';
/**
* 축제·공연·행사(관광공사 searchFestival2). 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다.
* "60일 넘는 상시 프로그램 제외"는 저장할 때가 아니라 조회할 때 적용한다.
*/
@Entity({ name: 'festival_event' })
@Index('ix_festival_event_period', ['startDate', 'endDate'])
@Index('ix_festival_event_lat_lng', ['lat', 'lng'])
@Check('ck_festival_event_kind', sqlIn('kind', EVENT_KINDS))
@Check('ck_festival_event_status', sqlIn('status', EVENT_STATUSES))
@Check(
'ck_festival_event_image_license',
sqlIn('image_license', IMAGE_LICENSES, true),
)
@Check('ck_festival_event_period', `"end_date" >= "start_date"`)
export class FestivalEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 관광공사 contentid */
@Column({ type: 'text', unique: true })
sourceId!: string;
@Column({ type: 'text' })
kind!: EventKind;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'date' })
startDate!: string;
@Column({ type: 'date' })
endDate!: string;
@Column({ type: 'double precision', nullable: true })
lat!: number | null;
@Column({ type: 'double precision', nullable: true })
lng!: number | null;
@Column({ type: 'text', nullable: true })
address!: string | null;
/** 개최 장소가 place 에 있으면 연결한다(선택) */
@Column({ type: 'uuid', nullable: true })
placeId!: string | null;
@ManyToOne(() => Place, { nullable: true })
@JoinColumn({ name: 'place_id' })
place?: Place | null;
/** 요금 — 관광공사 usetimefestival 은 이름과 달리 요금이다 */
@Column({ type: 'text', nullable: true })
feeText!: string | null;
/** 공연·운영 시간(playtime) */
@Column({ type: 'text', nullable: true })
playtimeText!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
@Column({ type: 'text', nullable: true })
imageUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageLicense!: ImageLicense | null;
/** 언어별 텍스트 { en: { name, overview } } */
@Column({ type: 'jsonb', nullable: true })
i18n!: Record<string, Record<string, string>> | null;
@Column({ type: 'text', default: 'active' })
status!: EventStatus;
@Column({ type: 'timestamptz', nullable: true })
lastSeenAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,130 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import type { Relation } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import {
CURATION_VERDICTS,
PET_POLICIES,
PET_SIZES,
PLACE_SETTINGS,
VERIFY_METHODS,
sqlIn,
sqlValues,
} from '../place.constants';
import type {
CurationVerdict,
PetPolicy,
PetSize,
PlaceSetting,
VerifyMethod,
} from '../place.constants';
import { Place } from './place.entity';
/**
* 운영자가 확인한 장소 정보 (place 1 : 0..1). 큐레이션 표의 오른쪽 칸이 그대로 옮겨 온다.
* 동기화는 이 테이블을 읽지도 쓰지도 않는다.
* 카카오·네이버에서 본 정보는 어떤 칸에도 넣지 않는다 — 확인 근거는 업체 본인 채널·전화·방문뿐이다.
*/
@Entity()
@Index('ix_place_curation_verdict', ['verdict'])
@Index('ix_place_curation_recheck_due_at', ['recheckDueAt'])
@Check('ck_place_curation_verdict', sqlIn('verdict', CURATION_VERDICTS))
@Check('ck_place_curation_pet_policy', sqlIn('pet_policy', PET_POLICIES))
@Check(
'ck_place_curation_pet_sizes',
`"pet_sizes" <@ ARRAY[${sqlValues(PET_SIZES)}]::text[]`,
)
@Check('ck_place_curation_setting', sqlIn('setting', PLACE_SETTINGS, true))
@Check(
'ck_place_curation_verified_method',
sqlIn('verified_method', VERIFY_METHODS, true),
)
// 확인일을 적었다면 무엇으로 확인했는지도 반드시 남긴다
@Check(
'ck_place_curation_verified',
`"verified_at" IS NULL OR "verified_method" IS NOT NULL`,
)
export class PlaceCuration {
@PrimaryColumn({ type: 'uuid' })
placeId!: string;
@OneToOne(() => Place, (place) => place.curation)
@JoinColumn({ name: 'place_id' })
place?: Relation<Place>;
/** 데이트추천(O/X) */
@Column({ type: 'text', default: 'pending' })
verdict!: CurationVerdict;
/** 추천이유 — 큐레이터가 직접 쓴 문장만(리뷰 문장 복사 금지) */
@Column({ type: 'text', nullable: true })
reasonKo!: string | null;
/** 추천이유 번역 { en: … } — 자체 작성 글이라 기계 번역 가능 */
@Column({ type: 'jsonb', nullable: true })
reasonI18n!: Record<string, string> | null;
/** 영업시간(확인) 원문 */
@Column({ type: 'text', nullable: true })
hoursText!: string | null;
/** 휴무(확인) 원문 */
@Column({ type: 'text', nullable: true })
restText!: string | null;
/** hoursText·restText 를 파서로 해석한 결과 */
@Column({ type: 'jsonb', nullable: true })
hours!: ParsedOpeningHours | null;
@Column({ type: 'text', default: 'unknown' })
petPolicy!: PetPolicy;
@Column({ type: 'text', array: true, default: () => `'{}'` })
petSizes!: PetSize[];
/** "테라스석만", "켄넬 필수" 등 */
@Column({ type: 'text', nullable: true })
petConditions!: string | null;
/** 실내/실외 — 비 오는 날 실내 우선 노출에 쓴다 */
@Column({ type: 'text', nullable: true })
setting!: PlaceSetting | null;
/** 업체 본인 공식 채널(인스타·홈페이지·블로그) */
@Column({ type: 'text', nullable: true })
officialUrl!: string | null;
@Column({ type: 'text', nullable: true })
verifiedMethod!: VerifyMethod | null;
/** 확인일 — 비어 있으면 영업시간·반려동물 값은 앱에 쓰지 않는다(place_listing 뷰) */
@Column({ type: 'date', nullable: true })
verifiedAt!: string | null;
@Column({ type: 'text', nullable: true })
verifiedBy!: string | null;
/** 다시 확인할 날짜(기본: 확인일 + 6개월) */
@Column({ type: 'date', nullable: true })
recheckDueAt!: string | null;
/** 내부 메모 — API 로 내보내지 않는다 */
@Column({ type: 'text', nullable: true })
memo!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,51 @@
import {
Check,
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { I18N_LANGS, I18N_ORIGINS, sqlIn } from '../place.constants';
import type { I18nLang, I18nOrigin } from '../place.constants';
import { Place } from './place.entity';
/**
* 장소의 언어별 표시 텍스트. 한국어는 place 본체에 있다.
* 관광공사 영문은 국문과 contentid 가 달라 좌표·분류로 맞춰 붙인다(origin=tour_en, originId=영문 contentid).
*/
@Entity()
@Check('ck_place_i18n_lang', sqlIn('lang', I18N_LANGS))
@Check('ck_place_i18n_origin', sqlIn('origin', I18N_ORIGINS))
export class PlaceI18n {
@PrimaryColumn({ type: 'uuid' })
placeId!: string;
@PrimaryColumn({ type: 'text' })
lang!: I18nLang;
@ManyToOne(() => Place)
@JoinColumn({ name: 'place_id' })
place?: Place;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text', nullable: true })
address!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
@Column({ type: 'text' })
origin!: I18nOrigin;
/** 관광공사 영문 contentid 등 원본 식별자 */
@Column({ type: 'text', nullable: true })
originId!: string | null;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,160 @@
import { ViewColumn, ViewEntity } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import { AI_TIER2_CATEGORIES, sqlValues } from '../place.constants';
import type {
CurationVerdict,
ImageLicense,
PetPolicy,
PetSize,
PlaceCategory,
PlaceSetting,
PlaceSource,
PlaceStatus,
} from '../place.constants';
/** 값의 출처 — 화면 표기("11월 2일 확인" / "출처 한국관광공사" / "미확인")를 고른다 */
export type ValueOrigin = 'curation' | 'public' | 'unknown';
// 큐레이션 값은 확인일이 있을 때만 쓴다 — 확인 안 된 값을 확인된 것처럼 보이지 않게 한다
const CURATED_HOURS = `c.verified_at IS NOT NULL AND c.hours IS NOT NULL`;
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`;
/**
* "앱이 보는 값" — 공공 값과 큐레이션 값을 합치고 노출 플래그를 계산한다.
* 우선순위(큐레이션 > 공공 > 미확인)와 노출 규칙은 **여기서만** 정한다. API·AI 는 이 뷰를 읽는다.
* 노출 규칙(2026-11-02 합의, docs/design/place-db.md §3):
* - is_searchable: 노출 중인 장소는 모두 검색·지도에 나온다(확인 안 된 상가정보 매장 포함)
* - is_home_pick: 추천(O) + 확인일이 있는 장소만 홈 동네 추천에 나온다
* - ai_tier: 1 = 추천(O) + 확인 / 2 = 관광공사 장소(코스 분류, X 아님) / NULL = 그 외 — 확인 안 된 상가정보 매장은 AI 후보가 아니다
*/
export const PLACE_LISTING_SQL = `
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 ${CURATED_HOURS} THEN c.hours WHEN ${PUBLIC_HOURS} THEN p.public_hours END AS hours,
CASE WHEN ${CURATED_HOURS} THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN ${CURATED_HOURS} THEN 'curation' WHEN ${PUBLIC_HOURS} THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN ${CURATED_PET} THEN c.pet_policy WHEN ${PUBLIC_PET} THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
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,
CASE
WHEN p.status <> 'active' 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
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id
`;
@ViewEntity({ name: 'place_listing', expression: PLACE_LISTING_SQL })
export class PlaceListing {
@ViewColumn()
id!: string;
@ViewColumn()
source!: PlaceSource;
@ViewColumn()
sourceId!: string;
@ViewColumn()
category!: PlaceCategory;
@ViewColumn()
name!: string;
@ViewColumn()
branchName!: string | null;
@ViewColumn()
addressRoad!: string | null;
@ViewColumn()
lat!: number;
@ViewColumn()
lng!: number;
@ViewColumn()
ldongRegnCd!: string | null;
@ViewColumn()
ldongSignguCd!: string | null;
@ViewColumn()
imageUrl!: string | null;
@ViewColumn()
imageThumbUrl!: string | null;
@ViewColumn()
imageLicense!: ImageLicense | null;
@ViewColumn()
status!: PlaceStatus;
@ViewColumn()
verdict!: CurationVerdict | null;
@ViewColumn()
verifiedAt!: string | null;
@ViewColumn()
setting!: PlaceSetting | null;
/** 큐레이션(확인됨) → 공공(해석 성공) → null */
@ViewColumn()
hours!: ParsedOpeningHours | null;
@ViewColumn()
hoursText!: string | null;
@ViewColumn()
hoursOrigin!: ValueOrigin;
@ViewColumn()
petPolicy!: PetPolicy;
@ViewColumn()
petOrigin!: ValueOrigin;
@ViewColumn()
petSizes!: PetSize[] | null;
@ViewColumn()
petConditions!: string | null;
@ViewColumn()
isSearchable!: boolean;
@ViewColumn()
isHomePick!: boolean;
/** 1: 큐레이션 추천 · 2: 관광공사 장소 · null: AI 후보 아님 */
@ViewColumn()
aiTier!: 1 | 2 | null;
}
@@ -0,0 +1,56 @@
import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { SYNC_JOBS, sqlIn } from '../place.constants';
import type { SyncJob } from '../place.constants';
/**
* 동기화 실행 기록 — "언제 받은 데이터인가"에 답하고, 오퍼레이션별 하루 호출 한도를 관리한다.
* 키나 요청 URL 은 남기지 않는다(오류 문구도 키를 가린 뒤 저장).
*/
@Entity()
@Index('ix_place_sync_run_job_started_at', ['job', 'startedAt'])
@Check('ck_place_sync_run_job', sqlIn('job', SYNC_JOBS))
export class PlaceSyncRun {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
job!: SyncJob;
/** 범위 — 예: 법정동 시·도 '27', 행정동 목록 등 */
@Column({ type: 'text', nullable: true })
scope!: string | null;
@Column({ type: 'timestamptz' })
startedAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
finishedAt!: Date | null;
@Column({ type: 'integer', default: 0 })
fetched!: number;
@Column({ type: 'integer', default: 0 })
inserted!: number;
@Column({ type: 'integer', default: 0 })
updated!: number;
@Column({ type: 'integer', default: 0 })
hidden!: number;
@Column({ type: 'integer', default: 0 })
gone!: number;
/** 오퍼레이션별 호출 수 { detailIntro2: 412, … } */
@Column({ type: 'jsonb', default: () => `'{}'` })
calls!: Record<string, number>;
/** 오류 앞 20건(키는 가린 뒤 저장) */
@Column({ type: 'jsonb', default: () => `'[]'` })
errors!: string[];
/** 원본 기준 시점(상가정보 stdrYm 등) */
@Column({ type: 'text', nullable: true })
snapshot!: string | null;
}
@@ -0,0 +1,167 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
OneToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import type { Relation } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import {
IMAGE_LICENSES,
PLACE_CATEGORIES,
PLACE_SOURCES,
PLACE_STATUSES,
PUBLIC_PET_POLICIES,
sqlIn,
} from '../place.constants';
import type {
ImageLicense,
PlaceCategory,
PlaceSource,
PlaceStatus,
PublicPetPolicy,
} from '../place.constants';
import { PlaceCuration } from './place-curation.entity';
/**
* 공공데이터 장소 — 출처 1건이 1행이다. 동기화 명령이 자동으로 갱신한다.
* 운영자가 확인한 값은 place_curation 에 따로 두며, 동기화는 그 테이블을 건드리지 않는다.
* 장소는 지우지 않는다(큐레이션·코스가 가리킬 수 있다) — 사라지면 status 를 바꾼다.
*/
@Entity()
@Unique('uq_place_source', ['source', 'sourceId'])
@Index('ix_place_lat_lng', ['lat', 'lng'])
@Index('ix_place_category', ['category'])
@Index('ix_place_status', ['status'])
@Check('ck_place_source', sqlIn('source', PLACE_SOURCES))
@Check('ck_place_category', sqlIn('category', PLACE_CATEGORIES))
@Check('ck_place_status', sqlIn('status', PLACE_STATUSES))
@Check(
'ck_place_public_pet_policy',
sqlIn('public_pet_policy', PUBLIC_PET_POLICIES, true),
)
@Check('ck_place_image_license', sqlIn('image_license', IMAGE_LICENSES, true))
@Check(
'ck_place_lat_lng',
`"lat" BETWEEN -90 AND 90 AND "lng" BETWEEN -180 AND 180`,
)
export class Place {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
source!: PlaceSource;
/** 관광공사 contentid · 상가정보 bizesId — (source, sourceId) 가 동기화·큐레이션의 연결 키 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
category!: PlaceCategory;
/** 원본 분류 코드(lclsSystm3 · indsSclsCd) — 분류 규칙을 바꿔도 다시 계산할 수 있게 */
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text', nullable: true })
branchName!: string | null;
@Column({ type: 'text', nullable: true })
addressRoad!: string | null;
@Column({ type: 'text', nullable: true })
addressJibun!: string | null;
/** WGS84. 좌표가 없는 원본은 적재하지 않는다(지도·거리 계산 불가) */
@Column({ type: 'double precision' })
lat!: number;
@Column({ type: 'double precision' })
lng!: number;
/** 법정동 시·도(2자리) — 관광공사 동기화 범위 */
@Column({ type: 'text', nullable: true })
ldongRegnCd!: string | null;
/** 법정동 시·군·구(3자리) */
@Column({ type: 'text', nullable: true })
ldongSignguCd!: string | null;
/** 행정동(상가정보 8자리) — 상가정보 동기화 범위 */
@Column({ type: 'text', nullable: true })
adongCd!: string | null;
@Column({ type: 'text', nullable: true })
phone!: string | null;
@Column({ type: 'text', nullable: true })
homepage!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
/** 영업시간·쉬는날 원문(관광공사 유형별 필드) */
@Column({ type: 'text', nullable: true })
publicHoursText!: string | null;
@Column({ type: 'text', nullable: true })
publicRestText!: string | null;
/** 원문을 opening-hours 파서로 해석한 결과 — 동기화 때마다 다시 계산한다 */
@Column({ type: 'jsonb', nullable: true })
publicHours!: ParsedOpeningHours | null;
/** 관광공사 반려동물 동반 정보 원문. 없으면 '미확인'(불가 아님) */
@Column({ type: 'jsonb', nullable: true })
publicPet!: Record<string, string> | null;
/** publicPet 을 동기화 때 요약한 값(전구역 → allowed, 일부구역 → partial) */
@Column({ type: 'text', nullable: true })
publicPetPolicy!: PublicPetPolicy | null;
/** 유형별 부가 정보(입장료·주차 등) 원문 */
@Column({ type: 'jsonb', nullable: true })
publicExtra!: Record<string, string> | null;
@Column({ type: 'text', nullable: true })
imageUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageThumbUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageLicense!: ImageLicense | null;
@Column({ type: 'text', default: 'active' })
status!: PlaceStatus;
/** 관광공사 modifiedtime */
@Column({ type: 'timestamptz', nullable: true })
sourceModifiedAt!: Date | null;
/** 상가정보 기준년월(stdrYm) */
@Column({ type: 'text', nullable: true })
sourceSnapshot!: string | null;
/** 마지막 동기화에서 원본에 있던 시각 */
@Column({ type: 'timestamptz', nullable: true })
lastSeenAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@OneToOne(() => PlaceCuration, (curation) => curation.place)
curation?: Relation<PlaceCuration> | null;
}
@@ -0,0 +1,35 @@
import {
AI_TIER2_CATEGORIES,
PLACE_CATEGORIES,
sqlIn,
sqlValues,
} from './place.constants';
describe('place.constants', () => {
it('CHECK 제약 식을 코드값 목록으로 만든다', () => {
expect(sqlIn('status', ['active', 'gone'])).toBe(
`"status" IN ('active', 'gone')`,
);
expect(sqlIn('setting', ['indoor'], true)).toBe(
`"setting" IS NULL OR "setting" IN ('indoor')`,
);
});
it('따옴표 같은 문자가 섞인 코드값은 SQL 로 만들지 않고 실패한다', () => {
expect(() => sqlValues(["a'; DROP TABLE place; --"])).toThrow();
});
it('언어 코드처럼 하이픈이 든 값은 허용한다', () => {
expect(sqlValues(['zh-Hans'])).toBe(`'zh-Hans'`);
});
it('숙박·쇼핑은 큐레이션 없이 AI 후보가 되지 않는다', () => {
const tier2: readonly string[] = AI_TIER2_CATEGORIES;
expect(tier2).not.toContain('lodging');
expect(tier2).not.toContain('shopping');
expect(tier2).not.toContain('bar');
for (const c of tier2) {
expect(PLACE_CATEGORIES).toContain(c);
}
});
});
@@ -0,0 +1,121 @@
/**
* 장소 DB 의 코드값 — 엔티티 CHECK 제약·place_listing 뷰·서비스가 이 목록 하나를 공유한다.
* 설계: docs/design/place-db.md
*/
/** 데이터 출처: 관광공사(TourAPI) · 소상공인 상가정보 */
export const PLACE_SOURCES = ['tour', 'store'] as const;
export type PlaceSource = (typeof PLACE_SOURCES)[number];
/** 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6) */
export const PLACE_CATEGORIES = [
'nature',
'attraction',
'culture',
'leisure',
'food',
'cafe',
'bar',
'shopping',
'lodging',
] as const;
export type PlaceCategory = (typeof PLACE_CATEGORIES)[number];
/** active: 노출 · hidden: 관광공사 비표출 · gone: 스냅샷에서 사라짐(폐업 추정). 장소는 지우지 않는다 */
export const PLACE_STATUSES = ['active', 'hidden', 'gone'] as const;
export type PlaceStatus = (typeof PLACE_STATUSES)[number];
/** 운영자 판단: 데이트 추천 O · X · 아직 안 봄 */
export const CURATION_VERDICTS = [
'recommend',
'not_recommend',
'pending',
] as const;
export type CurationVerdict = (typeof CURATION_VERDICTS)[number];
/** 반려동물 동반 — 정보가 없으면 '불가'가 아니라 'unknown' 이다 */
export const PET_POLICIES = [
'allowed',
'partial',
'not_allowed',
'unknown',
] as const;
export type PetPolicy = (typeof PET_POLICIES)[number];
/** 관광공사 반려동물 정보는 "동반 가능한 곳"만 담으므로 두 값뿐이다 */
export const PUBLIC_PET_POLICIES = ['allowed', 'partial'] as const;
export type PublicPetPolicy = (typeof PUBLIC_PET_POLICIES)[number];
export const PET_SIZES = ['small', 'medium', 'large'] as const;
export type PetSize = (typeof PET_SIZES)[number];
export const PLACE_SETTINGS = ['indoor', 'outdoor', 'both'] as const;
export type PlaceSetting = (typeof PLACE_SETTINGS)[number];
/** 확인 근거 — 업체 본인 채널·전화·방문만 인정한다(카카오·네이버 정보 금지) */
export const VERIFY_METHODS = ['phone', 'official_channel', 'visit'] as const;
export type VerifyMethod = (typeof VERIFY_METHODS)[number];
/** 한국어는 place 본체에 두고, 나머지 언어만 place_i18n 에 둔다 */
export const I18N_LANGS = ['en', 'ja', 'zh-Hans', 'zh-Hant'] as const;
export type I18nLang = (typeof I18N_LANGS)[number];
export const I18N_ORIGINS = ['tour_en', 'machine', 'curator'] as const;
export type I18nOrigin = (typeof I18N_ORIGINS)[number];
/** 공공누리 유형 — 화면에 출처 표시가 필요하다 */
export const IMAGE_LICENSES = ['Type1', 'Type3'] as const;
export type ImageLicense = (typeof IMAGE_LICENSES)[number];
/** 관광공사 lclsSystm2: EV01 축제 · EV02 공연 · EV03 행사 */
export const EVENT_KINDS = ['festival', 'performance', 'event'] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
export const EVENT_STATUSES = ['active', 'hidden'] as const;
export type EventStatus = (typeof EVENT_STATUSES)[number];
export const SYNC_JOBS = [
'tour_kor',
'tour_eng',
'tour_pet',
'store',
'event',
'closure',
] as const;
export type SyncJob = (typeof SYNC_JOBS)[number];
/**
* AI 2순위 후보가 되는 분류(큐레이션 없이도 관광공사 장소면 후보).
* 숙박·쇼핑은 큐레이션으로 추천(O) 받았을 때만 후보가 된다 — 2026-11-02 합의.
*/
export const AI_TIER2_CATEGORIES = [
'nature',
'attraction',
'culture',
'leisure',
'food',
'cafe',
] as const satisfies readonly PlaceCategory[];
/**
* CHECK 제약·뷰에 넣을 SQL 값 목록 — `'a', 'b'`.
* 코드 상수로만 만들지만, 혹시라도 따옴표가 섞이면 SQL 이 깨지므로 즉시 실패시킨다.
*/
export function sqlValues(values: readonly string[]): string {
for (const v of values) {
if (!/^[A-Za-z0-9_-]+$/.test(v)) {
throw new Error(`SQL 코드값에 허용되지 않는 문자가 있습니다: ${v}`);
}
}
return values.map((v) => `'${v}'`).join(', ');
}
/** `"col" IN (...)` CHECK 식. nullable 이면 NULL 도 허용한다 */
export function sqlIn(
column: string,
values: readonly string[],
nullable = false,
): string {
const inList = `"${column}" IN (${sqlValues(values)})`;
return nullable ? `"${column}" IS NULL OR ${inList}` : inList;
}
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FestivalEvent } from './entities/festival-event.entity';
import { PlaceCuration } from './entities/place-curation.entity';
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';
/**
* 장소·축제 데이터 모듈 (설계: docs/design/place-db.md).
* 지금은 스키마만 있다 — 동기화 명령·큐레이션 가져오기·조회 API 는 다음 단계에서 이 모듈에 추가한다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Place,
PlaceCuration,
PlaceI18n,
PlaceListing,
FestivalEvent,
PlaceSyncRun,
]),
],
exports: [TypeOrmModule],
})
export class PlaceModule {}