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
+2
View File
@@ -8,6 +8,7 @@ import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { HealthModule } from '@/modules/health/health.module';
import { PlaceModule } from '@/modules/place/place.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
@@ -58,6 +59,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
}),
}),
HealthModule,
PlaceModule,
UploadModule,
UserModule,
],
@@ -0,0 +1,137 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlaceTables1793597729768 implements MigrationInterface {
name = 'AddPlaceTables1793597729768';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "place_curation" ("place_id" uuid NOT NULL, "verdict" text NOT NULL DEFAULT 'pending', "reason_ko" text, "reason_i18n" jsonb, "hours_text" text, "rest_text" text, "hours" jsonb, "pet_policy" text NOT NULL DEFAULT 'unknown', "pet_sizes" text array NOT NULL DEFAULT '{}', "pet_conditions" text, "setting" text, "official_url" text, "verified_method" text, "verified_at" date, "verified_by" text, "recheck_due_at" date, "memo" text, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "ck_place_curation_verified" CHECK ("verified_at" IS NULL OR "verified_method" IS NOT NULL), CONSTRAINT "ck_place_curation_verified_method" CHECK ("verified_method" IS NULL OR "verified_method" IN ('phone', 'official_channel', 'visit')), CONSTRAINT "ck_place_curation_setting" CHECK ("setting" IS NULL OR "setting" IN ('indoor', 'outdoor', 'both')), CONSTRAINT "ck_place_curation_pet_sizes" CHECK ("pet_sizes" <@ ARRAY['small', 'medium', 'large']::text[]), CONSTRAINT "ck_place_curation_pet_policy" CHECK ("pet_policy" IN ('allowed', 'partial', 'not_allowed', 'unknown')), CONSTRAINT "ck_place_curation_verdict" CHECK ("verdict" IN ('recommend', 'not_recommend', 'pending')), CONSTRAINT "PK_645f6298438801472c60ce4195c" PRIMARY KEY ("place_id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_curation_recheck_due_at" ON "place_curation" ("recheck_due_at") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_curation_verdict" ON "place_curation" ("verdict") `,
);
await queryRunner.query(
`CREATE TABLE "place" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source" text NOT NULL, "source_id" text NOT NULL, "category" text NOT NULL, "source_category" text, "name" text NOT NULL, "branch_name" text, "address_road" text, "address_jibun" text, "lat" double precision NOT NULL, "lng" double precision NOT NULL, "ldong_regn_cd" text, "ldong_signgu_cd" text, "adong_cd" text, "phone" text, "homepage" text, "overview" text, "public_hours_text" text, "public_rest_text" text, "public_hours" jsonb, "public_pet" jsonb, "public_pet_policy" text, "public_extra" jsonb, "image_url" text, "image_thumb_url" text, "image_license" text, "status" text NOT NULL DEFAULT 'active', "source_modified_at" TIMESTAMP WITH TIME ZONE, "source_snapshot" text, "last_seen_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "uq_place_source" UNIQUE ("source", "source_id"), CONSTRAINT "ck_place_lat_lng" CHECK ("lat" BETWEEN -90 AND 90 AND "lng" BETWEEN -180 AND 180), CONSTRAINT "ck_place_image_license" CHECK ("image_license" IS NULL OR "image_license" IN ('Type1', 'Type3')), CONSTRAINT "ck_place_public_pet_policy" CHECK ("public_pet_policy" IS NULL OR "public_pet_policy" IN ('allowed', 'partial')), CONSTRAINT "ck_place_status" CHECK ("status" IN ('active', 'hidden', 'gone')), CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging')), CONSTRAINT "ck_place_source" CHECK ("source" IN ('tour', 'store')), CONSTRAINT "PK_96ab91d43aa89c5de1b59ee7cca" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_status" ON "place" ("status") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_category" ON "place" ("category") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_lat_lng" ON "place" ("lat", "lng") `,
);
await queryRunner.query(
`CREATE TABLE "place_sync_run" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "job" text NOT NULL, "scope" text, "started_at" TIMESTAMP WITH TIME ZONE NOT NULL, "finished_at" TIMESTAMP WITH TIME ZONE, "fetched" integer NOT NULL DEFAULT '0', "inserted" integer NOT NULL DEFAULT '0', "updated" integer NOT NULL DEFAULT '0', "hidden" integer NOT NULL DEFAULT '0', "gone" integer NOT NULL DEFAULT '0', "calls" jsonb NOT NULL DEFAULT '{}', "errors" jsonb NOT NULL DEFAULT '[]', "snapshot" text, CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure')), CONSTRAINT "PK_0262dd8226b47bf7ee739b633d6" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_sync_run_job_started_at" ON "place_sync_run" ("job", "started_at") `,
);
await queryRunner.query(
`CREATE TABLE "place_i18n" ("place_id" uuid NOT NULL, "lang" text NOT NULL, "name" text NOT NULL, "address" text, "overview" text, "origin" text NOT NULL, "origin_id" text, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "ck_place_i18n_origin" CHECK ("origin" IN ('tour_en', 'machine', 'curator')), CONSTRAINT "ck_place_i18n_lang" CHECK ("lang" IN ('en', 'ja', 'zh-Hans', 'zh-Hant')), CONSTRAINT "PK_42a28a7fcfecb7c73462df320ae" PRIMARY KEY ("place_id", "lang"))`,
);
await queryRunner.query(
`CREATE TABLE "festival_event" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source_id" text NOT NULL, "kind" text NOT NULL, "name" text NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "lat" double precision, "lng" double precision, "address" text, "place_id" uuid, "fee_text" text, "playtime_text" text, "overview" text, "image_url" text, "image_license" text, "i18n" jsonb, "status" text NOT NULL DEFAULT 'active', "last_seen_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e" UNIQUE ("source_id"), CONSTRAINT "ck_festival_event_period" CHECK ("end_date" >= "start_date"), CONSTRAINT "ck_festival_event_image_license" CHECK ("image_license" IS NULL OR "image_license" IN ('Type1', 'Type3')), CONSTRAINT "ck_festival_event_status" CHECK ("status" IN ('active', 'hidden')), CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event')), CONSTRAINT "PK_01f428b159e675773743bb1d220" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_festival_event_lat_lng" ON "festival_event" ("lat", "lng") `,
);
await queryRunner.query(
`CREATE INDEX "ix_festival_event_period" ON "festival_event" ("start_date", "end_date") `,
);
await queryRunner.query(
`ALTER TABLE "place_curation" ADD CONSTRAINT "FK_645f6298438801472c60ce4195c" FOREIGN KEY ("place_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "place_i18n" ADD CONSTRAINT "FK_add719bd193db01b37fc0486c13" FOREIGN KEY ("place_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "FK_9359619412cd3d514d041894f3c" FOREIGN KEY ("place_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.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",
],
);
}
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 "festival_event" DROP CONSTRAINT "FK_9359619412cd3d514d041894f3c"`,
);
await queryRunner.query(
`ALTER TABLE "place_i18n" DROP CONSTRAINT "FK_add719bd193db01b37fc0486c13"`,
);
await queryRunner.query(
`ALTER TABLE "place_curation" DROP CONSTRAINT "FK_645f6298438801472c60ce4195c"`,
);
await queryRunner.query(`DROP INDEX "public"."ix_festival_event_period"`);
await queryRunner.query(`DROP INDEX "public"."ix_festival_event_lat_lng"`);
await queryRunner.query(`DROP TABLE "festival_event"`);
await queryRunner.query(`DROP TABLE "place_i18n"`);
await queryRunner.query(
`DROP INDEX "public"."ix_place_sync_run_job_started_at"`,
);
await queryRunner.query(`DROP TABLE "place_sync_run"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_lat_lng"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_category"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_status"`);
await queryRunner.query(`DROP TABLE "place"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_curation_verdict"`);
await queryRunner.query(
`DROP INDEX "public"."ix_place_curation_recheck_due_at"`,
);
await queryRunner.query(`DROP TABLE "place_curation"`);
}
}
@@ -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 {}
@@ -1,6 +1,7 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { SnakeNamingStrategy } from '@/shared/config/snake-naming.strategy';
import { parsePositiveInt } from '@/shared/utils/env';
/**
@@ -73,6 +74,10 @@ export function buildDatabaseOptions(): DataSourceOptions {
migrationsRun: isProduction,
logging: !isProduction,
extra: { ...POOL_OPTIONS },
// 테이블·컬럼은 snake_case, TS 속성은 camelCase — 엔티티마다 name 을 적지 않는다
namingStrategy: new SnakeNamingStrategy(),
// uuid 기본값을 gen_random_uuid() 로 만든다(pgcrypto, PostgreSQL 13+ 는 기본 내장 함수)
uuidExtension: 'pgcrypto',
entities: paths.entities,
migrations: paths.migrations,
};
@@ -0,0 +1,33 @@
import { SnakeNamingStrategy } from './snake-naming.strategy';
describe('SnakeNamingStrategy', () => {
const strategy = new SnakeNamingStrategy();
it('클래스 이름으로 테이블 이름을 만들고, 직접 지정한 이름은 그대로 쓴다', () => {
expect(strategy.tableName('PlaceCuration', undefined)).toBe(
'place_curation',
);
expect(strategy.tableName('FestivalEvent', 'festival_event')).toBe(
'festival_event',
);
});
it('속성 이름을 snake_case 컬럼으로 바꾸고, 직접 지정한 컬럼 이름은 그대로 쓴다', () => {
expect(strategy.columnName('verifiedAt', undefined, [])).toBe(
'verified_at',
);
expect(strategy.columnName('verifiedAt', 'checked_on', [])).toBe(
'checked_on',
);
});
it('임베디드 접두어를 붙인다', () => {
expect(strategy.columnName('lat', undefined, ['geoPoint'])).toBe(
'geo_point_lat',
);
});
it('관계 조인 컬럼을 snake_case 로 만든다', () => {
expect(strategy.joinColumnName('place', 'id')).toBe('place_id');
});
});
@@ -0,0 +1,52 @@
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
import { toSnakeCase } from '@/shared/utils/snake-case';
/**
* DB 이름 규칙 — 테이블·컬럼은 snake_case, TS 속성은 camelCase.
* 엔티티마다 `name:` 을 적지 않아도 되게 한 곳에서 바꾼다(앱·CLI 가 database.config.ts 로 공유).
* `name:` 을 직접 지정한 경우에는 그 이름을 그대로 쓴다.
*/
export class SnakeNamingStrategy
extends DefaultNamingStrategy
implements NamingStrategyInterface
{
tableName(targetName: string, userSpecifiedName: string | undefined): string {
return userSpecifiedName ?? toSnakeCase(targetName);
}
columnName(
propertyName: string,
customName: string | undefined,
embeddedPrefixes: string[],
): string {
const prefix = toSnakeCase(embeddedPrefixes.concat('').join('_'));
return prefix + (customName ?? toSnakeCase(propertyName));
}
relationName(propertyName: string): string {
return toSnakeCase(propertyName);
}
joinColumnName(relationName: string, referencedColumnName: string): string {
return toSnakeCase(`${relationName}_${referencedColumnName}`);
}
joinTableName(
firstTableName: string,
secondTableName: string,
firstPropertyName: string,
): string {
return toSnakeCase(
`${firstTableName}_${firstPropertyName.replace(/\./g, '_')}_${secondTableName}`,
);
}
joinTableColumnName(
tableName: string,
propertyName: string,
columnName?: string,
): string {
return toSnakeCase(`${tableName}_${columnName ?? propertyName}`);
}
}
@@ -0,0 +1,22 @@
import { toSnakeCase } from './snake-case';
describe('toSnakeCase', () => {
it('camelCase 속성 이름을 snake_case 컬럼 이름으로 바꾼다', () => {
expect(toSnakeCase('sourceId')).toBe('source_id');
expect(toSnakeCase('ldongRegnCd')).toBe('ldong_regn_cd');
expect(toSnakeCase('publicHoursText')).toBe('public_hours_text');
});
it('PascalCase 클래스 이름을 테이블 이름으로 바꾼다(숫자 포함)', () => {
expect(toSnakeCase('PlaceI18n')).toBe('place_i18n');
expect(toSnakeCase('PlaceSyncRun')).toBe('place_sync_run');
});
it('대문자 약어가 이어져도 단어 경계를 지킨다', () => {
expect(toSnakeCase('HTTPServer')).toBe('http_server');
});
it('이미 snake_case 인 이름은 그대로 둔다', () => {
expect(toSnakeCase('place_id')).toBe('place_id');
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* camelCase·PascalCase 식별자를 snake_case 로 바꾼다 (DB 테이블·컬럼 이름 규칙용).
* 예: sourceId → source_id, PlaceI18n → place_i18n, ldongRegnCd → ldong_regn_cd
*/
export function toSnakeCase(name: string): string {
return (
name
// 소문자·숫자 뒤의 대문자 앞에 밑줄: sourceId → source_Id
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
// 대문자 연속 뒤에 단어가 시작될 때: HTTPServer → HTTP_Server
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
);
}
+282
View File
@@ -0,0 +1,282 @@
# 장소 DB 설계 (v1.1, 2026-11-02)
WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다.
1단계(엔티티·마이그레이션)를 구현했다 — `backend/src/modules/place/`, `backend/src/migrations/1793597729768-AddPlaceTables.ts`.
이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다.
## 0. 전제 (이미 정한 것)
| 전제 | 근거 |
| --- | --- |
| 장소 데이터는 **공공데이터(자동 갱신) + 운영자 큐레이션**만 저장한다 | 카카오는 ID·place_url·사용자가 입력한 글자 외 저장 금지 (`docs/research/pre-dev-check/kakao_place_id.md`) |
| 카카오 응답은 LLM 에 보내지 않는다 → AI 후보는 자체 장소 DB 에서만 | 같은 문서 |
| AI 추천은 **큐레이션 O 장소 우선 + 관광공사 장소(보조)**. 확인 안 된 상가정보 매장은 추천하지 않는다 | 2026-11-02 합의 |
| 확인 안 된 공공데이터 장소도 **검색·지도에는 노출**, "영업시간 미확인 · 방문 전 확인" | 2026-11-02 합의 |
| 관광공사 적재는 **지역 목록·동기화 API** 로 한다. 위치 검색은 반경 안 장소의 15–38%만 돌려준다 | `docs/research/data-check/` 정정 메모, 2026-11-02 실측 |
| 1차 지역은 대구(도심·수성못·앞산), 한국어·영어 출시, 4개 언어 전제 | MVP 결정 |
| 영업시간 문장은 `backend/src/shared/utils/opening-hours.ts` 로 해석한다 | 구현·테스트 완료 |
## 1. 전체 구조
```
[공공데이터 API] [운영자]
관광공사 국문·영문·반려동물 큐레이션 표(CSV)
상가정보 · 축제(searchFestival2) │
│ 동기화 명령(일·주·분기) │ 가져오기 명령
▼ ▼
┌───────────┐ 1:0..1 ┌────────────────┐
│ place │──────────│ place_curation │ ← 동기화가 절대 건드리지 않는다
└───────────┘ └────────────────┘
│ │ 1:N │
│ └── place_i18n(영·일·중) │
▼ ▼
place_listing (뷰) — 공공 값과 큐레이션 값을 합친 "앱이 보는 값" + 노출 플래그
│
▼
검색·지도·상세 API / AI 후보 조회 / 홈 동네 추천
festival_event — 축제·공연·행사 (홈·장소 상세의 축제 칸)
place_sync_run — 동기화 기록
```
핵심은 두 가지다.
1. **공공 원본(`place`)과 사람이 확인한 값(`place_curation`)을 테이블로 나눈다.** 동기화가 아무리 덮어써도 큐레이션은 남는다.
2. **"앱이 보는 값"은 뷰 하나에서만 정한다.** 큐레이션 값 > 공공 값 > 미확인 순서를 여기서만 적용한다(규칙을 서비스마다 따로 적지 않는다).
```mermaid
erDiagram
place ||--o| place_curation : "확인 정보"
place ||--o{ place_i18n : "언어별 텍스트"
place ||--o{ festival_event : "개최 장소(선택)"
place {
uuid id PK
text source "tour | store"
text source_id "UK(source, source_id)"
text category
text name
float lat
float lng
text status "active | hidden | gone"
}
place_curation {
uuid place_id PK
text verdict "recommend | not_recommend | pending"
date verified_at
}
place_i18n {
uuid place_id PK
text lang PK
text name
}
festival_event {
uuid id PK
text source_id UK
date start_date
date end_date
}
```
## 2. 테이블
### 2.1 `place` — 공공데이터 장소 (출처 1건 = 1행)
| 컬럼 | 타입 | 설명 |
| --- | --- | --- |
| `id` | uuid PK | 앱·공유 링크에 노출하는 ID |
| `source` | text | `tour`(관광공사) · `store`(상가정보) |
| `source_id` | text | contentid / bizesId. **`(source, source_id)` 유니크** — 동기화·큐레이션 가져오기의 연결 키 |
| `category` | text | 자체 분류(§2.6). 검색 탭·AI 슬롯에 쓴다 |
| `source_category` | text | 원본 코드(lclsSystm3 / indsSclsCd) — 분류 규칙을 바꿔도 다시 계산할 수 있게 |
| `name`, `branch_name` | text | 한국어 이름, 지점명(상가정보 brchNm) |
| `address_road`, `address_jibun` | text | |
| `lat`, `lng` | double precision | WGS84. §5 위치 검색 |
| `ldong_regn_cd`, `ldong_signgu_cd`, `adong_cd` | text | 법정동 시도(2)·시군구(3), 행정동(상가 8자리). 지역 필터·동기화 범위 |
| `phone`, `homepage` | text | 관광공사만 |
| `overview` | text | 소개(detailCommon2) |
| `public_hours_text`, `public_rest_text` | text | 영업시간·쉬는날 **원문** (유형별 필드 매핑은 `tools/data-check/curation_candidates.py` 의 `INTRO_FIELDS`) |
| `public_hours` | jsonb | 원문을 파서로 해석한 결과(`ParsedOpeningHours`). 동기화 때 다시 계산 |
| `public_pet` | jsonb | 관광공사 반려동물 동반 정보 원문(acmpyTypeCd·acmpyPsblCpam·acmpyNeedMtr 등). 없으면 null = **미확인**(불가 아님) |
| `public_pet_policy` | text | 위 원문을 동기화 때 요약한 값: `allowed`(전구역) · `partial`(일부구역) · null. 뷰는 이 값만 본다 |
| `public_extra` | jsonb | 유형별 부가 정보(입장료·주차 등). 필터로 승격할 항목은 §8 |
| `image_url`, `image_thumb_url` | text | firstimage / firstimage2 |
| `image_license` | text | `Type1` · `Type3` · null. 화면에 출처 표시 필수 |
| `status` | text | `active` · `hidden`(관광공사 비표출 showflag=0) · `gone`(다음 스냅샷에서 사라짐·폐업 추정) |
| `source_modified_at` | timestamptz | 관광공사 modifiedtime |
| `source_snapshot` | text | 상가정보 기준년월(stdrYm) |
| `last_seen_at` | timestamptz | 마지막 동기화에서 확인된 시각 |
| `created_at`, `updated_at` | timestamptz | |
- **지우지 않는다.** 사라진 장소는 `status=gone` 으로 숨긴다. 큐레이션과 코스가 그 장소를 가리키고 있을 수 있다.
- 숙박도 저장한다(검색의 '숙소' 탭). AI 코스·큐레이션 대상에서는 뺀다.
### 2.2 `place_curation` — 운영자가 확인한 정보 (place 1 : 0..1)
큐레이션 표의 오른쪽 12칸이 그대로 옮겨 온다.
| 컬럼 | 타입 | 큐레이션 표 칸 | 설명 |
| --- | --- | --- | --- |
| `place_id` | uuid PK, FK | 출처+출처ID 로 찾음 | |
| `verdict` | text | 데이트추천(O/X) | `recommend` · `not_recommend` · `pending` |
| `reason_ko` | text | 추천이유 | **큐레이터가 직접 쓴 문장만** (리뷰 문장 복사 금지) |
| `reason_i18n` | jsonb | — | `{en: …}`. 자체 작성 글이라 기계 번역 가능 |
| `hours_text`, `rest_text` | text | 영업시간(확인)·휴무(확인) | |
| `hours` | jsonb | — | 위 문장을 파서로 해석한 결과 |
| `pet_policy` | text | 반려동물(가능/일부/불가/미확인) | `allowed` · `partial` · `not_allowed` · `unknown` |
| `pet_sizes` | text[] | 반려동물 조건 | `small` · `medium` · `large` (디자인의 소형·중형·대형견) |
| `pet_conditions` | text | 반려동물 조건 | "테라스석만", "켄넬 필수" 등 |
| `setting` | text | 실내/실외 | `indoor` · `outdoor` · `both` — 비 오는 날 실내 우선 |
| `official_url` | text | 공식채널URL | 업체 본인 채널만 |
| `verified_method` | text | 확인방법 | `phone` · `official_channel` · `visit` |
| `verified_at` | date | 확인일 | 화면에 "11월 2일 확인"으로 노출 |
| `verified_by` | text | 확인자 | 운영자 식별자 |
| `recheck_due_at` | date | — | `verified_at + 6개월`(제안). 폐업 의심 시 앞당김 |
| `memo` | text | 메모 | 내부용, API 로 내보내지 않는다 |
| `updated_at` | timestamptz | | |
- 카카오·네이버에서 본 정보는 어떤 칸에도 옮기지 않는다. 확인 근거는 업체 본인 채널·전화·방문뿐이다.
### 2.3 `place_i18n` — 언어별 표시 텍스트
| 컬럼 | 설명 |
| --- | --- |
| `place_id`, `lang` | PK. `lang` = `en` · `ja` · `zh-Hans` · `zh-Hant` |
| `name`, `address`, `overview` | |
| `origin` | `tour_en`(관광공사 영문) · `machine`(기계 번역) · `curator` |
| `origin_id` | 영문 contentid (국문과 다르다) |
| `updated_at` | |
- 한국어는 `place` 본체에 둔다.
- 관광공사 영문은 국문과 연결 필드가 없어 **좌표(50m 안) + 분류 코드**로 맞춘다. 맞지 않은 영문 항목은 기록만 하고 붙이지 않는다(첫 적재 때 매칭률을 잰다).
### 2.4 `festival_event` — 축제·공연·행사
| 컬럼 | 설명 |
| --- | --- |
| `id` uuid PK, `source_id` 유니크 | 관광공사 contentid |
| `kind` | `festival`(EV01) · `performance`(EV02) · `event`(EV03) |
| `name`, `start_date`, `end_date`, `lat`, `lng`, `address` | |
| `place_id` | 개최 장소가 `place` 에 있으면 연결(선택) |
| `fee_text`, `playtime_text`, `overview`, `image_url`, `image_license` | `usetimefestival` 은 **요금**이다(시간 아님) |
| `i18n` | jsonb `{en: {name, overview}}` |
| `status`, `last_seen_at` | |
- "60일 넘는 상시 프로그램 제외"는 **조회할 때** 적용한다(저장은 그대로).
### 2.5 `place_sync_run` — 동기화 기록
`id`, `job`(tour_kor·tour_eng·tour_pet·store·event), `scope`(예: `27`), `started_at`, `finished_at`, `fetched`, `inserted`, `updated`, `hidden`, `gone`, `calls`(오퍼레이션별 호출 수 jsonb), `errors`(앞 20건), `snapshot`.
문의가 오면 "언제 받은 데이터인가"를 여기서 답한다.
### 2.6 자체 분류 (`category`)
검색 탭(관광지 · 축제 · 맛집·카페 · 숙소 · 산책·자연)과 AI 코스 슬롯을 이 값으로 고른다.
| category | 관광공사(lclsSystm1 기준) | 상가정보 | 검색 탭 |
| --- | --- | --- | --- |
| `nature` | NA | — | 산책·자연 |
| `attraction` | HS, VE(문화시설 제외) | — | 관광지 |
| `culture` | VE07(문화시설) | — | 관광지 |
| `leisure` | LS | — | 관광지 |
| `food` | FD(카페 제외) | 그 외 음식 업종(지금은 적재하지 않음) | 맛집·카페 |
| `cafe` | FD05 | I21201 카페 · I21001 빵/도넛 · I21008 아이스크림/빙수 · I21005 샌드위치/샐러드 | 맛집·카페 |
| `bar` | — | I21104 요리 주점 · I21103 생맥주 | 맛집·카페 |
| `shopping` | SH | — | 관광지 |
| `lodging` | AC | — | 숙소 |
- 위 대응은 2026-11-02 표본에서 본 코드로 만든 것이다. `lclsSystmCode2` 로 전체 코드표를 받아 확정한다.
## 3. "앱이 보는 값" — `place_listing` 뷰
`place` LEFT JOIN `place_curation` 으로 만든다. API·AI 는 이 뷰만 읽는다.
**값의 우선순위**
| 항목 | 규칙 | 화면 표기 |
| --- | --- | --- |
| 영업시간 | 큐레이션 `hours` → 공공 `public_hours`(해석 성공 시) → 미확인 | "11월 2일 확인" / "출처 한국관광공사" / "영업시간 미확인 · 방문 전 확인" |
| 반려동물 | 큐레이션 `pet_policy` → 공공 `public_pet` 있으면 해당 값 → `unknown` | 같은 방식 |
| 실내/실외 | 큐레이션 `setting` → null | |
**노출 플래그** (2026-11-02 합의를 그대로 옮긴 것)
| 플래그 | 조건 | 쓰는 곳 |
| --- | --- | --- |
| `is_searchable` | `status = active` | 검색·지도·상세 |
| `is_home_pick` | `verdict = recommend` **이고 `verified_at` 있음** | 홈 동네 추천 |
| `ai_tier` | 1: `verdict = recommend` 이고 `verified_at` 있음 / 2: `source = tour` 이고 코스 분류(숙박·쇼핑 매장 제외)이며 `verdict` 가 `not_recommend` 가 아님 / null: 그 외(**상가정보 미확인 매장 포함**) | AI 후보 조회 |
- `not_recommend` 는 추천에서만 빠지고 검색에는 남는다.
- 추천(O)만 표시하고 아직 확인하지 않은 상가정보 매장은 AI·홈 어디에도 쓰지 않는다(O/X 표시와 확인은 큐레이션의 서로 다른 단계다). 관광공사 장소는 확인 전이라도 2순위로 남는다.
- 큐레이션 행이 있어도 `verified_at` 이 비었으면 영업시간·반려동물 값을 쓰지 않는다(확인 안 된 값을 확인된 것처럼 보이지 않게).
- 2026-11-02 임시 DB 에서 8가지 경우(관광공사·상가 × 큐레이션 없음/추천+확인/추천만/X/사라짐, 숙소, 점포별 상이)로 규칙을 확인했다.
조회 API 를 만들 때 같은 경우를 DB 를 쓰는 e2e 테스트로 고정한다.
## 4. 동기화
모두 **Nest 애플리케이션 컨텍스트로 도는 CLI 명령**으로 만든다. API 서버 프로세스 안에서 돌리지 않는다. 운영 서버의 cron 이 부른다.
키는 backend env 의 `PUBLIC_DATA_SERVICE_KEY`(이미 example 에 있음). 호출 수는 `place_sync_run.calls` 에 남기고, **오퍼레이션별 하루 상한**(개발 계정 1,000)에 닿으면 멈춘 뒤 다음 날 이어서 한다.
| 작업 | 주기 | 방법 | 대구 규모(2026-11-02) |
| --- | --- | --- | --- |
| 관광공사 국문 초기 적재 | 1회 | `areaBasedList2`(lDongRegnCd=27) 전체 → 항목마다 `detailCommon2`·`detailIntro2` | 목록 1,193건 · 상세 오퍼레이션별 약 1,200회 → **개발 계정으로 2일** |
| 관광공사 국문 변경분 | 매일 | `areaBasedSyncList2`(modifiedtime=어제) → 바뀐 것만 상세 재조회, `showflag=0` → `hidden` | 하루 수십 건 예상 |
| 반려동물 | 매주 | `detailPetTour2` contentId 없이 전체(약 9,700건, 10회) → contentid 로 붙임 | 10회 |
| 관광공사 영문 | 매주 | 영문 `areaBasedList2` → 좌표+분류로 매칭 → `place_i18n(en)` | |
| 상가정보 | 분기 | 대상 행정동 `storeListInDong`(divId=adongCd) × 데이트 업종 → bizesId 로 upsert. **새 스냅샷에 없는 ID → `gone`** | 도심·수성못·앞산 행정동 |
| 축제·행사 | 매일 | `searchFestival2`(오늘−60일 ~ 오늘+60일) | |
| 폐업 대조 | 분기 | 행안부 인허가(휴게음식점·제과점·일반음식점) 영업상태와 주소·상호로 대조 → `gone` 후보 | 활용신청 후 |
- 비표출 레코드는 법정동이 비어 있으므로 비표출 확인은 지역 필터 없이 한다(매뉴얼 검토 노트).
- 동기화는 `place` 만 쓴다. `place_curation` 은 읽지도 쓰지도 않는다.
## 5. 위치 검색
- `lat`·`lng` double precision + 인덱스. 조회는 "경계 사각형으로 1차 필터 → 하버사인 거리로 정렬".
- 대구 규모(수천 건)에서는 충분하다. **PostGIS 는 지금 도입하지 않는다.**
전국 확장으로 수십만 건이 되거나 다각형(동네 경계) 검색이 필요해지면 `DB_IMAGE` 를 PostGIS 이미지로 바꾸고 geography 컬럼 + GiST 인덱스를 추가한다.
## 6. 큐레이션 가져오기
- MVP 는 **CSV 가져오기 명령**으로 시작한다. 운영자 웹 화면은 2단계.
- 입력: `data/curation/<지역>/*.csv` (정렬표·시범표 형식 그대로).
- `(출처, 출처ID)` → `place(source, source_id)` 로 찾는다. 장소가 없으면(아직 적재 전·사라짐) 건너뛰고 보고한다.
- 값 검증: `O/X`, `가능/일부/불가/미확인`, `실내/실외`, `전화/공식채널/방문`, 날짜 형식. 틀린 행은 반영하지 않고 행 번호와 함께 보고한다.
- 먼저 **미리보기(dry-run)** 로 바뀔 내용을 보여 주고, 확인 후 반영한다.
- 큐레이션 표는 운영 자산이다. 저장소에 커밋할지(공공데이터 + 우리가 확인한 사실만 담김), 구글 시트로 옮길지는 따로 정한다.
## 7. 다른 테이블과의 연결 (코스 설계 때 확정)
코스의 장소 한 칸(`course_stop`)은 **둘 중 하나**만 가진다.
- `place_id` — 자체 장소 DB 의 장소
- `kakao_place_id` + `kakao_place_url` + `user_label`(사용자가 직접 입력한 이름) — 카카오 검색으로 직접 추가한 장소
카카오 장소는 이름·주소·좌표를 저장할 수 없다. **지도에 어떻게 표시할지는 코스 설계 때 정한다**(예: 사용자가 지도에서 직접 찍은 위치).
## 8. MVP 에서 하지 않는 것
- 카카오·네이버 데이터 저장, UGC 리뷰
- 운영자 웹 관리 화면(CSV 가져오기로 대신)
- 상가정보 전국 적재(대상 행정동만)
- 입장료·주차 필터의 정규화(`public_extra` 에 원문으로 두고, 디자인의 '무료 입장'·'주차' 필터는 큐레이션 칸 추가 또는 원문 해석으로 2차에 결정)
## 9. 확인이 필요한 것
| 항목 | 언제 |
| --- | --- |
| `lclsSystm` 전체 코드표로 §2.6 분류 확정 | 엔티티 작성 전 (`lclsSystmCode2` 1회 호출) |
| `areaBasedSyncList2` 의 modifiedtime 이 "그날"인지 "그 이후"인지 | 변경분 동기화 첫 실행 |
| 영문 ↔ 국문 매칭률 | 영문 첫 적재 |
| Type3(변경금지) 이미지를 카드에서 잘라 보여 주는 것이 '변경'인지 | 화면 구현 전 (공공누리 기준) |
| 상가정보 반경·행정동 API 가 빠뜨리는 업소가 있는지 | 상가정보 첫 적재 (동 단위 결과와 반경 결과 비교) |
| 큐레이션 표 보관 위치(저장소 / 구글 시트) | 큐레이션 본격 시작 전 |
## 10. 구현 순서 (제안)
1. ✅ 엔티티 + 마이그레이션: `place`, `place_curation`, `place_i18n`, `festival_event`, `place_sync_run`, `place_listing` 뷰 (2026-11-02)
2. 관광공사 국문 초기 적재 명령(대구) — 개발 계정으로 2일
3. 상가정보 적재 명령(대상 행정동)
4. 큐레이션 CSV 가져오기 명령(미리보기 → 반영)
5. 조회 API: `GET /api/places`(검색·필터·커서 페이지네이션), `GET /api/places/{id}`, `GET /api/events`
6. 일·주·분기 동기화 + 폐업 대조