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:
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user