generated from ttipo/project-base
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:
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user