From 753a833c80db6ace7b9e607442abbe2f78d42aa4 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 04:56:06 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(region):=20Region=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EB=AA=A8=EB=8D=B8=C2=B7=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80=20(1=C2=B72?= =?UTF-8?q?=EC=B0=A8=20=EA=B3=84=EC=B8=B5=20+=20Store.region=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 33 +++++++++++++++ prisma/schema.prisma | 42 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 prisma/migrations/20260617194530_add_region_and_store_region/migration.sql diff --git a/prisma/migrations/20260617194530_add_region_and_store_region/migration.sql b/prisma/migrations/20260617194530_add_region_and_store_region/migration.sql new file mode 100644 index 0000000..40813ed --- /dev/null +++ b/prisma/migrations/20260617194530_add_region_and_store_region/migration.sql @@ -0,0 +1,33 @@ +-- AlterTable +ALTER TABLE `store` ADD COLUMN `region_id` BIGINT UNSIGNED NULL; + +-- CreateTable +CREATE TABLE `region` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `parent_id` BIGINT UNSIGNED NULL, + `level` TINYINT UNSIGNED NOT NULL, + `name` VARCHAR(80) NOT NULL, + `slug` VARCHAR(120) NOT NULL, + `sort_order` INTEGER NOT NULL DEFAULT 0, + `is_active` BOOLEAN NOT NULL DEFAULT true, + `center_lat` DECIMAL(10, 7) NULL, + `center_lng` DECIMAL(10, 7) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + `deleted_at` DATETIME(3) NULL, + + UNIQUE INDEX `region_slug_key`(`slug`), + INDEX `idx_region_parent_sort`(`parent_id`, `sort_order`), + INDEX `idx_region_level_active`(`level`, `is_active`), + INDEX `idx_region_deleted_at`(`deleted_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `idx_store_region_id` ON `store`(`region_id`); + +-- AddForeignKey +ALTER TABLE `region` ADD CONSTRAINT `region_parent_id_fkey` FOREIGN KEY (`parent_id`) REFERENCES `region`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `store` ADD CONSTRAINT `store_region_id_fkey` FOREIGN KEY (`region_id`) REFERENCES `region`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a98d96b..bab4b8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,6 +279,43 @@ model AuthRefreshSession { @@map("auth_refresh_session") } +/** + * ========================= + * Region (지역 마스터) + * - level 1 = 1차 광역그룹 (예: 서울 북부, 경기 남부, 인천) + * - level 2 = 2차 시군구 (예: 강남구, 수원시영통구, 강화군) + * - 동(洞)을 직접 보유한 표준 시군구를 2차로 채택. parent_id로 1차에 귀속. + * ========================= + */ + +model Region { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + parent_id BigInt? @db.UnsignedBigInt // null = 1차 광역그룹 + + level Int @db.UnsignedTinyInt + name String @db.VarChar(80) + slug String @unique @db.VarChar(120) + sort_order Int @default(0) + is_active Boolean @default(true) @db.TinyInt + + // 현재위치 기반 추천용 중심좌표. 좌표 데이터는 후속 확보 → 초기엔 비어 있을 수 있음 + center_lat Decimal? @db.Decimal(10, 7) + center_lng Decimal? @db.Decimal(10, 7) + + created_at DateTime @default(now()) @db.DateTime(3) + updated_at DateTime @updatedAt @db.DateTime(3) + deleted_at DateTime? @db.DateTime(3) + + parent Region? @relation("RegionTree", fields: [parent_id], references: [id]) + children Region[] @relation("RegionTree") + stores Store[] + + @@index([parent_id, sort_order], map: "idx_region_parent_sort") + @@index([level, is_active], map: "idx_region_level_active") + @@index([deleted_at], map: "idx_region_deleted_at") + @@map("region") +} + /** * ========================= * 2) Store @@ -297,6 +334,9 @@ model Store { address_district String? @db.VarChar(80) address_neighborhood String? @db.VarChar(80) + // 지역 필터용 2차 시군구(Region level 2). 기존 address_* free-text는 표시·역호환 유지 + region_id BigInt? @db.UnsignedBigInt + latitude Decimal? @db.Decimal(10, 7) longitude Decimal? @db.Decimal(10, 7) @@ -316,6 +356,7 @@ model Store { deleted_at DateTime? @db.DateTime(3) seller_account Account @relation("StoreSellerAccount", fields: [seller_account_id], references: [id]) + region Region? @relation(fields: [region_id], references: [id]) business_hours StoreBusinessHour[] special_closures StoreSpecialClosure[] @@ -333,6 +374,7 @@ model Store { @@index([seller_account_id], map: "idx_store_seller") @@index([store_name], map: "idx_store_name") @@index([address_city, address_district, address_neighborhood], map: "idx_store_region") + @@index([region_id], map: "idx_store_region_id") @@index([deleted_at], map: "idx_store_deleted_at") @@map("store") } From da15f1c7fa3463ceffd2122ff0796781d8359488 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 04:56:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(region):=20=EC=88=98=EB=8F=84=EA=B6=8C?= =?UTF-8?q?=20=EC=A7=80=EC=97=AD=20=EC=8B=9C=EB=93=9C(=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=C2=B7JSON=C2=B7seed)=20+=20=EC=8B=9C=EB=93=9C=20?= =?UTF-8?q?=EB=A7=A4=EC=9E=A5=20region=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + prisma/seed.ts | 4 + prisma/seed/data/regions.generated.json | 612 ++++++++++++++++++++++++ prisma/seed/regions.ts | 76 +++ prisma/seed/stores.ts | 12 + scripts/generate-region-seed.ts | 165 +++++++ 6 files changed, 872 insertions(+) create mode 100644 prisma/seed/data/regions.generated.json create mode 100644 prisma/seed/regions.ts create mode 100644 scripts/generate-region-seed.ts diff --git a/.gitignore b/.gitignore index dfd9e6b..6648997 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ caquick_ddl.sql src/features/example/* .figma/ + +# 지역 시드 원본 CSV (대용량 공공데이터, 파싱 산출 regions.generated.json 만 커밋) +prisma/seed/data/*.csv diff --git a/prisma/seed.ts b/prisma/seed.ts index 590547d..ff0eebf 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -16,6 +16,7 @@ import { resetSeedScope } from './seed/idempotent'; import { seedNotifications } from './seed/notifications'; import { seedOrders } from './seed/orders'; import { seedRecentViews } from './seed/recent-views'; +import { seedRegions } from './seed/regions'; import { seedReviews } from './seed/reviews'; import { seedSearchHistory } from './seed/search-history'; import { seedStores } from './seed/stores'; @@ -35,6 +36,9 @@ async function main(): Promise { log('유저 + 프로필 시드 중...'); const users = await seedUsers(prisma); + log('지역 마스터 시드 중...'); + await seedRegions(prisma); + log('매장 + 상품 시드 중...'); const stores = await seedStores(prisma); diff --git a/prisma/seed/data/regions.generated.json b/prisma/seed/data/regions.generated.json new file mode 100644 index 0000000..ed30364 --- /dev/null +++ b/prisma/seed/data/regions.generated.json @@ -0,0 +1,612 @@ +{ + "generatedFrom": "국토교통부_전국 법정동_20260609.csv", + "note": "수도권(서울/인천/경기) 활성 시군구. 2차=동 보유 표준 시군구. 1차 배정: 서울=한강 북/남, 경기=경기북부청 기준, 인천=단일.", + "level1": [ + { + "slug": "nationwide", + "name": "전국", + "sortOrder": 0 + }, + { + "slug": "seoul-north", + "name": "서울 북부", + "sortOrder": 1 + }, + { + "slug": "seoul-south", + "name": "서울 남부", + "sortOrder": 2 + }, + { + "slug": "gyeonggi-north", + "name": "경기 북부", + "sortOrder": 3 + }, + { + "slug": "gyeonggi-south", + "name": "경기 남부", + "sortOrder": 4 + }, + { + "slug": "incheon", + "name": "인천", + "sortOrder": 5 + } + ], + "level2": [ + { + "slug": "sgg-11110", + "name": "종로구", + "parentSlug": "seoul-north", + "sigunguCode": "11110", + "sortOrder": 0 + }, + { + "slug": "sgg-11140", + "name": "중구", + "parentSlug": "seoul-north", + "sigunguCode": "11140", + "sortOrder": 1 + }, + { + "slug": "sgg-11170", + "name": "용산구", + "parentSlug": "seoul-north", + "sigunguCode": "11170", + "sortOrder": 2 + }, + { + "slug": "sgg-11200", + "name": "성동구", + "parentSlug": "seoul-north", + "sigunguCode": "11200", + "sortOrder": 3 + }, + { + "slug": "sgg-11215", + "name": "광진구", + "parentSlug": "seoul-north", + "sigunguCode": "11215", + "sortOrder": 4 + }, + { + "slug": "sgg-11230", + "name": "동대문구", + "parentSlug": "seoul-north", + "sigunguCode": "11230", + "sortOrder": 5 + }, + { + "slug": "sgg-11260", + "name": "중랑구", + "parentSlug": "seoul-north", + "sigunguCode": "11260", + "sortOrder": 6 + }, + { + "slug": "sgg-11290", + "name": "성북구", + "parentSlug": "seoul-north", + "sigunguCode": "11290", + "sortOrder": 7 + }, + { + "slug": "sgg-11305", + "name": "강북구", + "parentSlug": "seoul-north", + "sigunguCode": "11305", + "sortOrder": 8 + }, + { + "slug": "sgg-11320", + "name": "도봉구", + "parentSlug": "seoul-north", + "sigunguCode": "11320", + "sortOrder": 9 + }, + { + "slug": "sgg-11350", + "name": "노원구", + "parentSlug": "seoul-north", + "sigunguCode": "11350", + "sortOrder": 10 + }, + { + "slug": "sgg-11380", + "name": "은평구", + "parentSlug": "seoul-north", + "sigunguCode": "11380", + "sortOrder": 11 + }, + { + "slug": "sgg-11410", + "name": "서대문구", + "parentSlug": "seoul-north", + "sigunguCode": "11410", + "sortOrder": 12 + }, + { + "slug": "sgg-11440", + "name": "마포구", + "parentSlug": "seoul-north", + "sigunguCode": "11440", + "sortOrder": 13 + }, + { + "slug": "sgg-11470", + "name": "양천구", + "parentSlug": "seoul-south", + "sigunguCode": "11470", + "sortOrder": 14 + }, + { + "slug": "sgg-11500", + "name": "강서구", + "parentSlug": "seoul-south", + "sigunguCode": "11500", + "sortOrder": 15 + }, + { + "slug": "sgg-11530", + "name": "구로구", + "parentSlug": "seoul-south", + "sigunguCode": "11530", + "sortOrder": 16 + }, + { + "slug": "sgg-11545", + "name": "금천구", + "parentSlug": "seoul-south", + "sigunguCode": "11545", + "sortOrder": 17 + }, + { + "slug": "sgg-11560", + "name": "영등포구", + "parentSlug": "seoul-south", + "sigunguCode": "11560", + "sortOrder": 18 + }, + { + "slug": "sgg-11590", + "name": "동작구", + "parentSlug": "seoul-south", + "sigunguCode": "11590", + "sortOrder": 19 + }, + { + "slug": "sgg-11620", + "name": "관악구", + "parentSlug": "seoul-south", + "sigunguCode": "11620", + "sortOrder": 20 + }, + { + "slug": "sgg-11650", + "name": "서초구", + "parentSlug": "seoul-south", + "sigunguCode": "11650", + "sortOrder": 21 + }, + { + "slug": "sgg-11680", + "name": "강남구", + "parentSlug": "seoul-south", + "sigunguCode": "11680", + "sortOrder": 22 + }, + { + "slug": "sgg-11710", + "name": "송파구", + "parentSlug": "seoul-south", + "sigunguCode": "11710", + "sortOrder": 23 + }, + { + "slug": "sgg-11740", + "name": "강동구", + "parentSlug": "seoul-south", + "sigunguCode": "11740", + "sortOrder": 24 + }, + { + "slug": "sgg-28110", + "name": "중구", + "parentSlug": "incheon", + "sigunguCode": "28110", + "sortOrder": 25 + }, + { + "slug": "sgg-28140", + "name": "동구", + "parentSlug": "incheon", + "sigunguCode": "28140", + "sortOrder": 26 + }, + { + "slug": "sgg-28177", + "name": "미추홀구", + "parentSlug": "incheon", + "sigunguCode": "28177", + "sortOrder": 27 + }, + { + "slug": "sgg-28185", + "name": "연수구", + "parentSlug": "incheon", + "sigunguCode": "28185", + "sortOrder": 28 + }, + { + "slug": "sgg-28200", + "name": "남동구", + "parentSlug": "incheon", + "sigunguCode": "28200", + "sortOrder": 29 + }, + { + "slug": "sgg-28237", + "name": "부평구", + "parentSlug": "incheon", + "sigunguCode": "28237", + "sortOrder": 30 + }, + { + "slug": "sgg-28245", + "name": "계양구", + "parentSlug": "incheon", + "sigunguCode": "28245", + "sortOrder": 31 + }, + { + "slug": "sgg-28260", + "name": "서구", + "parentSlug": "incheon", + "sigunguCode": "28260", + "sortOrder": 32 + }, + { + "slug": "sgg-28710", + "name": "강화군", + "parentSlug": "incheon", + "sigunguCode": "28710", + "sortOrder": 33 + }, + { + "slug": "sgg-28720", + "name": "옹진군", + "parentSlug": "incheon", + "sigunguCode": "28720", + "sortOrder": 34 + }, + { + "slug": "sgg-41111", + "name": "수원시장안구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41111", + "sortOrder": 35 + }, + { + "slug": "sgg-41113", + "name": "수원시권선구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41113", + "sortOrder": 36 + }, + { + "slug": "sgg-41115", + "name": "수원시팔달구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41115", + "sortOrder": 37 + }, + { + "slug": "sgg-41117", + "name": "수원시영통구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41117", + "sortOrder": 38 + }, + { + "slug": "sgg-41131", + "name": "성남시수정구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41131", + "sortOrder": 39 + }, + { + "slug": "sgg-41133", + "name": "성남시중원구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41133", + "sortOrder": 40 + }, + { + "slug": "sgg-41135", + "name": "성남시분당구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41135", + "sortOrder": 41 + }, + { + "slug": "sgg-41150", + "name": "의정부시", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41150", + "sortOrder": 42 + }, + { + "slug": "sgg-41171", + "name": "안양시만안구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41171", + "sortOrder": 43 + }, + { + "slug": "sgg-41173", + "name": "안양시동안구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41173", + "sortOrder": 44 + }, + { + "slug": "sgg-41192", + "name": "부천시원미구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41192", + "sortOrder": 45 + }, + { + "slug": "sgg-41194", + "name": "부천시소사구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41194", + "sortOrder": 46 + }, + { + "slug": "sgg-41196", + "name": "부천시오정구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41196", + "sortOrder": 47 + }, + { + "slug": "sgg-41210", + "name": "광명시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41210", + "sortOrder": 48 + }, + { + "slug": "sgg-41220", + "name": "평택시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41220", + "sortOrder": 49 + }, + { + "slug": "sgg-41250", + "name": "동두천시", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41250", + "sortOrder": 50 + }, + { + "slug": "sgg-41271", + "name": "안산시상록구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41271", + "sortOrder": 51 + }, + { + "slug": "sgg-41273", + "name": "안산시단원구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41273", + "sortOrder": 52 + }, + { + "slug": "sgg-41281", + "name": "고양시덕양구", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41281", + "sortOrder": 53 + }, + { + "slug": "sgg-41285", + "name": "고양시일산동구", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41285", + "sortOrder": 54 + }, + { + "slug": "sgg-41287", + "name": "고양시일산서구", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41287", + "sortOrder": 55 + }, + { + "slug": "sgg-41290", + "name": "과천시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41290", + "sortOrder": 56 + }, + { + "slug": "sgg-41310", + "name": "구리시", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41310", + "sortOrder": 57 + }, + { + "slug": "sgg-41360", + "name": "남양주시", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41360", + "sortOrder": 58 + }, + { + "slug": "sgg-41370", + "name": "오산시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41370", + "sortOrder": 59 + }, + { + "slug": "sgg-41390", + "name": "시흥시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41390", + "sortOrder": 60 + }, + { + "slug": "sgg-41410", + "name": "군포시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41410", + "sortOrder": 61 + }, + { + "slug": "sgg-41430", + "name": "의왕시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41430", + "sortOrder": 62 + }, + { + "slug": "sgg-41450", + "name": "하남시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41450", + "sortOrder": 63 + }, + { + "slug": "sgg-41461", + "name": "용인시처인구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41461", + "sortOrder": 64 + }, + { + "slug": "sgg-41463", + "name": "용인시기흥구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41463", + "sortOrder": 65 + }, + { + "slug": "sgg-41465", + "name": "용인시수지구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41465", + "sortOrder": 66 + }, + { + "slug": "sgg-41480", + "name": "파주시", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41480", + "sortOrder": 67 + }, + { + "slug": "sgg-41500", + "name": "이천시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41500", + "sortOrder": 68 + }, + { + "slug": "sgg-41550", + "name": "안성시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41550", + "sortOrder": 69 + }, + { + "slug": "sgg-41570", + "name": "김포시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41570", + "sortOrder": 70 + }, + { + "slug": "sgg-41591", + "name": "화성시만세구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41591", + "sortOrder": 71 + }, + { + "slug": "sgg-41593", + "name": "화성시효행구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41593", + "sortOrder": 72 + }, + { + "slug": "sgg-41595", + "name": "화성시병점구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41595", + "sortOrder": 73 + }, + { + "slug": "sgg-41597", + "name": "화성시동탄구", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41597", + "sortOrder": 74 + }, + { + "slug": "sgg-41610", + "name": "광주시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41610", + "sortOrder": 75 + }, + { + "slug": "sgg-41630", + "name": "양주시", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41630", + "sortOrder": 76 + }, + { + "slug": "sgg-41650", + "name": "포천시", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41650", + "sortOrder": 77 + }, + { + "slug": "sgg-41670", + "name": "여주시", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41670", + "sortOrder": 78 + }, + { + "slug": "sgg-41800", + "name": "연천군", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41800", + "sortOrder": 79 + }, + { + "slug": "sgg-41820", + "name": "가평군", + "parentSlug": "gyeonggi-north", + "sigunguCode": "41820", + "sortOrder": 80 + }, + { + "slug": "sgg-41830", + "name": "양평군", + "parentSlug": "gyeonggi-south", + "sigunguCode": "41830", + "sortOrder": 81 + } + ] +} diff --git a/prisma/seed/regions.ts b/prisma/seed/regions.ts new file mode 100644 index 0000000..1e63d04 --- /dev/null +++ b/prisma/seed/regions.ts @@ -0,0 +1,76 @@ +/** + * 지역(Region) 마스터 시드. + * + * 입력: prisma/seed/data/regions.generated.json (scripts/generate-region-seed.ts 산출물) + * - 1차 광역그룹 → 2차 시군구 순으로 slug 기준 upsert (멱등). + * - 지역은 영구 마스터이므로 resetSeedScope 정리 대상이 아니다. 항상 최신 상태로 보정한다. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { PrismaClient } from '@prisma/client'; + +interface RegionSeedFile { + level1: { slug: string; name: string; sortOrder: number }[]; + level2: { + slug: string; + name: string; + parentSlug: string; + sigunguCode: string; + sortOrder: number; + }[]; +} + +export async function seedRegions(prisma: PrismaClient): Promise { + const filePath = join( + process.cwd(), + 'prisma/seed/data/regions.generated.json', + ); + const data = JSON.parse(readFileSync(filePath, 'utf8')) as RegionSeedFile; + + const slugToId = new Map(); + for (const group of data.level1) { + const region = await prisma.region.upsert({ + where: { slug: group.slug }, + create: { + level: 1, + name: group.name, + slug: group.slug, + sort_order: group.sortOrder, + }, + update: { + name: group.name, + sort_order: group.sortOrder, + is_active: true, + deleted_at: null, + }, + }); + slugToId.set(group.slug, region.id); + } + + for (const child of data.level2) { + const parentId = slugToId.get(child.parentSlug); + if (parentId === undefined) { + throw new Error( + `[seed] region parent not found: ${child.parentSlug} (child ${child.slug})`, + ); + } + await prisma.region.upsert({ + where: { slug: child.slug }, + create: { + level: 2, + name: child.name, + slug: child.slug, + sort_order: child.sortOrder, + parent_id: parentId, + }, + update: { + name: child.name, + sort_order: child.sortOrder, + parent_id: parentId, + is_active: true, + deleted_at: null, + }, + }); + } +} diff --git a/prisma/seed/stores.ts b/prisma/seed/stores.ts index 175a9c7..adffa16 100644 --- a/prisma/seed/stores.ts +++ b/prisma/seed/stores.ts @@ -19,6 +19,16 @@ export interface SeededStores { } export async function seedStores(prisma: PrismaClient): Promise { + // 매장 region 매핑 (seedRegions 선행 실행 전제). Unchecked 경로라 FK를 직접 지정. + const gangnam = await prisma.region.findUniqueOrThrow({ + where: { slug: 'sgg-11680' }, + select: { id: true }, + }); + const mapo = await prisma.region.findUniqueOrThrow({ + where: { slug: 'sgg-11440' }, + select: { id: true }, + }); + // 매장 1: 케이크샵 A (소속 seller 계정 자동 생성) const sellerA = await prisma.account.create({ data: { @@ -37,6 +47,7 @@ export async function seedStores(prisma: PrismaClient): Promise { address_city: '서울특별시', address_district: '강남구', address_neighborhood: '역삼동', + region_id: gangnam.id, // 서울 강남구 latitude: 37.5012 as unknown as never, longitude: 127.0396 as unknown as never, business_hours_text: '매일 09:00 ~ 18:00 (화요일 정기 휴무)', @@ -62,6 +73,7 @@ export async function seedStores(prisma: PrismaClient): Promise { address_city: '서울특별시', address_district: '마포구', address_neighborhood: '서교동', + region_id: mapo.id, // 서울 마포구 business_hours_text: '평일 11:00 ~ 21:00', is_active: true, }, diff --git a/scripts/generate-region-seed.ts b/scripts/generate-region-seed.ts new file mode 100644 index 0000000..43d8ee6 --- /dev/null +++ b/scripts/generate-region-seed.ts @@ -0,0 +1,165 @@ +/** + * 지역(Region) 시드 데이터 생성기. + * + * 입력: 공공데이터포털 "국토교통부_전국 법정동" CSV (gitignored, prisma/seed/data/) + * 출력: prisma/seed/data/regions.generated.json (커밋 대상 — 시드 재현 소스) + * + * 규칙: + * - 수도권(서울/인천/경기)의 "활성(삭제일자 없음)" 법정동만 사용 + * - 2차 지역 = 동(洞)을 직접 보유한 표준 시군구 (경기 일반시는 구 단위: 수원시영통구 등) + * - 1차 배정: 서울=한강 북/남, 경기=경기북부청 관할 기준 북/남, 인천=단일 + * + * 1차 그룹 경계는 운영 정의이므로, 변경 시 이 파일의 SEOUL_NORTH / GYEONGGI_NORTH_CITY 를 + * 고치고 재실행한다. (figma spec에 2차 생활권 큐레이션이 확정되면 별도 반영) + * + * 실행: npx ts-node scripts/generate-region-seed.ts + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const CSV_PATH = join( + process.cwd(), + 'prisma/seed/data/국토교통부_전국 법정동_20260609.csv', +); +const OUT_PATH = join(process.cwd(), 'prisma/seed/data/regions.generated.json'); + +const CAPITAL_SIDO: readonly string[] = ['서울특별시', '인천광역시', '경기도']; + +// 서울 한강 이북 14개 구 → 서울 북부. 나머지 11개 구는 서울 남부. +const SEOUL_NORTH = new Set([ + '종로구', + '중구', + '용산구', + '성동구', + '광진구', + '동대문구', + '중랑구', + '성북구', + '강북구', + '도봉구', + '노원구', + '은평구', + '서대문구', + '마포구', +]); + +// 경기북부청 관할 10개 시·군 → 경기 북부. 나머지는 경기 남부. +const GYEONGGI_NORTH_CITY = new Set([ + '고양시', + '의정부시', + '남양주시', + '파주시', + '구리시', + '포천시', + '양주시', + '동두천시', + '가평군', + '연천군', +]); + +interface Level1 { + slug: string; + name: string; + sortOrder: number; +} + +interface Level2 { + slug: string; + name: string; + parentSlug: string; + sigunguCode: string; + sortOrder: number; +} + +// figma spec 순서: 전국 / 서울 북부 / 서울 남부 / 경기 북부 / 경기 남부 / 인천 +const LEVEL1: Level1[] = [ + { slug: 'nationwide', name: '전국', sortOrder: 0 }, + { slug: 'seoul-north', name: '서울 북부', sortOrder: 1 }, + { slug: 'seoul-south', name: '서울 남부', sortOrder: 2 }, + { slug: 'gyeonggi-north', name: '경기 북부', sortOrder: 3 }, + { slug: 'gyeonggi-south', name: '경기 남부', sortOrder: 4 }, + { slug: 'incheon', name: '인천', sortOrder: 5 }, +]; + +function parentSlugOf(sido: string, sigungu: string): string { + if (sido === '인천광역시') return 'incheon'; + if (sido === '서울특별시') { + return SEOUL_NORTH.has(sigungu) ? 'seoul-north' : 'seoul-south'; + } + // 경기도: "수원시영통구" → "수원시", "가평군" → "가평군" 으로 시/군 단위 추출 + const cityMatch = sigungu.match(/^(.+?[시군])/); + const city = cityMatch ? cityMatch[1] : sigungu; + return GYEONGGI_NORTH_CITY.has(city) ? 'gyeonggi-north' : 'gyeonggi-south'; +} + +interface Sigungu { + sido: string; + sigungu: string; + code5: string; +} + +function main(): void { + const text = readFileSync(CSV_PATH, 'utf8').replace(/^/, ''); + const lines = text.split(/\r?\n/).filter((l) => l.length > 0); + + // code5(시도2+시군구3) 기준 distinct. "동을 직접 보유한 시군구"만 채택. + const seen = new Map(); + + for (let i = 1; i < lines.length; i++) { + const cols = lines[i].split(','); + if (cols.length < 8) continue; + + const code = cols[0]; + const sido = cols[1]; + const sigungu = cols[2]; + const deletedAt = cols[7]; + + if (deletedAt && deletedAt.trim()) continue; // 폐지 제외 + if (!CAPITAL_SIDO.includes(sido)) continue; // 수도권만 + if (!sigungu) continue; + + // 법정동코드 10자리 = 시도2 + 시군구3 + 읍면동3 + 리2 + const sigunguPart = code.slice(2, 5); + const eupmyeonPart = code.slice(5, 8); + const riPart = code.slice(8, 10); + + if (sigunguPart === '000') continue; // 시도 레벨 제외 + if (eupmyeonPart === '000') continue; // 시군구 레벨 제외 (동 미보유 상위 '수원시') + if (riPart !== '00') continue; // 리 레벨 제외 + + const code5 = code.slice(0, 5); + if (!seen.has(code5)) seen.set(code5, { sido, sigungu, code5 }); + } + + const sigungus = [...seen.values()].sort((a, b) => + a.code5.localeCompare(b.code5), + ); + + const level2: Level2[] = sigungus.map((s, idx) => ({ + slug: `sgg-${s.code5}`, + name: s.sigungu, + parentSlug: parentSlugOf(s.sido, s.sigungu), + sigunguCode: s.code5, + sortOrder: idx, + })); + + const out = { + generatedFrom: '국토교통부_전국 법정동_20260609.csv', + note: '수도권(서울/인천/경기) 활성 시군구. 2차=동 보유 표준 시군구. 1차 배정: 서울=한강 북/남, 경기=경기북부청 기준, 인천=단일.', + level1: LEVEL1, + level2, + }; + + writeFileSync(OUT_PATH, `${JSON.stringify(out, null, 2)}\n`, 'utf8'); + + // 검증 출력 + const byParent: Record = {}; + for (const r of level2) + byParent[r.parentSlug] = (byParent[r.parentSlug] ?? 0) + 1; + + console.log('총 2차 시군구:', level2.length); + + console.log('1차별 2차 수:', byParent); +} + +main(); From 735e1559d814f91ffe2c922fff53b8df885ac6e5 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 04:56:14 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(region):=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C/=EA=B2=80=EC=83=89=20GraphQL=20API=20+=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 2 + .../region/constants/region-error-messages.ts | 6 + .../dto/inputs/search-regions.input.spec.ts | 48 ++++ .../region/dto/inputs/search-regions.input.ts | 22 ++ src/features/region/index.ts | 3 + src/features/region/region.module.ts | 11 + src/features/region/region.types.graphql | 39 +++ .../region/repositories/region.repository.ts | 99 +++++++ .../resolvers/region-query.resolver.spec.ts | 77 ++++++ .../region/resolvers/region-query.resolver.ts | 34 +++ .../region/services/region-mappers.helper.ts | 40 +++ .../region/services/region.service.spec.ts | 250 ++++++++++++++++++ .../region/services/region.service.ts | 53 ++++ .../region/types/region-output.type.ts | 26 ++ src/test/factories/index.ts | 1 + src/test/factories/region.factory.ts | 29 ++ 16 files changed, 740 insertions(+) create mode 100644 src/features/region/constants/region-error-messages.ts create mode 100644 src/features/region/dto/inputs/search-regions.input.spec.ts create mode 100644 src/features/region/dto/inputs/search-regions.input.ts create mode 100644 src/features/region/index.ts create mode 100644 src/features/region/region.module.ts create mode 100644 src/features/region/region.types.graphql create mode 100644 src/features/region/repositories/region.repository.ts create mode 100644 src/features/region/resolvers/region-query.resolver.spec.ts create mode 100644 src/features/region/resolvers/region-query.resolver.ts create mode 100644 src/features/region/services/region-mappers.helper.ts create mode 100644 src/features/region/services/region.service.spec.ts create mode 100644 src/features/region/services/region.service.ts create mode 100644 src/features/region/types/region-output.type.ts create mode 100644 src/test/factories/region.factory.ts diff --git a/src/app.module.ts b/src/app.module.ts index 97ee56a..11135f3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import docsConfig from '@/config/docs.config'; import oidcConfig from '@/config/oidc.config'; import s3Config from '@/config/s3.config'; import { AuthModule } from '@/features/auth/auth.module'; +import { RegionModule } from '@/features/region'; import { SellerModule } from '@/features/seller/seller.module'; import { SystemModule } from '@/features/system/system.module'; import { UserModule } from '@/features/user/user.module'; @@ -85,6 +86,7 @@ import { PrismaModule } from '@/prisma'; }), SystemModule, AuthModule, + RegionModule, UserModule, SellerModule, ], diff --git a/src/features/region/constants/region-error-messages.ts b/src/features/region/constants/region-error-messages.ts new file mode 100644 index 0000000..0df6d1a --- /dev/null +++ b/src/features/region/constants/region-error-messages.ts @@ -0,0 +1,6 @@ +export const REGION_ERRORS = { + GROUP_NOT_FOUND: '존재하지 않는 1차 지역입니다.', +} as const; + +/** searchRegions 기본 결과 개수 상한. */ +export const DEFAULT_REGION_SEARCH_LIMIT = 20; diff --git a/src/features/region/dto/inputs/search-regions.input.spec.ts b/src/features/region/dto/inputs/search-regions.input.spec.ts new file mode 100644 index 0000000..0e0625f --- /dev/null +++ b/src/features/region/dto/inputs/search-regions.input.spec.ts @@ -0,0 +1,48 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SearchRegionsInput } from '@/features/region/dto/inputs/search-regions.input'; + +function build(plain: object): SearchRegionsInput { + return plainToInstance(SearchRegionsInput, plain); +} + +describe('SearchRegionsInput', () => { + it('keyword만 있으면 통과 (limit optional)', async () => { + expect(await validate(build({ keyword: '강남' }))).toHaveLength(0); + }); + + it('keyword + 유효 limit 통과', async () => { + expect(await validate(build({ keyword: '강남', limit: 10 }))).toHaveLength( + 0, + ); + }); + + it('keyword 누락 거절', async () => { + const errors = await validate(build({})); + expect(errors.some((e) => e.property === 'keyword')).toBe(true); + }); + + it('keyword 빈 문자열 거절 (MinLength 1)', async () => { + const errors = await validate(build({ keyword: '' })); + expect(errors[0].property).toBe('keyword'); + expect(errors[0].constraints).toHaveProperty('minLength'); + }); + + it('limit 하한(0) 거절', async () => { + const errors = await validate(build({ keyword: 'a', limit: 0 })); + expect(errors[0].property).toBe('limit'); + }); + + it('limit 상한(51) 거절', async () => { + const errors = await validate(build({ keyword: 'a', limit: 51 })); + expect(errors[0].property).toBe('limit'); + }); + + it('limit 정수 아님 거절', async () => { + const errors = await validate(build({ keyword: 'a', limit: 1.5 })); + expect(errors[0].property).toBe('limit'); + }); +}); diff --git a/src/features/region/dto/inputs/search-regions.input.ts b/src/features/region/dto/inputs/search-regions.input.ts new file mode 100644 index 0000000..4ded814 --- /dev/null +++ b/src/features/region/dto/inputs/search-regions.input.ts @@ -0,0 +1,22 @@ +import { + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class SearchRegionsInput { + @IsString() + @MinLength(1) + @MaxLength(80) + keyword!: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/src/features/region/index.ts b/src/features/region/index.ts new file mode 100644 index 0000000..fe7af10 --- /dev/null +++ b/src/features/region/index.ts @@ -0,0 +1,3 @@ +// cross-feature 공개 API. 단일 구현 repo라 토큰/인터페이스 없이 구체 클래스로 주입(의도적). +export { RegionModule } from '@/features/region/region.module'; +export { RegionRepository } from '@/features/region/repositories/region.repository'; diff --git a/src/features/region/region.module.ts b/src/features/region/region.module.ts new file mode 100644 index 0000000..3e79824 --- /dev/null +++ b/src/features/region/region.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { RegionRepository } from '@/features/region/repositories/region.repository'; +import { RegionQueryResolver } from '@/features/region/resolvers/region-query.resolver'; +import { RegionService } from '@/features/region/services/region.service'; + +@Module({ + providers: [RegionRepository, RegionService, RegionQueryResolver], + exports: [RegionRepository], +}) +export class RegionModule {} diff --git a/src/features/region/region.types.graphql b/src/features/region/region.types.graphql new file mode 100644 index 0000000..e575423 --- /dev/null +++ b/src/features/region/region.types.graphql @@ -0,0 +1,39 @@ +extend type Query { + """1차 광역 지역 목록 (전국 포함)""" + regionGroups: [RegionGroup!]! + """특정 1차 지역의 2차 시군구 목록""" + regions(parentId: ID!): [Region!]! + """지역명 자동검색 (1·2차 모두 대상)""" + searchRegions(input: SearchRegionsInput!): [RegionSearchResult!]! +} + +input SearchRegionsInput { + keyword: String! + limit: Int = 20 +} + +"""1차 광역 지역 (예: 서울 북부, 인천)""" +type RegionGroup { + id: ID! + name: String! + slug: String! + """하위 2차 지역 보유 여부 (전국 등은 false)""" + hasChildren: Boolean! +} + +"""2차 시군구 지역 (예: 강남구, 수원시영통구)""" +type Region { + id: ID! + parentId: ID + name: String! + slug: String! + level: Int! +} + +"""지역 검색 결과. 동명 시군구 구분을 위해 상위 지역명을 동반.""" +type RegionSearchResult { + id: ID! + name: String! + parentName: String + level: Int! +} diff --git a/src/features/region/repositories/region.repository.ts b/src/features/region/repositories/region.repository.ts new file mode 100644 index 0000000..5d20ff3 --- /dev/null +++ b/src/features/region/repositories/region.repository.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '@/prisma'; + +export interface RegionRow { + id: bigint; + parent_id: bigint | null; + level: number; + name: string; + slug: string; +} + +export interface RegionGroupRow { + id: bigint; + name: string; + slug: string; + children: { id: bigint }[]; +} + +export interface RegionSearchRow { + id: bigint; + name: string; + level: number; + parent: { name: string } | null; +} + +@Injectable() +export class RegionRepository { + constructor(private readonly prisma: PrismaService) {} + + /** 1차 광역 지역 목록. hasChildren 판정을 위해 활성 2차를 1건만 동반 조회. */ + async findActiveGroups(): Promise { + return this.prisma.region.findMany({ + where: { level: 1, is_active: true, deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { + id: true, + name: true, + slug: true, + children: { + where: { is_active: true, deleted_at: null }, + select: { id: true }, + take: 1, + }, + }, + }); + } + + /** 특정 1차 지역에 속한 활성 2차 시군구 목록. */ + async findActiveChildren(parentId: bigint): Promise { + return this.prisma.region.findMany({ + where: { + parent_id: parentId, + level: 2, + is_active: true, + deleted_at: null, + }, + orderBy: { sort_order: 'asc' }, + select: { + id: true, + parent_id: true, + level: true, + name: true, + slug: true, + }, + }); + } + + /** parentId 유효성 검증용. 활성 1차 지역 존재 여부. */ + async existsActiveGroup(id: bigint): Promise { + const found = await this.prisma.region.findFirst({ + where: { id, level: 1, is_active: true, deleted_at: null }, + select: { id: true }, + }); + return Boolean(found); + } + + /** 지역명 부분일치 검색. 1·2차 모두 대상. 2차는 parent명을 동반 조회. */ + async searchActiveByName( + keyword: string, + limit: number, + ): Promise { + return this.prisma.region.findMany({ + where: { + name: { contains: keyword }, + is_active: true, + deleted_at: null, + }, + orderBy: [{ level: 'asc' }, { sort_order: 'asc' }], + take: limit, + select: { + id: true, + name: true, + level: true, + parent: { select: { name: true } }, + }, + }); + } +} diff --git a/src/features/region/resolvers/region-query.resolver.spec.ts b/src/features/region/resolvers/region-query.resolver.spec.ts new file mode 100644 index 0000000..6a3e6cf --- /dev/null +++ b/src/features/region/resolvers/region-query.resolver.spec.ts @@ -0,0 +1,77 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { RegionRepository } from '@/features/region/repositories/region.repository'; +import { RegionQueryResolver } from '@/features/region/resolvers/region-query.resolver'; +import { RegionService } from '@/features/region/services/region.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createRegion } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기별 세부 검증은 service.spec.ts에서 담당. + */ +describe('Region Query Resolver (real DB)', () => { + let resolver: RegionQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [RegionQueryResolver, RegionService, RegionRepository], + }); + resolver = module.get(RegionQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('regionGroups: 1차 목록을 반환한다', async () => { + await createRegion(prisma, { + level: 1, + name: '전국', + slug: 'nationwide', + sort_order: 0, + }); + + const result = await resolver.regionGroups(); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('전국'); + }); + + it('regions: parentId의 2차 목록을 반환한다', async () => { + const parent = await createRegion(prisma, { level: 1, slug: 'p' }); + await createRegion(prisma, { + level: 2, + name: '강남구', + slug: 'gn', + parent_id: parent.id, + }); + + const result = await resolver.regions(parent.id.toString()); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('강남구'); + }); + + it('regions: 존재하지 않는 parentId면 NotFoundException 전파', async () => { + await expect(resolver.regions('999999')).rejects.toThrow(NotFoundException); + }); + + it('searchRegions: 키워드로 검색 결과를 반환한다', async () => { + await createRegion(prisma, { level: 1, name: '인천', slug: 'incheon' }); + + const result = await resolver.searchRegions({ keyword: '인천' }); + + expect(result[0].name).toBe('인천'); + }); +}); diff --git a/src/features/region/resolvers/region-query.resolver.ts b/src/features/region/resolvers/region-query.resolver.ts new file mode 100644 index 0000000..5bedb7c --- /dev/null +++ b/src/features/region/resolvers/region-query.resolver.ts @@ -0,0 +1,34 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { SearchRegionsInput } from '@/features/region/dto/inputs/search-regions.input'; +import { RegionService } from '@/features/region/services/region.service'; +import type { + RegionGroupOutput, + RegionOutput, + RegionSearchResultOutput, +} from '@/features/region/types/region-output.type'; + +/** + * 지역 조회 resolver. 비로그인도 접근 가능한 public query (가드 없음). + */ +@Resolver('Query') +export class RegionQueryResolver { + constructor(private readonly regionService: RegionService) {} + + @Query('regionGroups') + regionGroups(): Promise { + return this.regionService.regionGroups(); + } + + @Query('regions') + regions(@Args('parentId') parentId: string): Promise { + return this.regionService.regions(parentId); + } + + @Query('searchRegions') + searchRegions( + @Args('input') input: SearchRegionsInput, + ): Promise { + return this.regionService.searchRegions(input); + } +} diff --git a/src/features/region/services/region-mappers.helper.ts b/src/features/region/services/region-mappers.helper.ts new file mode 100644 index 0000000..fcf6edc --- /dev/null +++ b/src/features/region/services/region-mappers.helper.ts @@ -0,0 +1,40 @@ +import type { + RegionGroupRow, + RegionRow, + RegionSearchRow, +} from '@/features/region/repositories/region.repository'; +import type { + RegionGroupOutput, + RegionOutput, + RegionSearchResultOutput, +} from '@/features/region/types/region-output.type'; + +export function toRegionGroupOutput(row: RegionGroupRow): RegionGroupOutput { + return { + id: row.id.toString(), + name: row.name, + slug: row.slug, + hasChildren: row.children.length > 0, + }; +} + +export function toRegionOutput(row: RegionRow): RegionOutput { + return { + id: row.id.toString(), + parentId: row.parent_id?.toString() ?? null, + name: row.name, + slug: row.slug, + level: row.level, + }; +} + +export function toRegionSearchResultOutput( + row: RegionSearchRow, +): RegionSearchResultOutput { + return { + id: row.id.toString(), + name: row.name, + parentName: row.parent?.name ?? null, + level: row.level, + }; +} diff --git a/src/features/region/services/region.service.spec.ts b/src/features/region/services/region.service.spec.ts new file mode 100644 index 0000000..ac35de8 --- /dev/null +++ b/src/features/region/services/region.service.spec.ts @@ -0,0 +1,250 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { RegionRepository } from '@/features/region/repositories/region.repository'; +import { RegionService } from '@/features/region/services/region.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createRegion } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Service ↔ Repository ↔ DB 통합 검증. region feature는 인증이 없는 public 조회라 + * 분기/매핑 검증을 service spec에 집중한다. + */ +describe('RegionService (real DB)', () => { + let service: RegionService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [RegionService, RegionRepository], + }); + service = module.get(RegionService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + describe('regionGroups', () => { + it('1차 지역만 sort_order 순으로 반환하고 hasChildren를 채운다', async () => { + const seoulNorth = await createRegion(prisma, { + level: 1, + name: '서울 북부', + slug: 'seoul-north', + sort_order: 1, + }); + await createRegion(prisma, { + level: 1, + name: '전국', + slug: 'nationwide', + sort_order: 0, + }); + await createRegion(prisma, { + level: 2, + name: '강남구', + slug: 'sgg-11680', + parent_id: seoulNorth.id, + }); + + const result = await service.regionGroups(); + + // sort_order 오름차순: 전국(0) → 서울 북부(1) + expect(result.map((r) => r.slug)).toEqual(['nationwide', 'seoul-north']); + expect(result.find((r) => r.slug === 'seoul-north')?.hasChildren).toBe( + true, + ); + expect(result.find((r) => r.slug === 'nationwide')?.hasChildren).toBe( + false, + ); + }); + + it('비활성/soft-delete 1차는 제외한다', async () => { + await createRegion(prisma, { level: 1, slug: 'active-g' }); + await createRegion(prisma, { + level: 1, + slug: 'inactive-g', + is_active: false, + }); + const deleted = await createRegion(prisma, { + level: 1, + slug: 'deleted-g', + }); + await prisma.region.update({ + where: { id: deleted.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.regionGroups(); + + expect(result.map((r) => r.slug)).toEqual(['active-g']); + }); + + it('soft-delete된 2차는 hasChildren 판정에서 제외된다', async () => { + const group = await createRegion(prisma, { level: 1, slug: 'g-only' }); + const child = await createRegion(prisma, { + level: 2, + slug: 'c-deleted', + parent_id: group.id, + }); + await prisma.region.update({ + where: { id: child.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.regionGroups(); + + expect(result.find((r) => r.slug === 'g-only')?.hasChildren).toBe(false); + }); + }); + + describe('regions', () => { + it('특정 1차의 활성 2차만 sort_order 순으로 반환한다', async () => { + const parent = await createRegion(prisma, { level: 1, slug: 'p1' }); + const other = await createRegion(prisma, { level: 1, slug: 'p2' }); + await createRegion(prisma, { + level: 2, + name: 'B구', + slug: 'b', + parent_id: parent.id, + sort_order: 1, + }); + await createRegion(prisma, { + level: 2, + name: 'A구', + slug: 'a', + parent_id: parent.id, + sort_order: 0, + }); + await createRegion(prisma, { + level: 2, + slug: 'other-child', + parent_id: other.id, + }); + await createRegion(prisma, { + level: 2, + slug: 'inactive-child', + parent_id: parent.id, + is_active: false, + }); + + const result = await service.regions(parent.id.toString()); + + expect(result.map((r) => r.slug)).toEqual(['a', 'b']); + expect(result[0]).toMatchObject({ + name: 'A구', + level: 2, + parentId: parent.id.toString(), + }); + }); + + it('존재하지 않는 1차면 NotFoundException', async () => { + await expect(service.regions('999999')).rejects.toThrow( + NotFoundException, + ); + }); + + it('2차 id를 parentId로 주면 NotFoundException (level 1만 허용)', async () => { + const parent = await createRegion(prisma, { level: 1, slug: 'pp' }); + const child = await createRegion(prisma, { + level: 2, + slug: 'cc', + parent_id: parent.id, + }); + + await expect(service.regions(child.id.toString())).rejects.toThrow( + NotFoundException, + ); + }); + + it('유효하지 않은 parentId 문자열이면 BadRequestException', async () => { + await expect(service.regions('not-a-number')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('searchRegions', () => { + it('이름 부분일치로 2차를 찾고 parentName을 동반한다', async () => { + const incheon = await createRegion(prisma, { + level: 1, + name: '인천', + slug: 'incheon', + }); + await createRegion(prisma, { + level: 2, + name: '중구', + slug: 'sgg-28110', + parent_id: incheon.id, + }); + + const result = await service.searchRegions({ keyword: '중' }); + + const jung = result.find((r) => r.name === '중구'); + expect(jung?.level).toBe(2); + expect(jung?.parentName).toBe('인천'); + }); + + it('1차 결과는 parentName이 null', async () => { + await createRegion(prisma, { + level: 1, + name: '서울 북부', + slug: 'seoul-north', + }); + + const result = await service.searchRegions({ keyword: '서울' }); + + const seoul = result.find((r) => r.name === '서울 북부'); + expect(seoul?.level).toBe(1); + expect(seoul?.parentName).toBeNull(); + }); + + it('빈/공백 검색어는 DB 조회 없이 빈 배열', async () => { + await createRegion(prisma, { level: 1, name: '서울', slug: 's' }); + + expect(await service.searchRegions({ keyword: ' ' })).toEqual([]); + }); + + it('limit으로 결과 개수를 제한한다', async () => { + const parent = await createRegion(prisma, { + level: 1, + name: '테스트시', + slug: 't', + }); + for (let i = 0; i < 5; i++) { + await createRegion(prisma, { + level: 2, + name: `테스트동${i}`, + slug: `t${i}`, + parent_id: parent.id, + sort_order: i, + }); + } + + const result = await service.searchRegions({ + keyword: '테스트', + limit: 3, + }); + + expect(result).toHaveLength(3); + }); + + it('비활성 지역은 검색되지 않는다', async () => { + await createRegion(prisma, { + level: 1, + name: '숨김지역', + slug: 'hidden', + is_active: false, + }); + + expect(await service.searchRegions({ keyword: '숨김' })).toEqual([]); + }); + }); +}); diff --git a/src/features/region/services/region.service.ts b/src/features/region/services/region.service.ts new file mode 100644 index 0000000..fa0a73f --- /dev/null +++ b/src/features/region/services/region.service.ts @@ -0,0 +1,53 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { + DEFAULT_REGION_SEARCH_LIMIT, + REGION_ERRORS, +} from '@/features/region/constants/region-error-messages'; +import type { SearchRegionsInput } from '@/features/region/dto/inputs/search-regions.input'; +import { RegionRepository } from '@/features/region/repositories/region.repository'; +import { + toRegionGroupOutput, + toRegionOutput, + toRegionSearchResultOutput, +} from '@/features/region/services/region-mappers.helper'; +import type { + RegionGroupOutput, + RegionOutput, + RegionSearchResultOutput, +} from '@/features/region/types/region-output.type'; + +@Injectable() +export class RegionService { + constructor(private readonly repo: RegionRepository) {} + + /** 1차 광역 지역 목록 (전국 포함). */ + async regionGroups(): Promise { + const rows = await this.repo.findActiveGroups(); + return rows.map(toRegionGroupOutput); + } + + /** 특정 1차 지역의 2차 시군구 목록. 존재하지 않는 1차면 404. */ + async regions(parentIdStr: string): Promise { + const parentId = parseId(parentIdStr); + const exists = await this.repo.existsActiveGroup(parentId); + if (!exists) { + throw new NotFoundException(REGION_ERRORS.GROUP_NOT_FOUND); + } + const rows = await this.repo.findActiveChildren(parentId); + return rows.map(toRegionOutput); + } + + /** 지역명 자동검색. 빈 검색어는 빈 배열. */ + async searchRegions( + input: SearchRegionsInput, + ): Promise { + const keyword = input.keyword.trim(); + if (!keyword) return []; + + const limit = input.limit ?? DEFAULT_REGION_SEARCH_LIMIT; + const rows = await this.repo.searchActiveByName(keyword, limit); + return rows.map(toRegionSearchResultOutput); + } +} diff --git a/src/features/region/types/region-output.type.ts b/src/features/region/types/region-output.type.ts new file mode 100644 index 0000000..494c8af --- /dev/null +++ b/src/features/region/types/region-output.type.ts @@ -0,0 +1,26 @@ +/** + * region resolver 반환용 도메인 출력 타입. + * SDL(region.types.graphql)의 RegionGroup / Region / RegionSearchResult 와 필드 일치. + */ + +export interface RegionGroupOutput { + id: string; + name: string; + slug: string; + hasChildren: boolean; +} + +export interface RegionOutput { + id: string; + parentId: string | null; + name: string; + slug: string; + level: number; +} + +export interface RegionSearchResultOutput { + id: string; + name: string; + parentName: string | null; + level: number; +} diff --git a/src/test/factories/index.ts b/src/test/factories/index.ts index b671e8d..d110f1f 100644 --- a/src/test/factories/index.ts +++ b/src/test/factories/index.ts @@ -4,6 +4,7 @@ export * from './notification.factory'; export * from './order.factory'; export * from './product.factory'; export * from './recent-product-view.factory'; +export * from './region.factory'; export * from './review.factory'; export * from './search-history.factory'; export * from './seller.factory'; diff --git a/src/test/factories/region.factory.ts b/src/test/factories/region.factory.ts new file mode 100644 index 0000000..9a5bcfd --- /dev/null +++ b/src/test/factories/region.factory.ts @@ -0,0 +1,29 @@ +import type { PrismaClient, Region } from '@prisma/client'; + +import { nextSeq } from '@/test/factories/sequence'; + +export interface RegionOverrides { + parent_id?: bigint | null; + level?: number; + name?: string; + slug?: string; + sort_order?: number; + is_active?: boolean; +} + +export async function createRegion( + prisma: PrismaClient, + overrides: RegionOverrides = {}, +): Promise { + const seq = nextSeq(); + return prisma.region.create({ + data: { + parent_id: overrides.parent_id ?? null, + level: overrides.level ?? 1, + name: overrides.name ?? `Region ${seq}`, + slug: overrides.slug ?? `region-${seq}`, + sort_order: overrides.sort_order ?? 0, + is_active: overrides.is_active ?? true, + }, + }); +}