From 753a833c80db6ace7b9e607442abbe2f78d42aa4 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 04:56:06 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(region):=20Region=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EB=AA=A8=EB=8D=B8=C2=B7=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80=20(1?= =?UTF-8?q?=C2=B72=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 02/10] =?UTF-8?q?feat(region):=20=EC=88=98=EB=8F=84?= =?UTF-8?q?=EA=B6=8C=20=EC=A7=80=EC=97=AD=20=EC=8B=9C=EB=93=9C(=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EA=B8=B0=C2=B7JSON=C2=B7seed)=20+=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=20=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 03/10] =?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, + }, + }); +} From 0f3384963878a9cd94935368d68a179ccff66153 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 05:24:22 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(store):=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EB=A7=A4=EC=9E=A5=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20(popul?= =?UTF-8?q?arStores=C2=B7=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=9E=AD=ED=82=B9?= =?UTF-8?q?=20=EC=A7=91=EA=B3=84)=20+=20=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 + .../constants/store-ranking.constants.ts | 33 ++++ .../dto/inputs/popular-stores.input.spec.ts | 43 +++++ .../store/dto/inputs/popular-stores.input.ts | 26 +++ src/features/store/index.ts | 3 + .../store/repositories/store.repository.ts | 144 ++++++++++++++ .../resolvers/store-query.resolver.spec.ts | 58 ++++++ .../store/resolvers/store-query.resolver.ts | 20 ++ .../services/store-listing.service.spec.ts | 178 ++++++++++++++++++ .../store/services/store-listing.service.ts | 99 ++++++++++ .../services/store-mappers.helper.spec.ts | 69 +++++++ .../store/services/store-mappers.helper.ts | 30 +++ .../services/store-ranking.helper.spec.ts | 59 ++++++ .../store/services/store-ranking.helper.ts | 44 +++++ src/features/store/store.module.ts | 11 ++ src/features/store/store.types.graphql | 33 ++++ src/features/store/types/store-output.type.ts | 21 +++ src/test/factories/index.ts | 1 + src/test/factories/store-wishlist.factory.ts | 28 +++ src/test/factories/store.factory.ts | 2 + 20 files changed, 904 insertions(+) create mode 100644 src/features/store/constants/store-ranking.constants.ts create mode 100644 src/features/store/dto/inputs/popular-stores.input.spec.ts create mode 100644 src/features/store/dto/inputs/popular-stores.input.ts create mode 100644 src/features/store/index.ts create mode 100644 src/features/store/repositories/store.repository.ts create mode 100644 src/features/store/resolvers/store-query.resolver.spec.ts create mode 100644 src/features/store/resolvers/store-query.resolver.ts create mode 100644 src/features/store/services/store-listing.service.spec.ts create mode 100644 src/features/store/services/store-listing.service.ts create mode 100644 src/features/store/services/store-mappers.helper.spec.ts create mode 100644 src/features/store/services/store-mappers.helper.ts create mode 100644 src/features/store/services/store-ranking.helper.spec.ts create mode 100644 src/features/store/services/store-ranking.helper.ts create mode 100644 src/features/store/store.module.ts create mode 100644 src/features/store/store.types.graphql create mode 100644 src/features/store/types/store-output.type.ts create mode 100644 src/test/factories/store-wishlist.factory.ts diff --git a/src/app.module.ts b/src/app.module.ts index 11135f3..d670e5d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ 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 { StoreModule } from '@/features/store'; import { SystemModule } from '@/features/system/system.module'; import { UserModule } from '@/features/user/user.module'; import { AuthGlobalModule } from '@/global/auth/auth-global.module'; @@ -87,6 +88,7 @@ import { PrismaModule } from '@/prisma'; SystemModule, AuthModule, RegionModule, + StoreModule, UserModule, SellerModule, ], diff --git a/src/features/store/constants/store-ranking.constants.ts b/src/features/store/constants/store-ranking.constants.ts new file mode 100644 index 0000000..66b739d --- /dev/null +++ b/src/features/store/constants/store-ranking.constants.ts @@ -0,0 +1,33 @@ +/** + * 인기 매장 랭킹 점수 파라미터. + * + * 점수 = w_order·ln(1+최근주문수) + w_wishlist·ln(1+찜수) + w_rating·베이지안평점 + * 비즈니스 KPI가 확정되면 이 상수를 교체한다(추천 기본값으로 운영). + */ +export const RANKING_WEIGHTS = { + order: 1.0, + wishlist: 0.5, + rating: 0.4, +} as const; + +/** 베이지안 평점 신뢰 임계 리뷰수(prior 가중). 리뷰가 적은 신규 매장 콜드스타트 보정. */ +export const RANKING_BAYESIAN_M = 5; + +/** 최근 주문 집계 기간(일). */ +export const RANKING_RECENT_ORDER_DAYS = 30; + +/** 인기 점수 가중에 포함되는 유효 주문 상태. */ +export const RANKING_VALID_ORDER_STATUSES = [ + 'CONFIRMED', + 'MADE', + 'PICKED_UP', +] as const; + +/** 전체 리뷰가 전무할 때 베이지안 prior로 사용할 기본 평점. */ +export const DEFAULT_GLOBAL_RATING_PRIOR = 4.0; + +/** popularStores 기본 페이지 크기. */ +export const DEFAULT_POPULAR_STORES_LIMIT = 20; + +/** 매장 카드에 노출할 대표 케이크 이미지 최대 장수. */ +export const POPULAR_STORE_CAKE_IMAGE_LIMIT = 4; diff --git a/src/features/store/dto/inputs/popular-stores.input.spec.ts b/src/features/store/dto/inputs/popular-stores.input.spec.ts new file mode 100644 index 0000000..1205bbd --- /dev/null +++ b/src/features/store/dto/inputs/popular-stores.input.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; + +function build(plain: object): PopularStoresInput { + return plainToInstance(PopularStoresInput, plain); +} + +describe('PopularStoresInput', () => { + it('빈 입력 통과 (모두 optional)', async () => { + expect(await validate(build({}))).toHaveLength(0); + }); + + it('regionIds 문자열 배열 + offset/limit 통과', async () => { + const errors = await validate( + build({ regionIds: ['1', '2'], offset: 0, limit: 20 }), + ); + expect(errors).toHaveLength(0); + }); + + it('regionIds 가 배열이 아니면 거절', async () => { + const errors = await validate(build({ regionIds: '1' })); + expect(errors[0].property).toBe('regionIds'); + }); + + it('regionIds 원소가 문자열이 아니면 거절', async () => { + const errors = await validate(build({ regionIds: [1, 2] })); + expect(errors[0].property).toBe('regionIds'); + }); + + it('offset 음수 거절', async () => { + const errors = await validate(build({ offset: -1 })); + expect(errors[0].property).toBe('offset'); + }); + + it('limit 하한(0)·상한(51) 거절', async () => { + expect((await validate(build({ limit: 0 })))[0].property).toBe('limit'); + expect((await validate(build({ limit: 51 })))[0].property).toBe('limit'); + }); +}); diff --git a/src/features/store/dto/inputs/popular-stores.input.ts b/src/features/store/dto/inputs/popular-stores.input.ts new file mode 100644 index 0000000..b8cdc7d --- /dev/null +++ b/src/features/store/dto/inputs/popular-stores.input.ts @@ -0,0 +1,26 @@ +import { + IsArray, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class PopularStoresInput { + @IsOptional() + @IsArray() + @IsString({ each: true }) + regionIds?: string[]; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/src/features/store/index.ts b/src/features/store/index.ts new file mode 100644 index 0000000..ce2acac --- /dev/null +++ b/src/features/store/index.ts @@ -0,0 +1,3 @@ +// cross-feature 공개 API. 단일 구현 repo라 토큰/인터페이스 없이 구체 클래스로 주입(의도적). +export { StoreModule } from '@/features/store/store.module'; +export { StoreRepository } from '@/features/store/repositories/store.repository'; diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts new file mode 100644 index 0000000..c84c45c --- /dev/null +++ b/src/features/store/repositories/store.repository.ts @@ -0,0 +1,144 @@ +import { Injectable } from '@nestjs/common'; + +import { + POPULAR_STORE_CAKE_IMAGE_LIMIT, + RANKING_VALID_ORDER_STATUSES, +} from '@/features/store/constants/store-ranking.constants'; +import { PrismaService } from '@/prisma'; + +export interface StoreCandidateRow { + id: bigint; + store_name: string; + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; +} + +export interface StoreReviewStat { + average: number; + count: number; +} + +@Injectable() +export class StoreRepository { + constructor(private readonly prisma: PrismaService) {} + + /** 인기 매장 랭킹 후보. 활성 매장만, 지역 필터(2차 시군구 다중) 적용. */ + async findActiveStoresForRanking( + regionIds?: bigint[], + ): Promise { + return this.prisma.store.findMany({ + where: { + is_active: true, + deleted_at: null, + ...(regionIds && regionIds.length > 0 + ? { region_id: { in: regionIds } } + : {}), + }, + select: { + id: true, + store_name: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + }, + }); + } + + /** 매장별 활성 찜 수. */ + async aggregateWishlistCounts( + storeIds: bigint[], + ): Promise> { + if (storeIds.length === 0) return new Map(); + const rows = await this.prisma.storeWishlistItem.groupBy({ + by: ['store_id'], + where: { store_id: { in: storeIds }, deleted_at: null }, + _count: { _all: true }, + }); + return new Map(rows.map((r) => [r.store_id, r._count._all])); + } + + /** 매장별 평균 평점·리뷰 수. */ + async aggregateReviewStats( + storeIds: bigint[], + ): Promise> { + if (storeIds.length === 0) return new Map(); + const rows = await this.prisma.review.groupBy({ + by: ['store_id'], + where: { store_id: { in: storeIds }, deleted_at: null }, + _avg: { rating: true }, + _count: { _all: true }, + }); + return new Map( + rows.map((r) => [ + r.store_id, + { + average: r._avg.rating !== null ? Number(r._avg.rating) : 0, + count: r._count._all, + }, + ]), + ); + } + + /** 매장별 최근 N일 유효 주문(아이템) 수. */ + async aggregateRecentOrderCounts( + storeIds: bigint[], + since: Date, + ): Promise> { + if (storeIds.length === 0) return new Map(); + const rows = await this.prisma.orderItem.groupBy({ + by: ['store_id'], + where: { + store_id: { in: storeIds }, + deleted_at: null, + order: { + status: { in: [...RANKING_VALID_ORDER_STATUSES] }, + created_at: { gte: since }, + }, + }, + _count: { _all: true }, + }); + return new Map(rows.map((r) => [r.store_id, r._count._all])); + } + + /** 전체 활성 리뷰 평균 평점(베이지안 prior). 리뷰가 없으면 null. */ + async globalReviewAverage(): Promise { + const agg = await this.prisma.review.aggregate({ + where: { deleted_at: null }, + _avg: { rating: true }, + }); + return agg._avg.rating !== null ? Number(agg._avg.rating) : null; + } + + /** 페이지 매장들의 대표 케이크 이미지(매장당 최대 N장, 활성 상품 1장씩). */ + async findStoreCakeImages( + storeIds: bigint[], + ): Promise> { + if (storeIds.length === 0) return new Map(); + const products = await this.prisma.product.findMany({ + where: { store_id: { in: storeIds }, is_active: true, deleted_at: null }, + orderBy: [{ store_id: 'asc' }, { id: 'desc' }], + select: { + store_id: true, + images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + take: 1, + select: { image_url: true }, + }, + }, + }); + + const map = new Map(); + for (const product of products) { + const url = product.images[0]?.image_url; + if (!url) continue; + const acc = map.get(product.store_id) ?? []; + if (acc.length < POPULAR_STORE_CAKE_IMAGE_LIMIT) { + acc.push(url); + map.set(product.store_id, acc); + } + } + return map; + } +} diff --git a/src/features/store/resolvers/store-query.resolver.spec.ts b/src/features/store/resolvers/store-query.resolver.spec.ts new file mode 100644 index 0000000..d9eea0f --- /dev/null +++ b/src/features/store/resolvers/store-query.resolver.spec.ts @@ -0,0 +1,58 @@ +import type { PrismaClient } from '@prisma/client'; + +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createRegion, createStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('Store Query Resolver (real DB)', () => { + let resolver: StoreQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [StoreQueryResolver, StoreListingService, StoreRepository], + }); + resolver = module.get(StoreQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('popularStores: 서비스에 위임해 커넥션을 반환한다', async () => { + await createStore(prisma, { store_name: '리졸버매장' }); + + const result = await resolver.popularStores(); + + expect(result.totalCount).toBe(1); + expect(result.items[0].storeName).toBe('리졸버매장'); + expect(result.rankedAt).toBeInstanceOf(Date); + }); + + it('popularStores: regionIds 필터를 위임한다', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-res' }); + const target = await createStore(prisma, { region_id: region.id }); + await createStore(prisma); // region 없는 매장 + + const result = await resolver.popularStores({ + regionIds: [region.id.toString()], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(target.id.toString()); + }); +}); diff --git a/src/features/store/resolvers/store-query.resolver.ts b/src/features/store/resolvers/store-query.resolver.ts new file mode 100644 index 0000000..5956804 --- /dev/null +++ b/src/features/store/resolvers/store-query.resolver.ts @@ -0,0 +1,20 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import type { PopularStoreConnection } from '@/features/store/types/store-output.type'; + +/** + * 매장 조회 resolver. 인기 매장 리스트는 비로그인도 접근 가능한 public query. + */ +@Resolver('Query') +export class StoreQueryResolver { + constructor(private readonly storeListingService: StoreListingService) {} + + @Query('popularStores') + popularStores( + @Args('input', { nullable: true }) input?: PopularStoresInput, + ): Promise { + return this.storeListingService.popularStores(input); + } +} diff --git a/src/features/store/services/store-listing.service.spec.ts b/src/features/store/services/store-listing.service.spec.ts new file mode 100644 index 0000000..9375bf5 --- /dev/null +++ b/src/features/store/services/store-listing.service.spec.ts @@ -0,0 +1,178 @@ +import type { PrismaClient } from '@prisma/client'; + +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createOrder, + createOrderItem, + createProduct, + createRegion, + createReview, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('StoreListingService (real DB)', () => { + let service: StoreListingService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [StoreListingService, StoreRepository], + }); + service = module.get(StoreListingService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function reviewStore(storeId: bigint, rating: number): Promise { + const orderItem = await createOrderItem(prisma, { store_id: storeId }); + await createReview(prisma, { order_item_id: orderItem.id, rating }); + } + + describe('popularStores', () => { + it('매장이 없으면 빈 커넥션과 rankedAt을 반환한다', async () => { + const result = await service.popularStores(); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.rankedAt).toBeInstanceOf(Date); + }); + + it('인기순(찜 많은 매장이 상위)으로 정렬하고 rank를 부여한다', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-rank' }); + const hot = await createStore(prisma, { + store_name: '핫', + region_id: region.id, + }); + const cold = await createStore(prisma, { + store_name: '콜드', + region_id: region.id, + }); + for (let i = 0; i < 3; i++) { + await createStoreWishlist(prisma, { store_id: hot.id }); + } + + const result = await service.popularStores(); + + expect(result.items.map((s) => s.storeName)).toEqual(['핫', '콜드']); + expect(result.items[0]).toMatchObject({ + id: hot.id.toString(), + rank: 1, + }); + expect(result.items[1].rank).toBe(2); + expect(cold.id.toString()).toBe(result.items[1].id); + }); + + it('지역 필터(regionIds)에 해당하는 시군구 매장만 반환한다', async () => { + const r1 = await createRegion(prisma, { level: 2, slug: 'sgg-a' }); + const r2 = await createRegion(prisma, { level: 2, slug: 'sgg-b' }); + const target = await createStore(prisma, { region_id: r1.id }); + await createStore(prisma, { region_id: r2.id }); + + const result = await service.popularStores({ + regionIds: [r1.id.toString()], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(target.id.toString()); + }); + + it('평균 평점(소수 첫째)과 리뷰 수를 집계한다', async () => { + const store = await createStore(prisma); + await reviewStore(store.id, 4); + await reviewStore(store.id, 5); + + const result = await service.popularStores(); + const item = result.items.find((s) => s.id === store.id.toString()); + + expect(item?.ratingAverage).toBe(4.5); + expect(item?.reviewCount).toBe(2); + }); + + it('최근 30일 유효 주문만 점수에 반영한다(오래된 주문 매장은 하위)', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-ord' }); + const recent = await createStore(prisma, { + store_name: '최근주문', + region_id: region.id, + }); + const stale = await createStore(prisma, { + store_name: '오래된주문', + region_id: region.id, + }); + + const recentOrder = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: recentOrder.id, + store_id: recent.id, + }); + + const staleOrder = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: staleOrder.id, + store_id: stale.id, + }); + await prisma.order.update({ + where: { id: staleOrder.id }, + data: { created_at: new Date(Date.now() - 40 * DAY_MS) }, + }); + + const result = await service.popularStores(); + + expect(result.items[0].storeName).toBe('최근주문'); + }); + + it('대표 케이크 이미지를 매장당 최대 4장 노출한다', async () => { + const store = await createStore(prisma); + for (let i = 0; i < 5; i++) { + const product = await createProduct(prisma, { store_id: store.id }); + await prisma.productImage.create({ + data: { + product_id: product.id, + image_url: `https://img/${product.id}.png`, + sort_order: 0, + }, + }); + } + + const result = await service.popularStores(); + const item = result.items.find((s) => s.id === store.id.toString()); + + expect(item?.cakeImageUrls).toHaveLength(4); + }); + + it('offset/limit 페이지네이션과 hasMore를 처리한다', async () => { + for (let i = 0; i < 3; i++) { + await createStore(prisma, { store_name: `매장${i}` }); + } + + const result = await service.popularStores({ offset: 0, limit: 2 }); + + expect(result.items).toHaveLength(2); + expect(result.totalCount).toBe(3); + expect(result.hasMore).toBe(true); + }); + + it('비활성 매장은 제외된다', async () => { + await createStore(prisma, { is_active: false }); + + const result = await service.popularStores(); + + expect(result.totalCount).toBe(0); + }); + }); +}); diff --git a/src/features/store/services/store-listing.service.ts b/src/features/store/services/store-listing.service.ts new file mode 100644 index 0000000..a5f0f58 --- /dev/null +++ b/src/features/store/services/store-listing.service.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { + DEFAULT_GLOBAL_RATING_PRIOR, + DEFAULT_POPULAR_STORES_LIMIT, + RANKING_RECENT_ORDER_DAYS, +} from '@/features/store/constants/store-ranking.constants'; +import type { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { toPopularStore } from '@/features/store/services/store-mappers.helper'; +import { + popularityScore, + type StoreMetrics, +} from '@/features/store/services/store-ranking.helper'; +import type { PopularStoreConnection } from '@/features/store/types/store-output.type'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +@Injectable() +export class StoreListingService { + constructor(private readonly repo: StoreRepository) {} + + /** + * 인기 매장 리스트. 후보 매장의 주문·찜·평점을 실시간 집계해 점수화·정렬한 뒤 + * 페이지를 잘라 대표 이미지를 채운다. + * + * 실시간 집계는 매장 규모가 커지면 캐시/배치(스냅샷)로 최적화할 여지가 있다. + */ + async popularStores( + input?: PopularStoresInput, + ): Promise { + const offset = input?.offset ?? 0; + const limit = input?.limit ?? DEFAULT_POPULAR_STORES_LIMIT; + const regionIds = input?.regionIds?.map((id) => parseId(id)); + + const rankedAt = new Date(); + const candidates = await this.repo.findActiveStoresForRanking(regionIds); + if (candidates.length === 0) { + return { items: [], totalCount: 0, hasMore: false, rankedAt }; + } + + const storeIds = candidates.map((c) => c.id); + const since = new Date( + rankedAt.getTime() - RANKING_RECENT_ORDER_DAYS * DAY_MS, + ); + + const [wishlistCounts, reviewStats, orderCounts, globalAverage] = + await Promise.all([ + this.repo.aggregateWishlistCounts(storeIds), + this.repo.aggregateReviewStats(storeIds), + this.repo.aggregateRecentOrderCounts(storeIds, since), + this.repo.globalReviewAverage(), + ]); + const prior = globalAverage ?? DEFAULT_GLOBAL_RATING_PRIOR; + + const scored = candidates.map((candidate) => { + const review = reviewStats.get(candidate.id); + const metrics: StoreMetrics = { + recentOrderCount: orderCounts.get(candidate.id) ?? 0, + wishlistCount: wishlistCounts.get(candidate.id) ?? 0, + ratingAverage: review?.average ?? 0, + reviewCount: review?.count ?? 0, + }; + return { candidate, metrics, score: popularityScore(metrics, prior) }; + }); + + // 점수 desc → 리뷰수 desc → id desc (안정적 동점 처리) + scored.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.metrics.reviewCount !== a.metrics.reviewCount) { + return b.metrics.reviewCount - a.metrics.reviewCount; + } + return b.candidate.id > a.candidate.id ? 1 : -1; + }); + + const totalCount = scored.length; + const page = scored.slice(offset, offset + limit); + const imagesByStore = await this.repo.findStoreCakeImages( + page.map((s) => s.candidate.id), + ); + + const items = page.map((entry, idx) => + toPopularStore( + entry.candidate, + entry.metrics, + offset + idx + 1, + imagesByStore.get(entry.candidate.id) ?? [], + ), + ); + + return { + items, + totalCount, + hasMore: offset + limit < totalCount, + rankedAt, + }; + } +} diff --git a/src/features/store/services/store-mappers.helper.spec.ts b/src/features/store/services/store-mappers.helper.spec.ts new file mode 100644 index 0000000..cc14247 --- /dev/null +++ b/src/features/store/services/store-mappers.helper.spec.ts @@ -0,0 +1,69 @@ +import type { StoreCandidateRow } from '@/features/store/repositories/store.repository'; +import { + buildRegionLabel, + toPopularStore, +} from '@/features/store/services/store-mappers.helper'; + +function row(overrides: Partial): StoreCandidateRow { + return { + id: 1n, + store_name: '매장', + address_city: null, + address_neighborhood: null, + region: null, + ...overrides, + }; +} + +describe('store-mappers.helper', () => { + describe('buildRegionLabel', () => { + it('시/동이 있으면 조합해서 표기한다', () => { + expect( + buildRegionLabel( + row({ address_city: '서울특별시', address_neighborhood: '역삼동' }), + ), + ).toBe('서울특별시 역삼동'); + }); + + it('일부만 있으면 있는 값만 표기한다', () => { + expect(buildRegionLabel(row({ address_neighborhood: '역삼동' }))).toBe( + '역삼동', + ); + }); + + it('주소가 없으면 2차 지역명으로 대체한다', () => { + expect(buildRegionLabel(row({ region: { name: '강남구' } }))).toBe( + '강남구', + ); + }); + + it('주소도 지역도 없으면 null', () => { + expect(buildRegionLabel(row({}))).toBeNull(); + }); + }); + + describe('toPopularStore', () => { + it('평점을 소수 첫째 자리로 반올림하고 rank·이미지를 매핑한다', () => { + const result = toPopularStore( + row({ id: 7n, store_name: '케이크하우스' }), + { + recentOrderCount: 3, + wishlistCount: 2, + ratingAverage: 4.666, + reviewCount: 9, + }, + 1, + ['a.png', 'b.png'], + ); + + expect(result).toMatchObject({ + id: '7', + rank: 1, + storeName: '케이크하우스', + ratingAverage: 4.7, + reviewCount: 9, + cakeImageUrls: ['a.png', 'b.png'], + }); + }); + }); +}); diff --git a/src/features/store/services/store-mappers.helper.ts b/src/features/store/services/store-mappers.helper.ts new file mode 100644 index 0000000..bc5d3d6 --- /dev/null +++ b/src/features/store/services/store-mappers.helper.ts @@ -0,0 +1,30 @@ +import type { StoreCandidateRow } from '@/features/store/repositories/store.repository'; +import type { StoreMetrics } from '@/features/store/services/store-ranking.helper'; +import type { PopularStore } from '@/features/store/types/store-output.type'; + +/** 매장 위치 표기. 시/동 조합 우선, 없으면 2차 지역명. (표기 규칙 확정 전 기본형) */ +export function buildRegionLabel(row: StoreCandidateRow): string | null { + const parts = [row.address_city, row.address_neighborhood].filter( + (p): p is string => Boolean(p), + ); + if (parts.length > 0) return parts.join(' '); + return row.region?.name ?? null; +} + +export function toPopularStore( + row: StoreCandidateRow, + metrics: StoreMetrics, + rank: number, + cakeImageUrls: string[], +): PopularStore { + return { + id: row.id.toString(), + rank, + storeName: row.store_name, + // 소수 첫째 자리까지(예: 4.666 → 4.7) + ratingAverage: Math.round(metrics.ratingAverage * 10) / 10, + reviewCount: metrics.reviewCount, + regionLabel: buildRegionLabel(row), + cakeImageUrls, + }; +} diff --git a/src/features/store/services/store-ranking.helper.spec.ts b/src/features/store/services/store-ranking.helper.spec.ts new file mode 100644 index 0000000..f7f6cc7 --- /dev/null +++ b/src/features/store/services/store-ranking.helper.spec.ts @@ -0,0 +1,59 @@ +import { RANKING_BAYESIAN_M } from '@/features/store/constants/store-ranking.constants'; +import { + bayesianRating, + popularityScore, +} from '@/features/store/services/store-ranking.helper'; + +describe('store-ranking.helper', () => { + describe('bayesianRating', () => { + it('리뷰가 없으면 globalAverage를 그대로 반환한다', () => { + expect(bayesianRating(5, 0, 4.0)).toBe(4.0); + }); + + it('count = m이면 실제 평균과 prior의 중간값', () => { + // (5*5 + 5*3) / (5+5) = 4.0 + expect(bayesianRating(5, RANKING_BAYESIAN_M, 3.0)).toBeCloseTo(4.0); + }); + + it('리뷰가 많을수록 실제 평균에 수렴한다', () => { + const few = bayesianRating(5, 1, 3.0); + const many = bayesianRating(5, 200, 3.0); + expect(many).toBeGreaterThan(few); + expect(many).toBeLessThanOrEqual(5); + }); + }); + + describe('popularityScore', () => { + it('주문·찜·평점이 높을수록 점수가 높다', () => { + const low = popularityScore( + { + recentOrderCount: 0, + wishlistCount: 0, + ratingAverage: 0, + reviewCount: 0, + }, + 4.0, + ); + const high = popularityScore( + { + recentOrderCount: 100, + wishlistCount: 50, + ratingAverage: 5, + reviewCount: 50, + }, + 4.0, + ); + expect(high).toBeGreaterThan(low); + }); + + it('주문 수 기여는 ln으로 체감한다(동일 증가량의 한계효용 감소)', () => { + const base = { wishlistCount: 0, ratingAverage: 0, reviewCount: 0 }; + const at0 = popularityScore({ ...base, recentOrderCount: 0 }, 0); + const at10 = popularityScore({ ...base, recentOrderCount: 10 }, 0); + const at100 = popularityScore({ ...base, recentOrderCount: 100 }, 0); + const at110 = popularityScore({ ...base, recentOrderCount: 110 }, 0); + // 같은 +10 증가라도 낮은 구간(0→10)의 상승폭이 높은 구간(100→110)보다 크다 + expect(at10 - at0).toBeGreaterThan(at110 - at100); + }); + }); +}); diff --git a/src/features/store/services/store-ranking.helper.ts b/src/features/store/services/store-ranking.helper.ts new file mode 100644 index 0000000..453a894 --- /dev/null +++ b/src/features/store/services/store-ranking.helper.ts @@ -0,0 +1,44 @@ +import { + RANKING_BAYESIAN_M, + RANKING_WEIGHTS, +} from '@/features/store/constants/store-ranking.constants'; + +export interface StoreMetrics { + recentOrderCount: number; + wishlistCount: number; + ratingAverage: number; + reviewCount: number; +} + +/** + * 베이지안 평점: 리뷰 수가 적을수록 전체 평균(globalAvg)으로 수축시켜 + * 신규/소량 리뷰 매장이 과대평가되는 것을 막는다. + */ +export function bayesianRating( + average: number, + count: number, + globalAverage: number, + m: number = RANKING_BAYESIAN_M, +): number { + if (count <= 0) return globalAverage; + return (count / (count + m)) * average + (m / (count + m)) * globalAverage; +} + +/** + * 인기 점수. 주문/찜은 ln 으로 롱테일을 완화하고, 평점은 베이지안 보정 후 가중 합산. + */ +export function popularityScore( + metrics: StoreMetrics, + globalAverage: number, +): number { + const bayes = bayesianRating( + metrics.ratingAverage, + metrics.reviewCount, + globalAverage, + ); + return ( + RANKING_WEIGHTS.order * Math.log1p(metrics.recentOrderCount) + + RANKING_WEIGHTS.wishlist * Math.log1p(metrics.wishlistCount) + + RANKING_WEIGHTS.rating * bayes + ); +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts new file mode 100644 index 0000000..d407612 --- /dev/null +++ b/src/features/store/store.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; + +@Module({ + providers: [StoreRepository, StoreListingService, StoreQueryResolver], + exports: [StoreRepository], +}) +export class StoreModule {} diff --git a/src/features/store/store.types.graphql b/src/features/store/store.types.graphql new file mode 100644 index 0000000..54badc9 --- /dev/null +++ b/src/features/store/store.types.graphql @@ -0,0 +1,33 @@ +extend type Query { + """인기 매장 리스트 (지역 필터 + 인기순). 비로그인 접근 가능.""" + popularStores(input: PopularStoresInput): PopularStoreConnection! +} + +input PopularStoresInput { + """2차 시군구 ID 다중 선택. 비우면 전국 대상.""" + regionIds: [ID!] + offset: Int = 0 + limit: Int = 20 +} + +type PopularStoreConnection { + items: [PopularStore!]! + totalCount: Int! + hasMore: Boolean! + """랭킹 산출 기준 시각(서버 조회 시점). 화면의 'N시 기준' 표기에 사용.""" + rankedAt: DateTime! +} + +"""인기 매장 카드""" +type PopularStore { + id: ID! + rank: Int! + storeName: String! + """평균 평점(0.0~5.0, 소수 첫째 자리).""" + ratingAverage: Float! + reviewCount: Int! + """매장 위치 표기(예: 서울특별시 역삼동).""" + regionLabel: String + """대표 케이크 이미지(최대 4장).""" + cakeImageUrls: [String!]! +} diff --git a/src/features/store/types/store-output.type.ts b/src/features/store/types/store-output.type.ts new file mode 100644 index 0000000..83447ee --- /dev/null +++ b/src/features/store/types/store-output.type.ts @@ -0,0 +1,21 @@ +/** + * store resolver 반환용 도메인 출력 타입. + * SDL(store.types.graphql)의 PopularStore / PopularStoreConnection 와 필드 일치. + */ + +export interface PopularStore { + id: string; + rank: number; + storeName: string; + ratingAverage: number; + reviewCount: number; + regionLabel: string | null; + cakeImageUrls: string[]; +} + +export interface PopularStoreConnection { + items: PopularStore[]; + totalCount: number; + hasMore: boolean; + rankedAt: Date; +} diff --git a/src/test/factories/index.ts b/src/test/factories/index.ts index d110f1f..2d46c19 100644 --- a/src/test/factories/index.ts +++ b/src/test/factories/index.ts @@ -9,5 +9,6 @@ export * from './review.factory'; export * from './search-history.factory'; export * from './seller.factory'; export * from './sequence'; +export * from './store-wishlist.factory'; export * from './store.factory'; export * from './user-profile.factory'; diff --git a/src/test/factories/store-wishlist.factory.ts b/src/test/factories/store-wishlist.factory.ts new file mode 100644 index 0000000..e27e63f --- /dev/null +++ b/src/test/factories/store-wishlist.factory.ts @@ -0,0 +1,28 @@ +import type { PrismaClient, StoreWishlistItem } from '@prisma/client'; + +import { createAccount } from '@/test/factories/account.factory'; +import { createStore } from '@/test/factories/store.factory'; + +export interface StoreWishlistOverrides { + account_id?: bigint; + store_id?: bigint; + deleted_at?: Date | null; +} + +export async function createStoreWishlist( + prisma: PrismaClient, + overrides: StoreWishlistOverrides = {}, +): Promise { + const accountId = + overrides.account_id ?? + (await createAccount(prisma, { account_type: 'USER' })).id; + const storeId = overrides.store_id ?? (await createStore(prisma)).id; + + return prisma.storeWishlistItem.create({ + data: { + account_id: accountId, + store_id: storeId, + deleted_at: overrides.deleted_at ?? null, + }, + }); +} diff --git a/src/test/factories/store.factory.ts b/src/test/factories/store.factory.ts index fc41028..bf6574d 100644 --- a/src/test/factories/store.factory.ts +++ b/src/test/factories/store.factory.ts @@ -11,6 +11,7 @@ export interface StoreOverrides { address_city?: string | null; address_district?: string | null; address_neighborhood?: string | null; + region_id?: bigint | null; is_active?: boolean; } @@ -33,6 +34,7 @@ export async function createStore( address_city: overrides.address_city ?? '서울시', address_district: overrides.address_district ?? '테스트구', address_neighborhood: overrides.address_neighborhood ?? '테스트동', + region_id: overrides.region_id ?? null, is_active: overrides.is_active ?? true, }, }); From 4ea7aeb4bdab9648e9269fbcbe7622c2682ad208 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 05:33:46 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix(store):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20soft-delete=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20+=20=EC=BC=80=EC=9D=B4=ED=81=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20take=20=EC=A0=9C=ED=95=9C=20(Code?= =?UTF-8?q?x=20P2=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/repositories/store.repository.ts | 58 +++++++++++-------- .../services/store-listing.service.spec.ts | 32 ++++++++++ 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index c84c45c..7c26baf 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -94,6 +94,9 @@ export class StoreRepository { order: { status: { in: [...RANKING_VALID_ORDER_STATUSES] }, created_at: { gte: since }, + // soft-delete extension은 nested relation filter에 deleted_at을 주입하지 + // 않으므로(=root read만 보정), 삭제된 주문이 랭킹을 부풀리지 않도록 명시한다. + deleted_at: null, }, }, _count: { _all: true }, @@ -115,30 +118,37 @@ export class StoreRepository { storeIds: bigint[], ): Promise> { if (storeIds.length === 0) return new Map(); - const products = await this.prisma.product.findMany({ - where: { store_id: { in: storeIds }, is_active: true, deleted_at: null }, - orderBy: [{ store_id: 'asc' }, { id: 'desc' }], - select: { - store_id: true, - images: { - where: { deleted_at: null }, - orderBy: { sort_order: 'asc' }, - take: 1, - select: { image_url: true }, - }, - }, - }); - const map = new Map(); - for (const product of products) { - const url = product.images[0]?.image_url; - if (!url) continue; - const acc = map.get(product.store_id) ?? []; - if (acc.length < POPULAR_STORE_CAKE_IMAGE_LIMIT) { - acc.push(url); - map.set(product.store_id, acc); - } - } - return map; + // 매장당 이미지 보유 활성 상품을 최대 N개만 조회한다. 전체 상품을 materialize한 + // 뒤 JS에서 자르면 상품이 많은 매장에서 불필요한 row 스캔이 발생하므로, + // 쿼리 단계에서 take로 제한한다(페이지 크기만큼의 병렬 조회). + const entries = await Promise.all( + storeIds.map(async (storeId) => { + const products = await this.prisma.product.findMany({ + where: { + store_id: storeId, + is_active: true, + deleted_at: null, + images: { some: { deleted_at: null } }, + }, + orderBy: { id: 'desc' }, + take: POPULAR_STORE_CAKE_IMAGE_LIMIT, + select: { + images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + take: 1, + select: { image_url: true }, + }, + }, + }); + const urls = products + .map((product) => product.images[0]?.image_url) + .filter((url): url is string => Boolean(url)); + return [storeId, urls] as const; + }), + ); + + return new Map(entries); } } diff --git a/src/features/store/services/store-listing.service.spec.ts b/src/features/store/services/store-listing.service.spec.ts index 9375bf5..ca7c4c3 100644 --- a/src/features/store/services/store-listing.service.spec.ts +++ b/src/features/store/services/store-listing.service.spec.ts @@ -136,6 +136,38 @@ describe('StoreListingService (real DB)', () => { expect(result.items[0].storeName).toBe('최근주문'); }); + it('soft-delete된 주문은 점수에 반영하지 않는다', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-sd' }); + const valid = await createStore(prisma, { + store_name: '유효주문', + region_id: region.id, + }); + const removed = await createStore(prisma, { + store_name: '삭제주문', + region_id: region.id, + }); + + const validOrder = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: validOrder.id, + store_id: valid.id, + }); + + const removedOrder = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: removedOrder.id, + store_id: removed.id, + }); + await prisma.order.update({ + where: { id: removedOrder.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.popularStores(); + + expect(result.items[0].storeName).toBe('유효주문'); + }); + it('대표 케이크 이미지를 매장당 최대 4장 노출한다', async () => { const store = await createStore(prisma); for (let i = 0; i < 5; i++) { From 54344367d505653f38ecba726b7d9e5af6b170de Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 05:58:44 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(store):=20=EB=A7=A4=EC=9E=A5=20?= =?UTF-8?q?=EC=B0=9C=20=ED=86=A0=EA=B8=80(=EB=A9=B1=EB=93=B1)=20+=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EB=A7=A4=EC=9E=A5=20isWishlisted=20(?= =?UTF-8?q?=EC=98=B5=EC=85=94=EB=84=90=20JWT=20=EA=B0=80=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store-wishlist-error-messages.ts | 3 + .../repositories/store-wishlist.repository.ts | 63 ++++++++ .../store/repositories/store.repository.ts | 9 ++ .../resolvers/store-query.resolver.spec.ts | 12 +- .../store/resolvers/store-query.resolver.ts | 13 +- .../store-wishlist-mutation.resolver.spec.ts | 93 ++++++++++++ .../store-wishlist-mutation.resolver.ts | 38 +++++ .../services/store-listing.service.spec.ts | 28 +++- .../store/services/store-listing.service.ts | 21 ++- .../services/store-mappers.helper.spec.ts | 2 + .../store/services/store-mappers.helper.ts | 2 + .../services/store-wishlist.service.spec.ts | 141 ++++++++++++++++++ .../store/services/store-wishlist.service.ts | 46 ++++++ src/features/store/store-wishlist.graphql | 6 + src/features/store/store.module.ts | 12 +- src/features/store/store.types.graphql | 2 + src/features/store/types/store-output.type.ts | 1 + .../guards/optional-jwt-auth.guard.spec.ts | 23 +++ .../auth/guards/optional-jwt-auth.guard.ts | 21 +++ src/global/auth/index.ts | 1 + 20 files changed, 527 insertions(+), 10 deletions(-) create mode 100644 src/features/store/constants/store-wishlist-error-messages.ts create mode 100644 src/features/store/repositories/store-wishlist.repository.ts create mode 100644 src/features/store/resolvers/store-wishlist-mutation.resolver.spec.ts create mode 100644 src/features/store/resolvers/store-wishlist-mutation.resolver.ts create mode 100644 src/features/store/services/store-wishlist.service.spec.ts create mode 100644 src/features/store/services/store-wishlist.service.ts create mode 100644 src/features/store/store-wishlist.graphql create mode 100644 src/global/auth/guards/optional-jwt-auth.guard.spec.ts create mode 100644 src/global/auth/guards/optional-jwt-auth.guard.ts diff --git a/src/features/store/constants/store-wishlist-error-messages.ts b/src/features/store/constants/store-wishlist-error-messages.ts new file mode 100644 index 0000000..ac8b4db --- /dev/null +++ b/src/features/store/constants/store-wishlist-error-messages.ts @@ -0,0 +1,3 @@ +export const STORE_WISHLIST_ERRORS = { + STORE_NOT_FOUND: '존재하지 않는 매장입니다.', +} as const; diff --git a/src/features/store/repositories/store-wishlist.repository.ts b/src/features/store/repositories/store-wishlist.repository.ts new file mode 100644 index 0000000..82174e0 --- /dev/null +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '@/prisma'; + +@Injectable() +export class StoreWishlistRepository { + constructor(private readonly prisma: PrismaService) {} + + /** 매장 찜 추가 (멱등). 없으면 생성, soft-delete된 경우 복원. */ + async upsertStoreWishlist(args: { + accountId: bigint; + storeId: bigint; + now: Date; + }): Promise { + await this.prisma.storeWishlistItem.upsert({ + where: { + account_id_store_id: { + account_id: args.accountId, + store_id: args.storeId, + }, + }, + create: { account_id: args.accountId, store_id: args.storeId }, + update: { deleted_at: null, updated_at: args.now }, + }); + } + + /** 매장 찜 해제 (멱등). active 항목만 soft-delete. */ + async softDeleteStoreWishlist(args: { + accountId: bigint; + storeId: bigint; + now: Date; + }): Promise { + await this.prisma.storeWishlistItem.updateMany({ + where: { + account_id: args.accountId, + store_id: args.storeId, + deleted_at: null, + }, + data: { deleted_at: args.now }, + }); + } + + /** + * 주어진 storeIds 중 사용자가 찜한 store_id 집합(string)을 단일 IN 쿼리로 반환. + * 비활성/soft-delete된 매장은 제외해 목록 가시성과 일관되게 한다(N+1 회피). + */ + async findWishlistedStoreIds(args: { + accountId: bigint; + storeIds: bigint[]; + }): Promise> { + if (args.storeIds.length === 0) return new Set(); + const rows = await this.prisma.storeWishlistItem.findMany({ + where: { + account_id: args.accountId, + store_id: { in: args.storeIds }, + deleted_at: null, + store: { is_active: true, deleted_at: null }, + }, + select: { store_id: true }, + }); + return new Set(rows.map((r) => r.store_id.toString())); + } +} diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 7c26baf..44d55ff 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -45,6 +45,15 @@ export class StoreRepository { }); } + /** 활성 매장 존재 검증(찜 등). */ + async existsActiveStore(storeId: bigint): Promise { + const found = await this.prisma.store.findFirst({ + where: { id: storeId, is_active: true, deleted_at: null }, + select: { id: true }, + }); + return Boolean(found); + } + /** 매장별 활성 찜 수. */ async aggregateWishlistCounts( storeIds: bigint[], diff --git a/src/features/store/resolvers/store-query.resolver.spec.ts b/src/features/store/resolvers/store-query.resolver.spec.ts index d9eea0f..38b6d1d 100644 --- a/src/features/store/resolvers/store-query.resolver.spec.ts +++ b/src/features/store/resolvers/store-query.resolver.spec.ts @@ -1,5 +1,6 @@ import type { PrismaClient } from '@prisma/client'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; import { StoreListingService } from '@/features/store/services/store-listing.service'; @@ -18,7 +19,12 @@ describe('Store Query Resolver (real DB)', () => { beforeAll(async () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ - providers: [StoreQueryResolver, StoreListingService, StoreRepository], + providers: [ + StoreQueryResolver, + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ], }); resolver = module.get(StoreQueryResolver); prisma = p; @@ -36,7 +42,7 @@ describe('Store Query Resolver (real DB)', () => { it('popularStores: 서비스에 위임해 커넥션을 반환한다', async () => { await createStore(prisma, { store_name: '리졸버매장' }); - const result = await resolver.popularStores(); + const result = await resolver.popularStores(undefined); expect(result.totalCount).toBe(1); expect(result.items[0].storeName).toBe('리졸버매장'); @@ -48,7 +54,7 @@ describe('Store Query Resolver (real DB)', () => { const target = await createStore(prisma, { region_id: region.id }); await createStore(prisma); // region 없는 매장 - const result = await resolver.popularStores({ + const result = await resolver.popularStores(undefined, { regionIds: [region.id.toString()], }); diff --git a/src/features/store/resolvers/store-query.resolver.ts b/src/features/store/resolvers/store-query.resolver.ts index 5956804..346906a 100644 --- a/src/features/store/resolvers/store-query.resolver.ts +++ b/src/features/store/resolvers/store-query.resolver.ts @@ -1,20 +1,31 @@ +import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; import { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; import { StoreListingService } from '@/features/store/services/store-listing.service'; import type { PopularStoreConnection } from '@/features/store/types/store-output.type'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; /** * 매장 조회 resolver. 인기 매장 리스트는 비로그인도 접근 가능한 public query. + * 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. */ @Resolver('Query') export class StoreQueryResolver { constructor(private readonly storeListingService: StoreListingService) {} @Query('popularStores') + @UseGuards(OptionalJwtAuthGuard) popularStores( + @CurrentUser() user: JwtUser | undefined, @Args('input', { nullable: true }) input?: PopularStoresInput, ): Promise { - return this.storeListingService.popularStores(input); + const accountId = user ? parseAccountId(user) : undefined; + return this.storeListingService.popularStores(input, accountId); } } diff --git a/src/features/store/resolvers/store-wishlist-mutation.resolver.spec.ts b/src/features/store/resolvers/store-wishlist-mutation.resolver.spec.ts new file mode 100644 index 0000000..21896d0 --- /dev/null +++ b/src/features/store/resolvers/store-wishlist-mutation.resolver.spec.ts @@ -0,0 +1,93 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreWishlistMutationResolver } from '@/features/store/resolvers/store-wishlist-mutation.resolver'; +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('Store Wishlist Mutation Resolver (real DB)', () => { + let resolver: StoreWishlistMutationResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreWishlistMutationResolver, + StoreWishlistService, + StoreWishlistRepository, + StoreRepository, + ], + }); + resolver = module.get(StoreWishlistMutationResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('addStoreToWishlist: accountId 변환 후 찜을 생성한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + const ok = await resolver.addStoreToWishlist( + { accountId: account.id.toString() }, + store.id.toString(), + ); + + expect(ok).toBe(true); + const row = await prisma.storeWishlistItem.findUniqueOrThrow({ + where: { + account_id_store_id: { + account_id: account.id, + store_id: store.id, + }, + }, + }); + expect(row.deleted_at).toBeNull(); + }); + + it('addStoreToWishlist: 없는 매장이면 NotFoundException 전파', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await expect( + resolver.addStoreToWishlist( + { accountId: account.id.toString() }, + '999999', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('removeStoreFromWishlist: 찜을 해제한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const ok = await resolver.removeStoreFromWishlist( + { accountId: account.id.toString() }, + store.id.toString(), + ); + + expect(ok).toBe(true); + const remaining = await prisma.storeWishlistItem.count({ + where: { account_id: account.id, store_id: store.id, deleted_at: null }, + }); + expect(remaining).toBe(0); + }); +}); diff --git a/src/features/store/resolvers/store-wishlist-mutation.resolver.ts b/src/features/store/resolvers/store-wishlist-mutation.resolver.ts new file mode 100644 index 0000000..9bcb5e9 --- /dev/null +++ b/src/features/store/resolvers/store-wishlist-mutation.resolver.ts @@ -0,0 +1,38 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Mutation') +@UseGuards(JwtAuthGuard) +export class StoreWishlistMutationResolver { + constructor(private readonly storeWishlistService: StoreWishlistService) {} + + @Mutation('addStoreToWishlist') + addStoreToWishlist( + @CurrentUser() user: JwtUser, + @Args('storeId') storeId: string, + ): Promise { + return this.storeWishlistService.addStoreToWishlist( + parseAccountId(user), + storeId, + ); + } + + @Mutation('removeStoreFromWishlist') + removeStoreFromWishlist( + @CurrentUser() user: JwtUser, + @Args('storeId') storeId: string, + ): Promise { + return this.storeWishlistService.removeStoreFromWishlist( + parseAccountId(user), + storeId, + ); + } +} diff --git a/src/features/store/services/store-listing.service.spec.ts b/src/features/store/services/store-listing.service.spec.ts index ca7c4c3..16c7110 100644 --- a/src/features/store/services/store-listing.service.spec.ts +++ b/src/features/store/services/store-listing.service.spec.ts @@ -1,10 +1,12 @@ import type { PrismaClient } from '@prisma/client'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreListingService } from '@/features/store/services/store-listing.service'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { + createAccount, createOrder, createOrderItem, createProduct, @@ -23,7 +25,11 @@ describe('StoreListingService (real DB)', () => { beforeAll(async () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ - providers: [StoreListingService, StoreRepository], + providers: [ + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ], }); service = module.get(StoreListingService); prisma = p; @@ -206,5 +212,25 @@ describe('StoreListingService (real DB)', () => { expect(result.totalCount).toBe(0); }); + + it('로그인 사용자의 찜 매장은 isWishlisted=true, 비로그인/미찜은 false', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const wished = await createStore(prisma, { store_name: '찜한매장' }); + await createStore(prisma, { store_name: '안찜매장' }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: wished.id, + }); + + const loggedIn = await service.popularStores(undefined, account.id); + const byName = new Map( + loggedIn.items.map((s) => [s.storeName, s.isWishlisted]), + ); + expect(byName.get('찜한매장')).toBe(true); + expect(byName.get('안찜매장')).toBe(false); + + const anonymous = await service.popularStores(); + expect(anonymous.items.every((s) => s.isWishlisted === false)).toBe(true); + }); }); }); diff --git a/src/features/store/services/store-listing.service.ts b/src/features/store/services/store-listing.service.ts index a5f0f58..3546961 100644 --- a/src/features/store/services/store-listing.service.ts +++ b/src/features/store/services/store-listing.service.ts @@ -7,6 +7,7 @@ import { RANKING_RECENT_ORDER_DAYS, } from '@/features/store/constants/store-ranking.constants'; import type { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { toPopularStore } from '@/features/store/services/store-mappers.helper'; import { @@ -19,7 +20,10 @@ const DAY_MS = 24 * 60 * 60 * 1000; @Injectable() export class StoreListingService { - constructor(private readonly repo: StoreRepository) {} + constructor( + private readonly repo: StoreRepository, + private readonly wishlistRepo: StoreWishlistRepository, + ) {} /** * 인기 매장 리스트. 후보 매장의 주문·찜·평점을 실시간 집계해 점수화·정렬한 뒤 @@ -29,6 +33,7 @@ export class StoreListingService { */ async popularStores( input?: PopularStoresInput, + accountId?: bigint, ): Promise { const offset = input?.offset ?? 0; const limit = input?.limit ?? DEFAULT_POPULAR_STORES_LIMIT; @@ -76,9 +81,16 @@ export class StoreListingService { const totalCount = scored.length; const page = scored.slice(offset, offset + limit); - const imagesByStore = await this.repo.findStoreCakeImages( - page.map((s) => s.candidate.id), - ); + const pageStoreIds = page.map((s) => s.candidate.id); + const [imagesByStore, wishlistedIds] = await Promise.all([ + this.repo.findStoreCakeImages(pageStoreIds), + accountId + ? this.wishlistRepo.findWishlistedStoreIds({ + accountId, + storeIds: pageStoreIds, + }) + : Promise.resolve(new Set()), + ]); const items = page.map((entry, idx) => toPopularStore( @@ -86,6 +98,7 @@ export class StoreListingService { entry.metrics, offset + idx + 1, imagesByStore.get(entry.candidate.id) ?? [], + wishlistedIds.has(entry.candidate.id.toString()), ), ); diff --git a/src/features/store/services/store-mappers.helper.spec.ts b/src/features/store/services/store-mappers.helper.spec.ts index cc14247..9d8f480 100644 --- a/src/features/store/services/store-mappers.helper.spec.ts +++ b/src/features/store/services/store-mappers.helper.spec.ts @@ -54,6 +54,7 @@ describe('store-mappers.helper', () => { }, 1, ['a.png', 'b.png'], + true, ); expect(result).toMatchObject({ @@ -63,6 +64,7 @@ describe('store-mappers.helper', () => { ratingAverage: 4.7, reviewCount: 9, cakeImageUrls: ['a.png', 'b.png'], + isWishlisted: true, }); }); }); diff --git a/src/features/store/services/store-mappers.helper.ts b/src/features/store/services/store-mappers.helper.ts index bc5d3d6..4d7720c 100644 --- a/src/features/store/services/store-mappers.helper.ts +++ b/src/features/store/services/store-mappers.helper.ts @@ -16,6 +16,7 @@ export function toPopularStore( metrics: StoreMetrics, rank: number, cakeImageUrls: string[], + isWishlisted: boolean, ): PopularStore { return { id: row.id.toString(), @@ -26,5 +27,6 @@ export function toPopularStore( reviewCount: metrics.reviewCount, regionLabel: buildRegionLabel(row), cakeImageUrls, + isWishlisted, }; } diff --git a/src/features/store/services/store-wishlist.service.spec.ts b/src/features/store/services/store-wishlist.service.spec.ts new file mode 100644 index 0000000..3e3db6e --- /dev/null +++ b/src/features/store/services/store-wishlist.service.spec.ts @@ -0,0 +1,141 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('StoreWishlistService (real DB)', () => { + let service: StoreWishlistService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreWishlistService, + StoreWishlistRepository, + StoreRepository, + ], + }); + service = module.get(StoreWishlistService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function activeWishlistCount( + accountId: bigint, + storeId: bigint, + ): Promise { + return prisma.storeWishlistItem.count({ + where: { account_id: accountId, store_id: storeId, deleted_at: null }, + }); + } + + describe('addStoreToWishlist', () => { + it('매장을 찜한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + const ok = await service.addStoreToWishlist( + account.id, + store.id.toString(), + ); + + expect(ok).toBe(true); + expect(await activeWishlistCount(account.id, store.id)).toBe(1); + }); + + it('중복 추가는 멱등하다(1건 유지)', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + await service.addStoreToWishlist(account.id, store.id.toString()); + await service.addStoreToWishlist(account.id, store.id.toString()); + + expect(await activeWishlistCount(account.id, store.id)).toBe(1); + }); + + it('soft-delete된 찜은 복원한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + deleted_at: new Date(), + }); + + await service.addStoreToWishlist(account.id, store.id.toString()); + + expect(await activeWishlistCount(account.id, store.id)).toBe(1); + }); + + it('존재하지 않는 매장이면 NotFoundException', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await expect( + service.addStoreToWishlist(account.id, '999999'), + ).rejects.toThrow(NotFoundException); + }); + + it('비활성 매장이면 NotFoundException', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const inactive = await createStore(prisma, { is_active: false }); + await expect( + service.addStoreToWishlist(account.id, inactive.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + + it('유효하지 않은 storeId면 BadRequestException', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await expect( + service.addStoreToWishlist(account.id, 'not-a-number'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('removeStoreFromWishlist', () => { + it('찜을 해제한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const ok = await service.removeStoreFromWishlist( + account.id, + store.id.toString(), + ); + + expect(ok).toBe(true); + expect(await activeWishlistCount(account.id, store.id)).toBe(0); + }); + + it('찜이 없어도 멱등하게 true를 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + const ok = await service.removeStoreFromWishlist( + account.id, + store.id.toString(), + ); + + expect(ok).toBe(true); + }); + }); +}); diff --git a/src/features/store/services/store-wishlist.service.ts b/src/features/store/services/store-wishlist.service.ts new file mode 100644 index 0000000..710751f --- /dev/null +++ b/src/features/store/services/store-wishlist.service.ts @@ -0,0 +1,46 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { STORE_WISHLIST_ERRORS } from '@/features/store/constants/store-wishlist-error-messages'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; + +@Injectable() +export class StoreWishlistService { + constructor( + private readonly wishlistRepo: StoreWishlistRepository, + private readonly storeRepo: StoreRepository, + ) {} + + /** 매장 찜 추가 (멱등). 존재하지 않거나 비활성 매장이면 404. */ + async addStoreToWishlist( + accountId: bigint, + storeIdStr: string, + ): Promise { + const storeId = parseId(storeIdStr); + const exists = await this.storeRepo.existsActiveStore(storeId); + if (!exists) { + throw new NotFoundException(STORE_WISHLIST_ERRORS.STORE_NOT_FOUND); + } + await this.wishlistRepo.upsertStoreWishlist({ + accountId, + storeId, + now: new Date(), + }); + return true; + } + + /** 매장 찜 해제 (멱등). 없는 항목이어도 true. */ + async removeStoreFromWishlist( + accountId: bigint, + storeIdStr: string, + ): Promise { + const storeId = parseId(storeIdStr); + await this.wishlistRepo.softDeleteStoreWishlist({ + accountId, + storeId, + now: new Date(), + }); + return true; + } +} diff --git a/src/features/store/store-wishlist.graphql b/src/features/store/store-wishlist.graphql new file mode 100644 index 0000000..2d9dd0a --- /dev/null +++ b/src/features/store/store-wishlist.graphql @@ -0,0 +1,6 @@ +extend type Mutation { + """매장 찜 추가 (멱등: 이미 있어도 true, soft-delete된 항목은 복원). 로그인 필요.""" + addStoreToWishlist(storeId: ID!): Boolean! + """매장 찜 해제 (멱등: 이미 없어도 true). 로그인 필요.""" + removeStoreFromWishlist(storeId: ID!): Boolean! +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index d407612..a817aa1 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -1,11 +1,21 @@ import { Module } from '@nestjs/common'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; +import { StoreWishlistMutationResolver } from '@/features/store/resolvers/store-wishlist-mutation.resolver'; import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; @Module({ - providers: [StoreRepository, StoreListingService, StoreQueryResolver], + providers: [ + StoreRepository, + StoreWishlistRepository, + StoreListingService, + StoreWishlistService, + StoreQueryResolver, + StoreWishlistMutationResolver, + ], exports: [StoreRepository], }) export class StoreModule {} diff --git a/src/features/store/store.types.graphql b/src/features/store/store.types.graphql index 54badc9..96bc82d 100644 --- a/src/features/store/store.types.graphql +++ b/src/features/store/store.types.graphql @@ -30,4 +30,6 @@ type PopularStore { regionLabel: String """대표 케이크 이미지(최대 4장).""" cakeImageUrls: [String!]! + """로그인 사용자의 찜 여부(비로그인 시 false).""" + isWishlisted: Boolean! } diff --git a/src/features/store/types/store-output.type.ts b/src/features/store/types/store-output.type.ts index 83447ee..8e2ac6d 100644 --- a/src/features/store/types/store-output.type.ts +++ b/src/features/store/types/store-output.type.ts @@ -11,6 +11,7 @@ export interface PopularStore { reviewCount: number; regionLabel: string | null; cakeImageUrls: string[]; + isWishlisted: boolean; } export interface PopularStoreConnection { diff --git a/src/global/auth/guards/optional-jwt-auth.guard.spec.ts b/src/global/auth/guards/optional-jwt-auth.guard.spec.ts new file mode 100644 index 0000000..98bdc91 --- /dev/null +++ b/src/global/auth/guards/optional-jwt-auth.guard.spec.ts @@ -0,0 +1,23 @@ +import { OptionalJwtAuthGuard } from '@/global/auth/guards/optional-jwt-auth.guard'; +import type { JwtUser } from '@/global/auth/types/jwt-payload.type'; + +describe('OptionalJwtAuthGuard', () => { + const guard = new OptionalJwtAuthGuard(); + const user: JwtUser = { accountId: '1', accountType: 'USER' }; + + it('user가 있으면 그대로 반환한다', () => { + expect(guard.handleRequest(null, user)).toBe(user); + }); + + it('user가 false면(토큰 없음) undefined를 반환한다', () => { + expect(guard.handleRequest(null, false)).toBeUndefined(); + }); + + it('user가 null이면 undefined를 반환한다', () => { + expect(guard.handleRequest(null, null)).toBeUndefined(); + }); + + it('에러가 있어도 user가 있으면 통과시킨다(에러를 throw하지 않음)', () => { + expect(guard.handleRequest(new Error('expired'), user)).toBe(user); + }); +}); diff --git a/src/global/auth/guards/optional-jwt-auth.guard.ts b/src/global/auth/guards/optional-jwt-auth.guard.ts new file mode 100644 index 0000000..fe905c8 --- /dev/null +++ b/src/global/auth/guards/optional-jwt-auth.guard.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; + +import { JwtAuthGuard } from '@/global/auth/guards/jwt-auth.guard'; +import type { JwtUser } from '@/global/auth/types/jwt-payload.type'; + +/** + * 옵셔널 JWT 가드. + * + * 토큰이 있으면 인증해 req.user를 채우고, 없거나 검증 실패해도 요청을 통과시킨다. + * 비로그인 접근을 허용하면서 로그인 시에만 부가 정보(예: isWishlisted)를 채우는 + * public query에 사용한다. + */ +@Injectable() +export class OptionalJwtAuthGuard extends JwtAuthGuard { + override handleRequest( + _err: unknown, + user: TUser | false | null, + ): TUser | undefined { + return user || undefined; + } +} diff --git a/src/global/auth/index.ts b/src/global/auth/index.ts index 5f3951c..f1a509d 100644 --- a/src/global/auth/index.ts +++ b/src/global/auth/index.ts @@ -3,6 +3,7 @@ */ export * from '@/global/auth/auth-global.module'; export * from '@/global/auth/guards/jwt-auth.guard'; +export * from '@/global/auth/guards/optional-jwt-auth.guard'; export * from '@/global/auth/decorators/current-user.decorator'; export * from '@/global/auth/types/jwt-payload.type'; export * from '@/global/auth/constants/auth-cookie.constants'; From f80aaa598f852cc56299d6eaa29588c24bbe1928 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 06:06:32 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix(auth):=20OptionalJwtAuthGuard?= =?UTF-8?q?=EB=A5=BC=20AuthGlobalModule=20provider/export=EC=97=90=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20(Codex=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/global/auth/auth-global.module.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/global/auth/auth-global.module.ts b/src/global/auth/auth-global.module.ts index 7721987..6b27bef 100644 --- a/src/global/auth/auth-global.module.ts +++ b/src/global/auth/auth-global.module.ts @@ -4,6 +4,7 @@ import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { JwtAuthGuard } from '@/global/auth/guards/jwt-auth.guard'; +import { OptionalJwtAuthGuard } from '@/global/auth/guards/optional-jwt-auth.guard'; /** * 전역 인증 인프라 모듈 @@ -26,7 +27,7 @@ import { JwtAuthGuard } from '@/global/auth/guards/jwt-auth.guard'; }, }), ], - providers: [JwtAuthGuard], - exports: [JwtAuthGuard, PassportModule, JwtModule], + providers: [JwtAuthGuard, OptionalJwtAuthGuard], + exports: [JwtAuthGuard, OptionalJwtAuthGuard, PassportModule, JwtModule], }) export class AuthGlobalModule {} From fd387f63f7f01ab4c9021c488ca26a679ca6ef76 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 06:28:45 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix(store):=20=EB=A7=A4=EC=9E=A5=20?= =?UTF-8?q?=EC=B0=9C=EC=9D=84=20USER=20=EA=B3=84=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=ED=95=9C=20(Codex=20P2=20-=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20=EB=B3=B4=ED=98=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/store-wishlist-error-messages.ts | 1 + .../repositories/store-wishlist.repository.ts | 9 +++++++++ .../store/services/store-wishlist.service.spec.ts | 14 +++++++++++++- .../store/services/store-wishlist.service.ts | 11 ++++++++++- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/features/store/constants/store-wishlist-error-messages.ts b/src/features/store/constants/store-wishlist-error-messages.ts index ac8b4db..49a6f02 100644 --- a/src/features/store/constants/store-wishlist-error-messages.ts +++ b/src/features/store/constants/store-wishlist-error-messages.ts @@ -1,3 +1,4 @@ export const STORE_WISHLIST_ERRORS = { STORE_NOT_FOUND: '존재하지 않는 매장입니다.', + USER_ONLY: '매장 찜은 일반 사용자만 이용할 수 있습니다.', } as const; diff --git a/src/features/store/repositories/store-wishlist.repository.ts b/src/features/store/repositories/store-wishlist.repository.ts index 82174e0..3d7de06 100644 --- a/src/features/store/repositories/store-wishlist.repository.ts +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -60,4 +60,13 @@ export class StoreWishlistRepository { }); return new Set(rows.map((r) => r.store_id.toString())); } + + /** 활성 USER 계정 여부. 매장 찜은 구매자(USER)만 가능 → 인기 랭킹 무결성 보호. */ + async isActiveUserAccount(accountId: bigint): Promise { + const account = await this.prisma.account.findFirst({ + where: { id: accountId, account_type: 'USER', deleted_at: null }, + select: { id: true }, + }); + return Boolean(account); + } } diff --git a/src/features/store/services/store-wishlist.service.spec.ts b/src/features/store/services/store-wishlist.service.spec.ts index 3e3db6e..ec3e719 100644 --- a/src/features/store/services/store-wishlist.service.spec.ts +++ b/src/features/store/services/store-wishlist.service.spec.ts @@ -1,4 +1,8 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; @@ -106,6 +110,14 @@ describe('StoreWishlistService (real DB)', () => { service.addStoreToWishlist(account.id, 'not-a-number'), ).rejects.toThrow(BadRequestException); }); + + it('USER가 아닌 계정(SELLER)은 찜할 수 없다(Forbidden)', async () => { + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + const store = await createStore(prisma); + await expect( + service.addStoreToWishlist(seller.id, store.id.toString()), + ).rejects.toThrow(ForbiddenException); + }); }); describe('removeStoreFromWishlist', () => { diff --git a/src/features/store/services/store-wishlist.service.ts b/src/features/store/services/store-wishlist.service.ts index 710751f..4f7ca96 100644 --- a/src/features/store/services/store-wishlist.service.ts +++ b/src/features/store/services/store-wishlist.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { parseId } from '@/common/utils/id-parser'; import { STORE_WISHLIST_ERRORS } from '@/features/store/constants/store-wishlist-error-messages'; @@ -17,6 +21,11 @@ export class StoreWishlistService { accountId: bigint, storeIdStr: string, ): Promise { + // 매장 찜은 구매자(USER)만 가능. SELLER/ADMIN 찜이 인기 랭킹을 조작하지 못하도록 차단. + const isUser = await this.wishlistRepo.isActiveUserAccount(accountId); + if (!isUser) { + throw new ForbiddenException(STORE_WISHLIST_ERRORS.USER_ONLY); + } const storeId = parseId(storeIdStr); const exists = await this.storeRepo.existsActiveStore(storeId); if (!exists) { From ff3b8ff0fbe752f2167298a19f64deee3429ffee Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 06:51:27 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat(pickup):=20=ED=99=88=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=ED=94=BD=EC=97=85=20=EB=82=A0=EC=A7=9C/=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=8A=AC=EB=A1=AF=20(pickupCalendar/pickupTimeSlot?= =?UTF-8?q?s)=20+=20KST=20=EC=9C=A0=ED=8B=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 2 + src/common/utils/kst-time.spec.ts | 93 +++++++++++++++ src/common/utils/kst-time.ts | 88 ++++++++++++++ .../pickup/constants/pickup-error-messages.ts | 10 ++ .../pickup/constants/pickup.constants.ts | 17 +++ src/features/pickup/index.ts | 1 + src/features/pickup/pickup.module.ts | 9 ++ src/features/pickup/pickup.types.graphql | 29 +++++ .../resolvers/pickup-query.resolver.spec.ts | 20 ++++ .../pickup/resolvers/pickup-query.resolver.ts | 25 ++++ .../services/pickup-slot.service.spec.ts | 100 ++++++++++++++++ .../pickup/services/pickup-slot.service.ts | 107 ++++++++++++++++++ .../pickup/types/pickup-output.type.ts | 26 +++++ 13 files changed, 527 insertions(+) create mode 100644 src/common/utils/kst-time.spec.ts create mode 100644 src/common/utils/kst-time.ts create mode 100644 src/features/pickup/constants/pickup-error-messages.ts create mode 100644 src/features/pickup/constants/pickup.constants.ts create mode 100644 src/features/pickup/index.ts create mode 100644 src/features/pickup/pickup.module.ts create mode 100644 src/features/pickup/pickup.types.graphql create mode 100644 src/features/pickup/resolvers/pickup-query.resolver.spec.ts create mode 100644 src/features/pickup/resolvers/pickup-query.resolver.ts create mode 100644 src/features/pickup/services/pickup-slot.service.spec.ts create mode 100644 src/features/pickup/services/pickup-slot.service.ts create mode 100644 src/features/pickup/types/pickup-output.type.ts diff --git a/src/app.module.ts b/src/app.module.ts index d670e5d..8be329e 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 { PickupModule } from '@/features/pickup'; import { RegionModule } from '@/features/region'; import { SellerModule } from '@/features/seller/seller.module'; import { StoreModule } from '@/features/store'; @@ -87,6 +88,7 @@ import { PrismaModule } from '@/prisma'; }), SystemModule, AuthModule, + PickupModule, RegionModule, StoreModule, UserModule, diff --git a/src/common/utils/kst-time.spec.ts b/src/common/utils/kst-time.spec.ts new file mode 100644 index 0000000..cb88b95 --- /dev/null +++ b/src/common/utils/kst-time.spec.ts @@ -0,0 +1,93 @@ +import { + formatKstDate, + formatMinutesOfDay, + kstDayDiff, + kstMidnightUtc, + kstMinutesOfDay, + parseKstDate, + parseKstYearMonth, + toKstYmd, +} from '@/common/utils/kst-time'; + +describe('kst-time', () => { + describe('toKstYmd / formatKstDate (KST 경계)', () => { + it('UTC 15:00은 KST 다음날 00:00이다', () => { + const date = new Date('2026-06-17T15:00:00.000Z'); + expect(toKstYmd(date)).toEqual({ year: 2026, month: 6, day: 18 }); + expect(formatKstDate(date)).toBe('2026-06-18'); + }); + + it('UTC 14:59:59는 아직 KST 같은 날이다', () => { + const date = new Date('2026-06-17T14:59:59.000Z'); + expect(formatKstDate(date)).toBe('2026-06-17'); + }); + }); + + describe('kstMinutesOfDay', () => { + it('KST 시각의 자정 경과 분을 반환한다', () => { + // UTC 01:30 = KST 10:30 = 630분 + expect(kstMinutesOfDay(new Date('2026-06-18T01:30:00.000Z'))).toBe(630); + }); + }); + + describe('parseKstYearMonth', () => { + it('유효한 YYYY-MM 파싱', () => { + expect(parseKstYearMonth('2026-06')).toEqual({ year: 2026, month: 6 }); + }); + it('형식/범위 오류는 null', () => { + expect(parseKstYearMonth('2026-13')).toBeNull(); + expect(parseKstYearMonth('2026-6')).toBeNull(); + expect(parseKstYearMonth('not-a-month')).toBeNull(); + }); + }); + + describe('parseKstDate', () => { + it('KST 자정에 해당하는 UTC Date로 변환한다', () => { + // 2026-06-18 00:00 KST = 2026-06-17 15:00 UTC + const date = parseKstDate('2026-06-18'); + expect(date?.toISOString()).toBe('2026-06-17T15:00:00.000Z'); + }); + it('존재하지 않는 날짜(2026-02-30)는 null', () => { + expect(parseKstDate('2026-02-30')).toBeNull(); + }); + it('형식 오류는 null', () => { + expect(parseKstDate('2026/06/18')).toBeNull(); + expect(parseKstDate('2026-06')).toBeNull(); + }); + }); + + describe('kstMidnightUtc', () => { + it('KST 자정에 해당하는 UTC Date를 만든다', () => { + expect(kstMidnightUtc(2026, 6, 18).toISOString()).toBe( + '2026-06-17T15:00:00.000Z', + ); + }); + }); + + describe('kstDayDiff', () => { + it('같은 KST 날짜는 0', () => { + const a = new Date('2026-06-18T01:00:00.000Z'); // KST 06-18 10:00 + const b = new Date('2026-06-18T10:00:00.000Z'); // KST 06-18 19:00 + expect(kstDayDiff(a, b)).toBe(0); + }); + it('다음날은 1, 이전날은 -1', () => { + const today = new Date('2026-06-18T01:00:00.000Z'); // KST 06-18 + expect(kstDayDiff(today, new Date('2026-06-19T01:00:00.000Z'))).toBe(1); + expect(kstDayDiff(today, new Date('2026-06-17T01:00:00.000Z'))).toBe(-1); + }); + it('UTC 자정 경계를 넘어도 KST 기준으로 센다', () => { + // UTC 15:00 = KST 익일 00:00 → 하루 차이 + const a = new Date('2026-06-17T14:00:00.000Z'); // KST 06-17 23:00 + const b = new Date('2026-06-17T15:00:00.000Z'); // KST 06-18 00:00 + expect(kstDayDiff(a, b)).toBe(1); + }); + }); + + describe('formatMinutesOfDay', () => { + it('분을 HH:MM으로 포맷', () => { + expect(formatMinutesOfDay(630)).toBe('10:30'); + expect(formatMinutesOfDay(0)).toBe('00:00'); + expect(formatMinutesOfDay(1170)).toBe('19:30'); + }); + }); +}); diff --git a/src/common/utils/kst-time.ts b/src/common/utils/kst-time.ts new file mode 100644 index 0000000..b2f8996 --- /dev/null +++ b/src/common/utils/kst-time.ts @@ -0,0 +1,88 @@ +/** + * KST(Asia/Seoul, UTC+9) 기준 날짜/시간 유틸. + * + * 서버 타임존과 무관하게 동작하도록, 내부적으로 UTC epoch에 +9h 오프셋을 적용해 + * KST 달력값을 계산한다. 모든 함수는 순수 함수(입력 Date만 사용)이며, "현재 시각"이 + * 필요한 호출부는 now를 주입해 결정적으로 테스트한다. + */ +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +function pad2(value: number): string { + return value.toString().padStart(2, '0'); +} + +export interface KstYmd { + year: number; + month: number; // 1-12 + day: number; +} + +/** UTC Date를 KST 기준 연/월/일로 분해한다. */ +export function toKstYmd(date: Date): KstYmd { + const kst = new Date(date.getTime() + KST_OFFSET_MS); + return { + year: kst.getUTCFullYear(), + month: kst.getUTCMonth() + 1, + day: kst.getUTCDate(), + }; +} + +/** KST 기준 (year, month, day) 자정에 해당하는 UTC Date. month는 1-12. */ +export function kstMidnightUtc(year: number, month: number, day: number): Date { + return new Date(Date.UTC(year, month - 1, day) - KST_OFFSET_MS); +} + +/** KST 기준 "YYYY-MM-DD" 문자열. */ +export function formatKstDate(date: Date): string { + const { year, month, day } = toKstYmd(date); + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +/** KST 기준 자정부터의 경과 분(0-1439). */ +export function kstMinutesOfDay(date: Date): number { + const kst = new Date(date.getTime() + KST_OFFSET_MS); + return kst.getUTCHours() * 60 + kst.getUTCMinutes(); +} + +/** "YYYY-MM" 파싱. 형식/범위 오류 시 null. */ +export function parseKstYearMonth( + value: string, +): { year: number; month: number } | null { + const matched = /^(\d{4})-(\d{2})$/.exec(value); + if (!matched) return null; + const year = Number(matched[1]); + const month = Number(matched[2]); + if (month < 1 || month > 12) return null; + return { year, month }; +} + +/** "YYYY-MM-DD"(KST)를 그 날 00:00 KST에 해당하는 UTC Date로 변환. 잘못된 날짜는 null. */ +export function parseKstDate(value: string): Date | null { + const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!matched) return null; + const year = Number(matched[1]); + const month = Number(matched[2]); + const day = Number(matched[3]); + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + + const date = kstMidnightUtc(year, month, day); + // 존재하지 않는 날짜(예: 2026-02-30)는 정규화되며 입력과 불일치 → 거부 + if (formatKstDate(date) !== value) return null; + return date; +} + +/** KST 자정 기준 (b 날짜 - a 날짜) 일수. 같은 날 0. */ +export function kstDayDiff(a: Date, b: Date): number { + const startA = parseKstDate(formatKstDate(a)); + const startB = parseKstDate(formatKstDate(b)); + if (!startA || !startB) return 0; + return Math.round((startB.getTime() - startA.getTime()) / DAY_MS); +} + +/** 자정 경과 분(0-1439)을 "HH:MM"으로 포맷. */ +export function formatMinutesOfDay(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${pad2(hours)}:${pad2(mins)}`; +} diff --git a/src/features/pickup/constants/pickup-error-messages.ts b/src/features/pickup/constants/pickup-error-messages.ts new file mode 100644 index 0000000..0f22905 --- /dev/null +++ b/src/features/pickup/constants/pickup-error-messages.ts @@ -0,0 +1,10 @@ +export const PICKUP_ERRORS = { + INVALID_YEAR_MONTH: '유효하지 않은 연월 형식입니다. (YYYY-MM)', + INVALID_DATE: '유효하지 않은 날짜 형식입니다. (YYYY-MM-DD)', +} as const; + +/** 선택 불가 날짜 사유 코드. */ +export const PICKUP_DAY_REASON = { + PAST: 'PAST', + OUT_OF_RANGE: 'OUT_OF_RANGE', +} as const; diff --git a/src/features/pickup/constants/pickup.constants.ts b/src/features/pickup/constants/pickup.constants.ts new file mode 100644 index 0000000..ce9c421 --- /dev/null +++ b/src/features/pickup/constants/pickup.constants.ts @@ -0,0 +1,17 @@ +/** + * 홈 전역 픽업 슬롯 정책(매장 무관 고정값). + * + * 화면(05)의 달력·시간 선택 UX 제공용. 실제 매장별 영업시간·capacity·휴무 반영은 + * 주문 단계에서 매장 정책으로 별도 검증한다(이 feature 범위 밖). + * 정책값은 기획 확정 시 이 상수를 교체한다. + */ +export const PICKUP_OPEN_MINUTES = 10 * 60; // 10:00 +export const PICKUP_CLOSE_MINUTES = 20 * 60; // 20:00 (미포함 → 마지막 슬롯 19:30) +export const PICKUP_SLOT_INTERVAL_MINUTES = 30; +export const PICKUP_AFTERNOON_START_MINUTES = 12 * 60; // 12:00 (오전/오후 경계) + +/** 오늘부터 선택 가능한 최대 일수. */ +export const PICKUP_MAX_DAYS_AHEAD = 30; + +/** 당일 픽업 최소 리드타임(분). 현재시각 + 이 값 이전 슬롯은 마감. */ +export const PICKUP_MIN_LEAD_MINUTES = 60; diff --git a/src/features/pickup/index.ts b/src/features/pickup/index.ts new file mode 100644 index 0000000..9283a22 --- /dev/null +++ b/src/features/pickup/index.ts @@ -0,0 +1 @@ +export { PickupModule } from '@/features/pickup/pickup.module'; diff --git a/src/features/pickup/pickup.module.ts b/src/features/pickup/pickup.module.ts new file mode 100644 index 0000000..eec0c72 --- /dev/null +++ b/src/features/pickup/pickup.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { PickupQueryResolver } from '@/features/pickup/resolvers/pickup-query.resolver'; +import { PickupSlotService } from '@/features/pickup/services/pickup-slot.service'; + +@Module({ + providers: [PickupSlotService, PickupQueryResolver], +}) +export class PickupModule {} diff --git a/src/features/pickup/pickup.types.graphql b/src/features/pickup/pickup.types.graphql new file mode 100644 index 0000000..241d6b3 --- /dev/null +++ b/src/features/pickup/pickup.types.graphql @@ -0,0 +1,29 @@ +extend type Query { + """월별 픽업 가능 날짜 (홈 전역 정책 기준). yearMonth: "YYYY-MM" """ + pickupCalendar(yearMonth: String!): PickupCalendar! + """선택 날짜의 픽업 시간 슬롯. date: "YYYY-MM-DD" """ + pickupTimeSlots(date: String!): PickupTimeSlots! +} + +type PickupCalendar { + yearMonth: String! + days: [PickupDay!]! +} + +type PickupDay { + date: String! + selectable: Boolean! + """선택 불가 사유. PAST | OUT_OF_RANGE (선택 가능 시 null).""" + reason: String +} + +type PickupTimeSlots { + date: String! + morning: [PickupSlot!]! + afternoon: [PickupSlot!]! +} + +type PickupSlot { + time: String! + available: Boolean! +} diff --git a/src/features/pickup/resolvers/pickup-query.resolver.spec.ts b/src/features/pickup/resolvers/pickup-query.resolver.spec.ts new file mode 100644 index 0000000..1a17d66 --- /dev/null +++ b/src/features/pickup/resolvers/pickup-query.resolver.spec.ts @@ -0,0 +1,20 @@ +import { PickupQueryResolver } from '@/features/pickup/resolvers/pickup-query.resolver'; +import { PickupSlotService } from '@/features/pickup/services/pickup-slot.service'; + +describe('PickupQueryResolver', () => { + const service = new PickupSlotService(); + const resolver = new PickupQueryResolver(service); + + it('pickupCalendar: 서비스에 위임해 월 달력을 반환한다', () => { + const result = resolver.pickupCalendar('2026-06'); + expect(result.yearMonth).toBe('2026-06'); + expect(result.days).toHaveLength(30); + }); + + it('pickupTimeSlots: 서비스에 위임해 시간 슬롯을 반환한다', () => { + const result = resolver.pickupTimeSlots('2026-06-25'); + expect(result.date).toBe('2026-06-25'); + expect(result.morning).toHaveLength(4); + expect(result.afternoon).toHaveLength(16); + }); +}); diff --git a/src/features/pickup/resolvers/pickup-query.resolver.ts b/src/features/pickup/resolvers/pickup-query.resolver.ts new file mode 100644 index 0000000..18ab592 --- /dev/null +++ b/src/features/pickup/resolvers/pickup-query.resolver.ts @@ -0,0 +1,25 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { PickupSlotService } from '@/features/pickup/services/pickup-slot.service'; +import type { + PickupCalendar, + PickupTimeSlots, +} from '@/features/pickup/types/pickup-output.type'; + +/** + * 홈 픽업 슬롯 조회 resolver. 비로그인도 접근 가능한 public query. + */ +@Resolver('Query') +export class PickupQueryResolver { + constructor(private readonly pickupSlotService: PickupSlotService) {} + + @Query('pickupCalendar') + pickupCalendar(@Args('yearMonth') yearMonth: string): PickupCalendar { + return this.pickupSlotService.pickupCalendar(yearMonth); + } + + @Query('pickupTimeSlots') + pickupTimeSlots(@Args('date') date: string): PickupTimeSlots { + return this.pickupSlotService.pickupTimeSlots(date); + } +} diff --git a/src/features/pickup/services/pickup-slot.service.spec.ts b/src/features/pickup/services/pickup-slot.service.spec.ts new file mode 100644 index 0000000..7a44cb7 --- /dev/null +++ b/src/features/pickup/services/pickup-slot.service.spec.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common'; + +import { PickupSlotService } from '@/features/pickup/services/pickup-slot.service'; + +describe('PickupSlotService', () => { + const service = new PickupSlotService(); + + describe('pickupCalendar', () => { + it('형식 오류면 BadRequestException', () => { + expect(() => service.pickupCalendar('2026/06')).toThrow( + BadRequestException, + ); + expect(() => service.pickupCalendar('2026-13')).toThrow( + BadRequestException, + ); + }); + + it('월 일수만큼 days를 만들고 과거는 PAST로 막는다', () => { + const now = new Date('2026-06-18T01:00:00.000Z'); // KST 06-18 10:00 + const calendar = service.pickupCalendar('2026-06', now); + + expect(calendar.yearMonth).toBe('2026-06'); + expect(calendar.days).toHaveLength(30); // 6월은 30일 + + const past = calendar.days.find((d) => d.date === '2026-06-17'); + expect(past).toMatchObject({ selectable: false, reason: 'PAST' }); + + const today = calendar.days.find((d) => d.date === '2026-06-18'); + expect(today).toMatchObject({ selectable: true, reason: null }); + }); + + it('오늘+최대일수(30) 초과는 OUT_OF_RANGE', () => { + const now = new Date('2026-06-01T01:00:00.000Z'); // KST 06-01 + const calendar = service.pickupCalendar('2026-07', now); + + // 06-01 + 30일 = 07-01 까지 선택 가능, 07-02부터 범위 초과 + expect( + calendar.days.find((d) => d.date === '2026-07-01')?.selectable, + ).toBe(true); + expect(calendar.days.find((d) => d.date === '2026-07-02')).toMatchObject({ + selectable: false, + reason: 'OUT_OF_RANGE', + }); + }); + }); + + describe('pickupTimeSlots', () => { + it('형식 오류면 BadRequestException', () => { + expect(() => service.pickupTimeSlots('06-18')).toThrow( + BadRequestException, + ); + expect(() => service.pickupTimeSlots('2026-02-30')).toThrow( + BadRequestException, + ); + }); + + it('미래 날짜는 전 슬롯 available, 오전/오후를 구분한다', () => { + const now = new Date('2026-06-18T01:00:00.000Z'); + const slots = service.pickupTimeSlots('2026-06-25', now); + + expect(slots.date).toBe('2026-06-25'); + // 오전 10:00,10:30,11:00,11:30 (4), 오후 12:00~19:30 (16) + expect(slots.morning).toHaveLength(4); + expect(slots.afternoon).toHaveLength(16); + expect(slots.morning[0]).toEqual({ time: '10:00', available: true }); + expect(slots.afternoon[0].time).toBe('12:00'); + expect(slots.afternoon.at(-1)?.time).toBe('19:30'); + expect( + [...slots.morning, ...slots.afternoon].every((s) => s.available), + ).toBe(true); + }); + + it('당일은 현재시각+리드타임(60분) 이전 슬롯을 마감한다', () => { + const now = new Date('2026-06-18T04:00:00.000Z'); // KST 13:00 → cutoff 14:00 + const slots = service.pickupTimeSlots('2026-06-18', now); + const all = [...slots.morning, ...slots.afternoon]; + + expect(all.find((s) => s.time === '13:30')?.available).toBe(false); + expect(all.find((s) => s.time === '14:00')?.available).toBe(true); + }); + + it('과거 날짜는 전 슬롯 마감', () => { + const now = new Date('2026-06-18T01:00:00.000Z'); + const slots = service.pickupTimeSlots('2026-06-17', now); + + expect( + [...slots.morning, ...slots.afternoon].every((s) => !s.available), + ).toBe(true); + }); + + it('범위 초과 날짜는 전 슬롯 마감', () => { + const now = new Date('2026-06-01T01:00:00.000Z'); + const slots = service.pickupTimeSlots('2026-07-15', now); // +44일 + + expect( + [...slots.morning, ...slots.afternoon].every((s) => !s.available), + ).toBe(true); + }); + }); +}); diff --git a/src/features/pickup/services/pickup-slot.service.ts b/src/features/pickup/services/pickup-slot.service.ts new file mode 100644 index 0000000..dfa148f --- /dev/null +++ b/src/features/pickup/services/pickup-slot.service.ts @@ -0,0 +1,107 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { + formatKstDate, + formatMinutesOfDay, + kstDayDiff, + kstMidnightUtc, + kstMinutesOfDay, + parseKstDate, + parseKstYearMonth, +} from '@/common/utils/kst-time'; +import { + PICKUP_DAY_REASON, + PICKUP_ERRORS, +} from '@/features/pickup/constants/pickup-error-messages'; +import { + PICKUP_AFTERNOON_START_MINUTES, + PICKUP_CLOSE_MINUTES, + PICKUP_MAX_DAYS_AHEAD, + PICKUP_MIN_LEAD_MINUTES, + PICKUP_OPEN_MINUTES, + PICKUP_SLOT_INTERVAL_MINUTES, +} from '@/features/pickup/constants/pickup.constants'; +import type { + PickupCalendar, + PickupSlot, + PickupTimeSlots, +} from '@/features/pickup/types/pickup-output.type'; + +@Injectable() +export class PickupSlotService { + /** + * 월별 픽업 가능 날짜. KST 기준 과거는 PAST, 오늘+최대일수 초과는 OUT_OF_RANGE로 + * 선택 불가 처리한다. now는 테스트 주입용. + */ + pickupCalendar(yearMonth: string, now: Date = new Date()): PickupCalendar { + const ym = parseKstYearMonth(yearMonth); + if (!ym) { + throw new BadRequestException(PICKUP_ERRORS.INVALID_YEAR_MONTH); + } + + const daysInMonth = new Date(Date.UTC(ym.year, ym.month, 0)).getUTCDate(); + + const days = Array.from({ length: daysInMonth }, (_, index) => { + const day = index + 1; + const date = kstMidnightUtc(ym.year, ym.month, day); + const diff = kstDayDiff(now, date); + + if (diff < 0) { + return { + date: formatKstDate(date), + selectable: false, + reason: PICKUP_DAY_REASON.PAST, + }; + } + if (diff > PICKUP_MAX_DAYS_AHEAD) { + return { + date: formatKstDate(date), + selectable: false, + reason: PICKUP_DAY_REASON.OUT_OF_RANGE, + }; + } + return { date: formatKstDate(date), selectable: true, reason: null }; + }); + + return { yearMonth, days }; + } + + /** + * 특정 날짜의 시간 슬롯(오전/오후). 당일은 현재시각+리드타임 이전 슬롯을 마감, + * 과거/범위 초과 날짜는 전부 마감 처리한다. now는 테스트 주입용. + */ + pickupTimeSlots(date: string, now: Date = new Date()): PickupTimeSlots { + const parsed = parseKstDate(date); + if (!parsed) { + throw new BadRequestException(PICKUP_ERRORS.INVALID_DATE); + } + + const diff = kstDayDiff(now, parsed); + const outOfRange = diff < 0 || diff > PICKUP_MAX_DAYS_AHEAD; + const isToday = diff === 0; + const cutoffMinutes = isToday + ? kstMinutesOfDay(now) + PICKUP_MIN_LEAD_MINUTES + : Number.NEGATIVE_INFINITY; + + const morning: PickupSlot[] = []; + const afternoon: PickupSlot[] = []; + + for ( + let minutes = PICKUP_OPEN_MINUTES; + minutes < PICKUP_CLOSE_MINUTES; + minutes += PICKUP_SLOT_INTERVAL_MINUTES + ) { + const slot: PickupSlot = { + time: formatMinutesOfDay(minutes), + available: !outOfRange && minutes >= cutoffMinutes, + }; + if (minutes < PICKUP_AFTERNOON_START_MINUTES) { + morning.push(slot); + } else { + afternoon.push(slot); + } + } + + return { date, morning, afternoon }; + } +} diff --git a/src/features/pickup/types/pickup-output.type.ts b/src/features/pickup/types/pickup-output.type.ts new file mode 100644 index 0000000..bbd124f --- /dev/null +++ b/src/features/pickup/types/pickup-output.type.ts @@ -0,0 +1,26 @@ +/** + * pickup resolver 반환용 도메인 출력 타입. + * SDL(pickup.types.graphql)과 필드 일치. + */ + +export interface PickupDay { + date: string; // "YYYY-MM-DD" + selectable: boolean; + reason: string | null; // PAST | OUT_OF_RANGE (선택 가능 시 null) +} + +export interface PickupCalendar { + yearMonth: string; // "YYYY-MM" + days: PickupDay[]; +} + +export interface PickupSlot { + time: string; // "HH:MM" + available: boolean; +} + +export interface PickupTimeSlots { + date: string; + morning: PickupSlot[]; + afternoon: PickupSlot[]; +} From 334e8e64ccaa26d73a32313c7270843ccd72b765 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 18 Jun 2026 20:58:50 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(pickup=20=EB=8B=B9?= =?UTF-8?q?=EC=9D=BC=20=EB=A7=88=EA=B0=90=20CLOSED,=20region=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=20upsert=20=EC=A0=95=EA=B7=9C=ED=99=94,=20keyword=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/seed/regions.ts | 3 +++ .../pickup/constants/pickup-error-messages.ts | 1 + .../pickup/services/pickup-slot.service.spec.ts | 10 ++++++++++ src/features/pickup/services/pickup-slot.service.ts | 13 +++++++++++++ .../region/dto/inputs/search-regions.input.spec.ts | 6 ++++++ 5 files changed, 33 insertions(+) diff --git a/prisma/seed/regions.ts b/prisma/seed/regions.ts index 1e63d04..42139d4 100644 --- a/prisma/seed/regions.ts +++ b/prisma/seed/regions.ts @@ -39,6 +39,8 @@ export async function seedRegions(prisma: PrismaClient): Promise { sort_order: group.sortOrder, }, update: { + level: 1, + parent_id: null, name: group.name, sort_order: group.sortOrder, is_active: true, @@ -65,6 +67,7 @@ export async function seedRegions(prisma: PrismaClient): Promise { parent_id: parentId, }, update: { + level: 2, name: child.name, sort_order: child.sortOrder, parent_id: parentId, diff --git a/src/features/pickup/constants/pickup-error-messages.ts b/src/features/pickup/constants/pickup-error-messages.ts index 0f22905..71c2efa 100644 --- a/src/features/pickup/constants/pickup-error-messages.ts +++ b/src/features/pickup/constants/pickup-error-messages.ts @@ -7,4 +7,5 @@ export const PICKUP_ERRORS = { export const PICKUP_DAY_REASON = { PAST: 'PAST', OUT_OF_RANGE: 'OUT_OF_RANGE', + CLOSED: 'CLOSED', // 당일이지만 현재시각+리드타임으로 가용 슬롯이 없음 } as const; diff --git a/src/features/pickup/services/pickup-slot.service.spec.ts b/src/features/pickup/services/pickup-slot.service.spec.ts index 7a44cb7..094ce07 100644 --- a/src/features/pickup/services/pickup-slot.service.spec.ts +++ b/src/features/pickup/services/pickup-slot.service.spec.ts @@ -42,6 +42,16 @@ describe('PickupSlotService', () => { reason: 'OUT_OF_RANGE', }); }); + + it('오늘이지만 가용 슬롯이 없으면 CLOSED로 막는다', () => { + // KST 19:00 + 리드 60분 = cutoff 20:00 > 마지막 슬롯 19:30 → 당일 슬롯 없음 + const now = new Date('2026-06-18T10:00:00.000Z'); // KST 06-18 19:00 + const calendar = service.pickupCalendar('2026-06', now); + expect(calendar.days.find((d) => d.date === '2026-06-18')).toMatchObject({ + selectable: false, + reason: 'CLOSED', + }); + }); }); describe('pickupTimeSlots', () => { diff --git a/src/features/pickup/services/pickup-slot.service.ts b/src/features/pickup/services/pickup-slot.service.ts index dfa148f..0fa7157 100644 --- a/src/features/pickup/services/pickup-slot.service.ts +++ b/src/features/pickup/services/pickup-slot.service.ts @@ -41,6 +41,12 @@ export class PickupSlotService { const daysInMonth = new Date(Date.UTC(ym.year, ym.month, 0)).getUTCDate(); + // 오늘은 현재시각+리드타임 이후 가용 슬롯이 하나라도 있어야 선택 가능 + // (없으면 pickupTimeSlots가 전부 마감 → 캘린더도 선택 불가로 일치시킨다) + const cutoffMinutes = kstMinutesOfDay(now) + PICKUP_MIN_LEAD_MINUTES; + const lastSlotMinutes = PICKUP_CLOSE_MINUTES - PICKUP_SLOT_INTERVAL_MINUTES; + const todayHasSlot = cutoffMinutes <= lastSlotMinutes; + const days = Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; const date = kstMidnightUtc(ym.year, ym.month, day); @@ -60,6 +66,13 @@ export class PickupSlotService { reason: PICKUP_DAY_REASON.OUT_OF_RANGE, }; } + if (diff === 0 && !todayHasSlot) { + return { + date: formatKstDate(date), + selectable: false, + reason: PICKUP_DAY_REASON.CLOSED, + }; + } return { date: formatKstDate(date), selectable: true, reason: null }; }); diff --git a/src/features/region/dto/inputs/search-regions.input.spec.ts b/src/features/region/dto/inputs/search-regions.input.spec.ts index 0e0625f..b40aeae 100644 --- a/src/features/region/dto/inputs/search-regions.input.spec.ts +++ b/src/features/region/dto/inputs/search-regions.input.spec.ts @@ -45,4 +45,10 @@ describe('SearchRegionsInput', () => { const errors = await validate(build({ keyword: 'a', limit: 1.5 })); expect(errors[0].property).toBe('limit'); }); + + it('keyword 최대 길이 경계(80 통과, 81 거절)', async () => { + expect(await validate(build({ keyword: 'a'.repeat(80) }))).toHaveLength(0); + const errors = await validate(build({ keyword: 'a'.repeat(81) })); + expect(errors[0].property).toBe('keyword'); + }); });